1import { LOCALE_AVAILABILITY, LOCALE_REGISTRY } from './locale-profiles.9915b2d28745.js'; 2 3const DOCUMENT_COPY = Object.freeze({ 4 en: Object.freeze({}), 5 da: Object.freeze({ 6 Invoice: 'Faktura', Quote: 'Tilbud', Estimate: 'Prisoverslag', Receipt: 'Kvittering', 'Credit Note': 'Kreditnota', 7 Issued: 'Udstedt', Due: 'Forfalder', 'Valid until': 'Gyldig til', From: 'Fra', 'Bill To': 'Til', 8 'Received From': 'Modtaget fra', 'Credit To': 'Krediteres til', 'Prepared For': 'Til', Description: 'Beskrivelse', 9 Qty: 'Antal', 'Unit Price': 'Enhedspris', Tax: 'Moms', 'Sales tax': 'Moms', Amount: 'Beløb', Subtotal: 'Subtotal', 10 Discount: 'Rabat', Fees: 'Gebyrer', 'Quoted Total': 'Tilbud i alt', 'Estimated Total': 'Estimat i alt', 11 'Total Paid': 'Betalt i alt', 'Credit Total': 'Kredit i alt', 'Total Due': 'Beløb i alt', Terms: 'Betalingsbetingelser', 12 Account: 'Konto', Address: 'Adresse', Email: 'E-mail', Phone: 'Telefon', 'Tax ID': 'Skatte-ID', Delivery: 'Levering', 13 Place: 'Sted', 'Scan for contact': 'Scan for kontakt', 'Scan to pay': 'Scan for at betale', 14 'Made with InvoiceCraftly.com': 'Lavet med InvoiceCraftly.com', 'Due on Receipt': 'Betales ved modtagelse', 15 'Net 7': 'Netto 7 dage', 'Net 14': 'Netto 14 dage', 'Net 30': 'Netto 30 dage', 16 'Valid for 7 days': 'Gyldig i 7 dage', 'Valid for 14 days': 'Gyldig i 14 dage', 17 'Valid for 30 days': 'Gyldig i 30 dage', 'Valid until accepted': 'Gyldig indtil accept' 18 }), 19 nb: Object.freeze({ 20 Invoice: 'Faktura', Quote: 'Tilbud', Estimate: 'Prisoverslag', Receipt: 'Kvittering', 'Credit Note': 'Kreditnota', 21 Issued: 'Utstedt', Due: 'Forfaller', 'Valid until': 'Gyldig til', From: 'Fra', 'Bill To': 'Til', 22 'Received From': 'Mottatt fra', 'Credit To': 'Krediteres til', 'Prepared For': 'Til', Description: 'Beskrivelse', 23 Qty: 'Antall', 'Unit Price': 'Enhetspris', Tax: 'MVA', 'Sales tax': 'MVA', Amount: 'Beløp', Subtotal: 'Delsum', 24 Discount: 'Rabatt', Fees: 'Gebyrer', 'Quoted Total': 'Tilbud totalt', 'Estimated Total': 'Estimert total', 25 'Total Paid': 'Betalt totalt', 'Credit Total': 'Kreditert totalt', 'Total Due': 'Til betaling', Terms: 'Betalingsbetingelser', 26 Account: 'Konto', Address: 'Adresse', Email: 'E-post', Phone: 'Telefon', 'Tax ID': 'Skatte-ID', Delivery: 'Levering', 27 Place: 'Sted', 'Scan for contact': 'Skann for kontakt', 'Scan to pay': 'Skann for å betale', 28 'Made with InvoiceCraftly.com': 'Laget med InvoiceCraftly.com', 'Due on Receipt': 'Betales ved mottak', 29 'Net 7': 'Netto 7 dager', 'Net 14': 'Netto 14 dager', 'Net 30': 'Netto 30 dager', 30 'Valid for 7 days': 'Gyldig i 7 dager', 'Valid for 14 days': 'Gyldig i 14 dager', 31 'Valid for 30 days': 'Gyldig i 30 dager', 'Valid until accepted': 'Gyldig til akseptert' 32 }), 33 sv: Object.freeze({ 34 Invoice: 'Faktura', Quote: 'Offert', Estimate: 'Kostnadsförslag', Receipt: 'Kvitto', 'Credit Note': 'Kreditnota', 35 Issued: 'Utfärdad', Due: 'Förfaller', 'Valid until': 'Giltig till', From: 'Från', 'Bill To': 'Till', 36 'Received From': 'Mottaget från', 'Credit To': 'Krediteras till', 'Prepared For': 'Till', Description: 'Beskrivning', 37 Qty: 'Antal', 'Unit Price': 'Enhetspris', Tax: 'Moms', 'Sales tax': 'Moms', Amount: 'Belopp', Subtotal: 'Delsumma', 38 Discount: 'Rabatt', Fees: 'Avgifter', 'Quoted Total': 'Offert totalt', 'Estimated Total': 'Kostnadsförslag totalt', 39 'Total Paid': 'Betalt totalt', 'Credit Total': 'Krediterat totalt', 'Total Due': 'Att betala', Terms: 'Betalningsvillkor', 40 Account: 'Konto', Address: 'Adress', Email: 'E-post', Phone: 'Telefon', 'Tax ID': 'Organisations-/skattenummer', Delivery: 'Leverans', 41 Place: 'Plats', 'Scan for contact': 'Skanna för kontakt', 'Scan to pay': 'Skanna för betalning', 42 'Made with InvoiceCraftly.com': 'Skapad med InvoiceCraftly.com', 'Due on Receipt': 'Betalas vid mottagande', 43 'Net 7': 'Netto 7 dagar', 'Net 14': 'Netto 14 dagar', 'Net 30': 'Netto 30 dagar', 44 'Valid for 7 days': 'Giltig i 7 dagar', 'Valid for 14 days': 'Giltig i 14 dagar', 45 'Valid for 30 days': 'Giltig i 30 dagar', 'Valid until accepted': 'Giltig tills den accepteras' 46 }), 47 fi: Object.freeze({ 48 Invoice: 'Lasku', Quote: 'Tarjous', Estimate: 'Kustannusarvio', Receipt: 'Kuitti', 'Credit Note': 'Hyvityslasku', 49 Issued: 'Päiväys', Due: 'Eräpäivä', 'Valid until': 'Voimassa asti', From: 'Myyjä', 'Bill To': 'Asiakas', 50 'Received From': 'Maksaja', 'Credit To': 'Hyvityksen saaja', 'Prepared For': 'Asiakas', Description: 'Kuvaus', 51 Qty: 'Määrä', 'Unit Price': 'Yksikköhinta', Tax: 'ALV', 'Sales tax': 'ALV', Amount: 'Summa', Subtotal: 'Välisumma', 52 Discount: 'Alennus', Fees: 'Lisämaksut', 'Quoted Total': 'Tarjous yhteensä', 'Estimated Total': 'Kustannusarvio yhteensä', 53 'Total Paid': 'Maksettu yhteensä', 'Credit Total': 'Hyvitys yhteensä', 'Total Due': 'Maksettava yhteensä', Terms: 'Maksuehdot', 54 Account: 'Tili', Address: 'Osoite', Email: 'Sähköposti', Phone: 'Puhelin', 'Tax ID': 'Y-tunnus / verotunniste', Delivery: 'Toimitus', 55 Place: 'Paikka', 'Scan for contact': 'Skannaa yhteystiedot', 'Scan to pay': 'Skannaa ja maksa', 56 'Made with InvoiceCraftly.com': 'Luotu InvoiceCraftly.com-palvelulla', 'Due on Receipt': 'Maksetaan heti', 57 'Net 7': 'Netto 7 päivää', 'Net 14': 'Netto 14 päivää', 'Net 30': 'Netto 30 päivää', 58 'Valid for 7 days': 'Voimassa 7 päivää', 'Valid for 14 days': 'Voimassa 14 päivää', 59 'Valid for 30 days': 'Voimassa 30 päivää', 'Valid until accepted': 'Voimassa hyväksymiseen asti' 60 }) 61}); 62 63const USER_CONTENT_SELECTOR = [ 64 '.pv-wordmark', 65 '.pv-party-name', 66 '.pv-contact-text', 67 '.pv-notes', 68 '.pv-table tbody td' 69].join(', '); 70 71const DOCUMENT_PAGE_SELECTOR = '.page[data-document-locale]'; 72const DOCUMENT_LANGUAGE_CONTROL_SELECTOR = '[data-path="localization.documentLocale"]'; 73const VISUAL_EXPORT_TRIGGER_SELECTOR = '#btn-export, #btn-pdf, #btn-png, #btn-svg'; 74 75function normalizeUiLocale(locale = 'en') {
76 const value = String(locale || 'en').toLowerCase(); 77 if (value.startsWith('da')) return 'da'; 78 if (value.startsWith('nb') || value.startsWith('no')) return 'nb'; 79 if (value.startsWith('sv')) return 'sv'; 80 if (value.startsWith('fi')) return 'fi'; 81 return 'en'; 82} 83 84function normalizeDocumentLocale(locale = 'en') { 85 const value = String(locale || 'en').toLowerCase(); 86 if (value.startsWith('da')) return 'da'; 87 if (value.startsWith('nb') || value.startsWith('no')) return 'nb'; 88 if (value.startsWith('sv')) return 'sv'; 89 if (value.startsWith('fi')) return 'fi'; 90 return 'en'; 91} 92 93function localeVisibleInPicker(locale, uiLocale) { 94 if (locale.availability === LOCALE_AVAILABILITY.ACTIVE) return true; 95 // A foundation locale is visible only inside its own isolated market preview. 96 // This lets Finland exercise native document rendering before public release 97 // without adding Finnish to every production editor language picker. 98 return locale.availability === LOCALE_AVAILABILITY.FOUNDATION && locale.id === uiLocale; 99} 100 101export function getDocumentLanguageOptions(uiLocale = 'en') { 102 const ui = normalizeUiLocale(uiLocale); 103 return Object.freeze( 104 Object.values(LOCALE_REGISTRY) 105 .filter((locale) => localeVisibleInPicker(locale, ui)) 106 .map((locale) => Object.freeze({ 107 value: locale.documentLocale, 108 label: locale.displayNames?.[ui] || locale.displayNames?.en || locale.documentLocale, 109 languageTag: locale.languageTag 110 })) 111 ); 112} 113 114function activeDocumentLocale(locale, uiLocale = 'en') { 115 const normalized = normalizeDocumentLocale(locale); 116 return getDocumentLanguageOptions(uiLocale).some((option) => option.value === normalized) ? normalized : 'en'; 117} 118 119function syncDocumentLanguageControl(control, selectedLocale = '') { 120 if (!control?.matches?.(DOCUMENT_LANGUAGE_CONTROL_SELECTOR)) return; 121 const uiLocale = normalizeUiLocale(document.documentElement.lang || 'en'); 122 const options = getDocumentLanguageOptions(uiLocale); 123 const selected = activeDocumentLocale(selectedLocale || control.value || 'en', uiLocale); 124 const signature = `${uiLocale}:${options.map((option) => option.value).join(',')}`; 125 const currentValues = [...control.options].map((option) => option.value).join(','); 126 const desiredValues = options.map((option) => option.value).join(','); 127 128 if (control.dataset.documentLanguageOptions !== signature || currentValues !== desiredValues) { 129 const fragment = document.createDocumentFragment(); 130 options.forEach((option) => { 131 const element = document.createElement('option'); 132 element.value = option.value; 133 element.textContent = option.label; 134 element.lang = option.languageTag; 135 fragment.appendChild(element); 136 }); 137 control.replaceChildren(fragment); 138 control.dataset.documentLanguageOptions = signature; 139 } else { 140 options.forEach((option, index) => { 141 const element = control.options[index]; 142 if (!element) return; 143 element.textContent = option.label; 144 element.lang = option.languageTag; 145 }); 146 } 147 control.value = selected; 148} 149 150function renderedDocumentLocale(root = document) { 151 const page = root.querySelector?.(DOCUMENT_PAGE_SELECTOR) || document.querySelector(DOCUMENT_PAGE_SELECTOR); 152 return page?.dataset?.documentLocale || ''; 153} 154 155function syncDocumentLanguageControls(root = document, selectedLocale = '') { 156 const controls = []; 157 if (root?.matches?.(DOCUMENT_LANGUAGE_CONTROL_SELECTOR)) controls.push(root); 158 root?.querySelectorAll?.(DOCUMENT_LANGUAGE_CONTROL_SELECTOR).forEach((control) => controls.push(control)); 159 const preferred = selectedLocale || renderedDocumentLocale(root); 160 controls.forEach((control) => syncDocumentLanguageControl(control, preferred)); 161} 162 163export function getDocumentLanguage(invoice) { 164 return normalizeDocumentLocale(invoice?.localization?.documentLocale || 'en'); 165} 166 167export function translateDocumentText(invoice, value) { 168 const text = String(value ?? ''); 169 if (!text) return text; 170 const language = getDocumentLanguage(invoice); 171 return DOCUMENT_COPY[language]?.[text] || text; 172} 173 174function translateRenderedText(invoice, value) { 175 const leading = value.match(/^\s*/)?.[0] || ''; 176 const trailing = value.match(/\s*$/)?.[0] || ''; 177 const core = value.trim(); 178 if (!core) return value; 179 180 const direct = translateDocumentText(invoice, core); 181 if (direct !== core) return `${leading}${direct}${trailing}`; 182 183 const colonLabel = core.match(/^([^:]+):$/); 184 if (colonLabel) { 185 const translated = translateDocumentText(invoice, colonLabel[1]); 186 if (translated !== colonLabel[1]) return `${leading}${translated}:${trailing}`; 187 } 188 189 for (const prefix of ['Issued', 'Due', 'Valid until']) { 190 if (core.startsWith(`${prefix} `)) { 191 return `${leading}${translateDocumentText(invoice, prefix)} ${core.slice(prefix.length + 1)}${trailing}`; 192 } 193 } 194 195 for (const prefix of ['Terms', 'Account']) { 196 if (core.startsWith(`${prefix}: `)) { 197 const remainder = core.slice(prefix.length + 2); 198 return `${leading}${translateDocumentText(invoice, prefix)}: ${translateDocumentText(invoice, remainder)}${trailing}`; 199 } 200 } 201 202 const taxBreakdown = core.match(/^(.+?) @ (.+)$/); 203 if (taxBreakdown) { 204 const translatedLabel = translateDocumentText(invoice, taxBreakdown[1]); 205 if (translatedLabel !== taxBreakdown[1]) return `${leading}${translatedLabel} @ ${taxBreakdown[2]}${trailing}`; 206 } 207 208 return value; 209} 210 211export function applyDocumentLanguagePresentation(root, invoice) { 212 if (!root || getDocumentLanguage(invoice) === 'en') return; 213 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); 214 const textNodes = []; 215 while (walker.nextNode()) textNodes.push(walker.currentNode);
216 textNodes.forEach((node) => { 217 const element = node.parentElement; 218 if (!element || element.closest(USER_CONTENT_SELECTOR)) return; 219 const translated = translateRenderedText(invoice, node.nodeValue || ''); 220 if (translated !== node.nodeValue) node.nodeValue = translated; 221 }); 222 223 root.querySelectorAll('[aria-label], [title]').forEach((element) => { 224 ['aria-label', 'title'].forEach((attribute) => { 225 if (!element.hasAttribute(attribute)) return; 226 const value = element.getAttribute(attribute) || ''; 227 const translated = translateRenderedText(invoice, value); 228 if (translated !== value) element.setAttribute(attribute, translated); 229 }); 230 }); 231} 232 233function applyToPage(page) { 234 if (!page?.matches?.(DOCUMENT_PAGE_SELECTOR)) return; 235 const documentLocale = page.dataset.documentLocale || 'en'; 236 applyDocumentLanguagePresentation(page, { 237 localization: { documentLocale } 238 }); 239 syncDocumentLanguageControls(document, documentLocale); 240} 241 242function applyWithin(node) { 243 if (node?.nodeType !== Node.ELEMENT_NODE) return; 244 if (node.matches(DOCUMENT_PAGE_SELECTOR)) applyToPage(node); 245 node.querySelectorAll?.(DOCUMENT_PAGE_SELECTOR).forEach(applyToPage); 246 const containingPage = node.closest?.(DOCUMENT_PAGE_SELECTOR); 247 if (containingPage) applyToPage(containingPage); 248 syncDocumentLanguageControls(node, renderedDocumentLocale()); 249} 250 251function applyAllRenderedPages() { 252 document.querySelectorAll(DOCUMENT_PAGE_SELECTOR).forEach(applyToPage); 253 syncDocumentLanguageControls(document, renderedDocumentLocale()); 254} 255 256/** 257 * Document language is a property of the document, not of the editor route. 258 * Install once for every editor so an English UI can preview/export a Danish, 259 * Norwegian or Swedish document and localized UI can still produce English. 260 * Foundation locales may render only inside their own isolated preview until 261 * promoted to ACTIVE. 262 * 263 * The language picker is also hydrated here from the locale registry so UI 264 * choices and renderer capability cannot drift apart as markets are added. 265 */ 266export function installDocumentLanguagePresentation() { 267 applyAllRenderedPages(); 268 const observer = new MutationObserver((mutations) => { 269 mutations.forEach((mutation) => { 270 if (mutation.type === 'attributes') { 271 applyWithin(mutation.target); 272 return; 273 } 274 mutation.addedNodes.forEach(applyWithin); 275 }); 276 }); 277 observer.observe(document.body, { 278 childList: true, 279 subtree: true, 280 attributes: true, 281 attributeFilter: ['data-document-locale'] 282 }); 283 284 document.addEventListener('click', (event) => { 285 if (event.target.closest?.(VISUAL_EXPORT_TRIGGER_SELECTOR)) applyAllRenderedPages(); 286 }, { capture: true }); 287 288 return observer; 289}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.