diff --git a/tools/translate/app.css b/tools/translate/app.css new file mode 100644 index 00000000..03e0ae15 --- /dev/null +++ b/tools/translate/app.css @@ -0,0 +1,207 @@ +@import url('./shared.css'); + +body { + align-items: flex-start; +} + +.app-container { + width: 100%; + max-width: 1200px; +} + +.app-form { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.app-input { + display: grid; + gap: 12px; + grid-template-columns: 1fr; + align-items: start; +} + +.app-input-loader { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-out; + overflow: hidden; +} + +.app-input-loader.open { + grid-template-rows: 1fr; +} + +.app-input-loader-content { + min-height: 0; + display: flex; + gap: 12px; + align-items: center; + padding-bottom: 0; + transition: padding-bottom 0.3s ease-out; + flex-wrap: wrap; +} + +.app-input-loader.open .app-input-loader-content { + padding-bottom: 12px; +} + +.app-input-loader input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + font-size: 14px; + color: var(--text); + background: #fff; +} + +.app-input-loader input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +.app-output { + display: grid; + gap: 12px; +} + +.app-output-list { + list-style: none; + padding: 0; + margin: 0; +} + +.app-output-list li { + display: flex; + align-items: center; + gap: 12px; + position: relative; + padding-left: 16px; +} + +.app-li-number { + font-weight: bold; + margin-right: 8px; +} + +textarea { + width: 100%; + min-height: 200px; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; + font-size: 15px; + line-height: 1.5; + color: var(--text); + background: #fff; + box-shadow: none; +} + +select { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea:focus, +select:focus, +button:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +button { + border: none; + border-radius: var(--radius-sm); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease; + background: var(--primary); + color: #fff; + box-shadow: none; +} + +button:hover { + background: var(--primary-dark); +} + +.status { + font-size: 14px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.status.loading, +.status.translating { + color: #0052cc; + background: #deebff; +} + +.status.translated, +.status.saved { + color: #064; + background: #e3fcef; +} + +.status.error { + color: #bf2600; + background: #ffebe6; +} + +.status svg { + vertical-align: middle; + margin-right: 4px; + margin-left: 4px; + width: 16px; + height: 16px; +} + +.app-error { + color: #c62828; + font-size: 13px; + min-height: 16px; + display: none; + margin-top: 4px; +} + +.app-folder-loader-error { + color: #c62828; + font-size: 13px; + flex-basis: 100%; + margin-top: 4px; + display: none; +} + +@media (width <= 720px) { + .app-form { + padding: 20px; + } + + .app-input { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} diff --git a/tools/translate/app.html b/tools/translate/app.html new file mode 100644 index 00000000..7c880779 --- /dev/null +++ b/tools/translate/app.html @@ -0,0 +1,39 @@ + + + + Translation tool + + + + + +
+

Translation tool

+

Enter list of URLs to translate or load from folder

+
+
+
+
+ + +
+
+
+ + + +
+
+
+
    +
    +
    +
    + + \ No newline at end of file diff --git a/tools/translate/app.js b/tools/translate/app.js new file mode 100644 index 00000000..93d5bed0 --- /dev/null +++ b/tools/translate/app.js @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +/* eslint-disable no-continue */ +/* eslint-disable no-await-in-loop */ + +// eslint-disable-next-line import/no-unresolved +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { translate, ADMIN_URL } from './shared.js'; + +const EDIT_ICON_SVG = ''; + +(async function init() { + const { context, actions } = await DA_SDK; + const { daFetch } = actions; + + const urlsTextarea = document.querySelector('textarea[name="urls"]'); + const languageSelect = document.querySelector('select[name="language"]'); + const translateButton = document.querySelector('button[name="translate"]'); + const outputList = document.querySelector('.app-output-list'); + const errorMessage = document.querySelector('.app-error'); + const loadFromFolderLink = document.querySelector('#load-from-folder'); + const loaderRow = document.querySelector('.app-input-loader'); + const folderInput = document.querySelector('input[name="folder"]'); + const loadFromFolderButton = document.querySelector('button[name="load-from-folder"]'); + const folderLoaderErrorMessage = document.querySelector('.app-folder-loader-error'); + + loadFromFolderLink.addEventListener('click', (e) => { + e.preventDefault(); + folderInput.value = `https://da.live/#/${context.org}/${context.repo}/drafts`; + loaderRow.classList.toggle('open'); + }); + + loadFromFolderButton.addEventListener('click', async (e) => { + folderLoaderErrorMessage.textContent = ''; + folderLoaderErrorMessage.style.display = 'none'; + + e.preventDefault(); + const folder = folderInput.value; + let url; + try { + url = new URL(folder); + } catch (error) { + folderLoaderErrorMessage.textContent = `Invalid folder URL: ${error.message}`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + if (!folder.startsWith(`https://da.live/#/${context.org}/${context.repo}`)) { + folderLoaderErrorMessage.textContent = `Folder URL must be a valid da.live folder URL - example: https://da.live/#/${context.org}/${context.repo}/...`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + const pathname = url.hash.replace(`#/${context.org}/${context.repo}`, ''); + const listUrl = `${ADMIN_URL}/list/${context.org}/${context.repo}${pathname}`; + const resp = await daFetch(listUrl); + if (!resp.ok) { + folderLoaderErrorMessage.textContent = `Failed to load folder list: ${resp.statusText}`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + const list = await resp.json(); + urlsTextarea.value = list.filter((item) => item.ext === 'html').map((item) => `https://da.live/edit#${item.path.replace(/\.html$/, '')}`).join('\n'); + loaderRow.classList.toggle('open'); + }); + + const updateStatus = (listItem, status, text) => { + let statusEl = listItem.querySelector('.status'); + if (!statusEl) { + statusEl = document.createElement('span'); + statusEl.className = 'status'; + listItem.appendChild(statusEl); + } + statusEl.className = `status ${status}`; + statusEl.innerHTML = text; + }; + + translateButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (errorMessage) { + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; + } + const urls = urlsTextarea.value.split('\n').filter((url) => url.trim() !== ''); + if (urls.length === 0) { + errorMessage.textContent = 'Please enter a list of URLs to translate'; + errorMessage.style.display = 'block'; + return; + } + + outputList.innerHTML = ''; + + // eslint-disable-next-line no-restricted-syntax + for (let i = 0; i < urls.length; i += 1) { + // Add a numbered badge to the left of the list item + const badge = document.createElement('span'); + badge.className = 'app-li-number'; + badge.textContent = `${i + 1}.`; + badge.style.fontWeight = 'bold'; + badge.style.marginRight = '8px'; + + const listItem = document.createElement('li'); + const a = document.createElement('a'); + a.href = urls[i]; + a.textContent = urls[i]; + listItem.appendChild(a); + listItem.insertBefore(badge, listItem.firstChild); + outputList.appendChild(listItem); + + let url; + try { + url = new URL(urls[i]); + } catch (error) { + updateStatus(listItem, 'error', 'Invalid URL format'); + continue; + } + + // Validate URL format + // Expected: https://----. or https://da.live/edit#// + const isDaLiveEditUrl = url.toString().startsWith(`https://da.live/edit#/${context.org}/${context.repo}/`); + const isPreviewUrl = url.hostname.includes(`${context.repo}--${context.org}`); + if (!isPreviewUrl && !isDaLiveEditUrl) { + updateStatus(listItem, 'error', 'Must be a URL from this organization and repository'); + } else { + updateStatus(listItem, 'loading', 'Loading'); + + try { + const resourcePath = isDaLiveEditUrl ? url.hash.replace(/^#/, '').replace(`/${context.org}/${context.repo}`, '') : url.pathname; + let sourceUrl = `${ADMIN_URL}/source/${context.org}/${context.repo}${resourcePath}`; + + // if needed, append .html + if (!sourceUrl.endsWith('.html')) { + sourceUrl += '.html'; + } + + let resp = await daFetch(sourceUrl); + if (!resp.ok) { + updateStatus(listItem, 'error', `Page content cannot be retrieved: (${resp.statusText})`); + continue; + } + const html = await resp.text(); + + updateStatus(listItem, 'translating', 'Translating'); + + const translatedHtml = await translate( + html, + languageSelect.value, + context, + undefined, // could be both + daFetch, + ); + + updateStatus(listItem, 'translated', 'Translated'); + + const blob = new Blob([translatedHtml], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + const opts = { method: 'PUT', body: formData }; + resp = await daFetch(sourceUrl, opts); + if (!resp.ok) { + updateStatus(listItem, 'error', `Failed to save translated HTML: (${resp.statusText})`); + } + const daHref = `https://da.live/edit#/${context.org}/${context.repo}${resourcePath}`; + updateStatus(listItem, 'saved', `Translated page saved! ${EDIT_ICON_SVG}`); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error retrieving page content', error); + updateStatus(listItem, 'error', `Page content cannot be retrieved: (${error.message || 'Check console for details'})`); + } + } + } + }); +}()); diff --git a/tools/translate/plugin.css b/tools/translate/plugin.css new file mode 100644 index 00000000..a86838fb --- /dev/null +++ b/tools/translate/plugin.css @@ -0,0 +1,122 @@ +@import url('./shared.css'); + +.translate-container { + width: min(560px, 100%); +} + +.translate-form { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.translate-input, +.translate-output { + display: grid; + gap: 12px; +} + +.translate-error { + color: #c62828; + font-size: 13px; + min-height: 16px; + display: none; +} + +.translate-input { + grid-template-columns: minmax(0, 1fr) 200px; + align-items: start; +} + +textarea { + width: 100%; + min-height: 120px; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; + font-size: 15px; + line-height: 1.5; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea[name="output"] { + background: #f7f8fa; +} + +select { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea:focus, +select:focus, +button:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +button { + border: none; + border-radius: var(--radius-sm); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +button[name="translate"] { + background: var(--primary); + color: #fff; + box-shadow: none; +} + +button[name="translate"]:hover { + background: var(--primary-dark); +} + +button[name="replace"] { + background: #f3f4f6; + color: var(--text); + border: 1px solid var(--border); +} + +button[name="replace"]:hover { + background: #e7eaee; +} + +@media (width <= 720px) { + .translate-form { + padding: 20px; + } + + .translate-input { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} + +@media (width <= 500px) { + .translate-input textarea, + .translate-output textarea, + button[name="replace"] { + display: none; + } +} \ No newline at end of file diff --git a/tools/translate/plugin.html b/tools/translate/plugin.html new file mode 100644 index 00000000..bad8087e --- /dev/null +++ b/tools/translate/plugin.html @@ -0,0 +1,32 @@ + + + + Translation plugin + + + + + + +
    +
    +
    + + +
    +
    + +
    + +
    + +
    +
    + + diff --git a/tools/translate/plugin.js b/tools/translate/plugin.js new file mode 100644 index 00000000..84379ea1 --- /dev/null +++ b/tools/translate/plugin.js @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// eslint-disable-next-line import/no-unresolved +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { addDnt, translate, EDITOR_FORMAT } from './shared.js'; + +(async function init() { + // eslint-disable-next-line no-unused-vars + const { context, token, actions } = await DA_SDK; + + const isLightVersion = window.innerWidth < 500; + + let selection = 'No text selected.'; + try { + selection = await actions.getSelection(); + selection = await addDnt(selection, EDITOR_FORMAT, context, actions.daFetch); + } catch (error) { + // ignore + } + + const inputTextarea = document.querySelector('textarea[name="input"]'); + inputTextarea.value = selection; + + const outputTextarea = document.querySelector('textarea[name="output"]'); + outputTextarea.value = ''; + + const languageSelector = document.querySelector('select[name="language"]'); + languageSelector.value = 'fr'; + + const translateBtn = document.querySelector('button[name="translate"]'); + const errorMessage = document.querySelector('.translate-error'); + + translateBtn.addEventListener('click', async (e) => { + e.preventDefault(); + if (errorMessage) { + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; + } + let translation = ''; + try { + translation = await translate( + inputTextarea.value, + languageSelector.value, + context, + EDITOR_FORMAT, + actions.daFetch, + true, + ); + } catch (error) { + if (errorMessage) { + errorMessage.textContent = error?.message || 'Translation failed.'; + errorMessage.style.display = 'block'; + } + return; + } + + if (isLightVersion) { + actions.sendHTML(translation); + actions.closeLibrary(); + } else { + outputTextarea.value = translation; + } + }); + + const replaceBtn = document.querySelector('button[name="replace"]'); + replaceBtn.textContent = 'Replace'; + replaceBtn.addEventListener('click', async (e) => { + e.preventDefault(); + actions.sendHTML(outputTextarea.value); + actions.closeLibrary(); + }); +}()); diff --git a/tools/translate/shared.css b/tools/translate/shared.css new file mode 100644 index 00000000..c017a5b6 --- /dev/null +++ b/tools/translate/shared.css @@ -0,0 +1,34 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + color-scheme: light; + + --bg: #f5f6f7; + --card: #fff; + --text: #1d1d1f; + --muted: #5b6169; + --border: #d9dde3; + --primary: #2a64d3; + --primary-dark: #1f4da3; + --ring: rgb(42 100 211 / 20%); + --shadow: 0 2px 8px rgb(28 39 52 / 8%); + --radius: 10px; + --radius-sm: 8px; +} + +body { + margin: 0; + font-family: "Adobe Clean", Inter, "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + diff --git a/tools/translate/shared.js b/tools/translate/shared.js new file mode 100644 index 00000000..b275e5bb --- /dev/null +++ b/tools/translate/shared.js @@ -0,0 +1,295 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const ADMIN_URL = 'https://admin.da.live'; +// const ADMIN_URL = 'https://stage-admin.da.live'; +// const ADMIN_URL = 'http://localhost:8787'; + +const TRANSLATION_SERVICE_URL = 'https://translate.da.live/google'; + +const CONFIG_PATH = '/.da/translate.json'; +const CONFIG_CONTENT_DNT_SHEET = 'dnt-content-rules'; +const CONFIG_METADATA_FIELDS_SHEET = 'dt-metadata-fields'; +const METADATA_FIELDS_TO_TRANSLATE = ['title', 'description']; + +const EDITOR_FORMAT = 'table'; +const ADMIN_FORMAT = 'div'; + +const ICONS_RULE = { + description: 'Icon names should be not translated', + apply: (html) => { + const ps = html.querySelectorAll('p'); + ps.forEach((p) => { + // ignore p if one parent has a translate="no" attribute + if (p.closest('[translate="no"]')) { + return; + } + const text = p.textContent; + const regex = /:([a-zA-Z0-9-_]+):/g; + const matches = text.match(regex); + + if (matches) { + matches.forEach((match) => { + // ignore if only digits (could be a timestamp) + if (/^:[\d:]+:$/i.test(match)) { + return; + } + p.innerHTML = p.innerHTML.replace(match, `${match}`); + }); + } + }); + return html; + }, +}; + +const CONTENT_DNT_RULE = { + description: 'Specific content fragments should be not translated', + apply: (html, config) => { + const fragments = config[CONFIG_CONTENT_DNT_SHEET]?.data || []; + if (fragments.length === 0) { + return html; + } + const elements = html.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, a'); + elements.forEach((element) => { + const text = element.textContent; + fragments.forEach((fragment) => { + const { content } = fragment; + if (content && text.includes(content)) { + element.innerHTML = element.innerHTML.replace(content, `${content}`); + } + }); + }); + return html; + }, +}; + +const RULES = { + [EDITOR_FORMAT]: [{ + description: 'First row of any table should be not translated', + apply: (html) => { + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + rows[0].setAttribute('translate', 'no'); + } + }); + return html; + }, + }, { + description: 'Property names of the "metadata" table should be not translated (except Title and Description or the ones in the config)', + apply: (html, config) => { + const keys = (() => { + const data = config[CONFIG_METADATA_FIELDS_SHEET]?.data || []; + const ks = data.filter((f) => f.metadata).map((f) => f.metadata.toLowerCase().trim()); + ks.push(...METADATA_FIELDS_TO_TRANSLATE.map((f) => f.toLowerCase().trim())); + return [...new Set(ks)]; + })(); + + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + const metadataTable = rows[0].textContent.toLowerCase().trim() === 'metadata'; + if (metadataTable) { + table.setAttribute('translate', 'no'); + rows.forEach((row) => { + const keyEl = row.querySelector('td:first-child'); + const valueEl = row.querySelector('td:last-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && keys.includes(key)) { + valueEl.setAttribute('translate', 'yes'); + } + }); + } + } + }); + return html; + }, + }, { + description: 'section metadata table should be not translated', + apply: (html) => { + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + const name = rows[0].textContent.toLowerCase().trim(); + const sectionMetadata = name === 'section metadata' || name === 'section-metadata'; + if (sectionMetadata) { + table.setAttribute('translate', 'no'); + } + } + }); + return html; + }, + }, ICONS_RULE, CONTENT_DNT_RULE], + [ADMIN_FORMAT]: [{ + description: 'First column of all rows in "metadata" block should be not translated', + apply: (html, config) => { + const keys = (() => { + const data = config[CONFIG_METADATA_FIELDS_SHEET]?.data || []; + const ks = data.filter((f) => f.metadata).map((f) => f.metadata.toLowerCase().trim()); + ks.push(...METADATA_FIELDS_TO_TRANSLATE.map((f) => f.toLowerCase().trim())); + return [...new Set(ks)]; + })(); + + const divs = html.querySelectorAll('div[class=metadata]'); + divs.forEach((block) => { + block.setAttribute('translate', 'no'); + block.querySelectorAll('& > div').forEach((row) => { + const keyEl = row.querySelector('div:first-child'); + const valueEl = row.querySelector('div:last-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && keys.includes(key)) { + valueEl.setAttribute('translate', 'yes'); + } + }); + }); + return html; + }, + }, { + description: 'section metadata block should be not translated', + apply: (html) => { + const divs = html.querySelectorAll('div[class=section-metadata]'); + divs.forEach((div) => { + div.setAttribute('translate', 'no'); + }); + return html; + }, + }, ICONS_RULE, CONTENT_DNT_RULE], +}; + +const getConfig = async (context, daFetch) => { + const resp = await daFetch(`${ADMIN_URL}/source/${context.org}/${context.repo}${CONFIG_PATH}`); + if (!resp.ok) { + return {}; + } + const config = await resp.json(); + return config || {}; +}; + +const addDnt = async (text, format, context, daFetch) => { + const config = await getConfig(context, daFetch); + let html = new DOMParser().parseFromString(text, 'text/html'); + let rules; + if (format) { + rules = RULES[format]; + } else { + rules = [...RULES[ADMIN_FORMAT], ...RULES[EDITOR_FORMAT]]; + } + if (rules) { + rules.forEach((rule) => { + html = rule.apply(html, config); + }); + } + return html.documentElement.outerHTML; +}; + +const removeDnt = (html) => { + html.querySelectorAll('[translate]').forEach((element) => { + element.removeAttribute('translate'); + + if (element.tagName === 'SPAN') { + element.replaceWith(element.textContent); + } + }); + return html.documentElement.outerHTML; +}; + +const adjustURLs = (html, context) => { + const { path } = context; + // test if path starts with //. + const pathPrefixRegex = /^\/?[a-z]{2}\/[a-z]{2}[-_][a-z]{2}(?=\/|$)/; + const isLocalPath = pathPrefixRegex.test(path); + const pathSegments = path.replace(/^\/+/, '').split('/'); + const basePrefix = pathSegments.length >= 2 ? `/${pathSegments[0]}/${pathSegments[1]}` : ''; + if (isLocalPath && basePrefix) { + html.querySelectorAll('a[href]').forEach((element) => { + if (!element.href) return; + const { pathname } = new URL(element.href); + + if (pathPrefixRegex.test(pathname)) { + // replace the first 2 segments of the pathname with the first 2 segments of the path + const newPathname = pathname.replace(pathPrefixRegex, basePrefix); + const newHref = element.href.replace(pathname, newPathname); + if (element.textContent === element.href) { + element.textContent = newHref; + } + element.href = newHref; + } + }); + } + return html.documentElement.outerHTML; +}; + +const postProcess = (text, context) => { + const html = new DOMParser().parseFromString(text, 'text/html'); + let result = removeDnt(html); + // remove start tag and end tag + result = adjustURLs(html, context); + result = result.replace(/^<\/head>/, '').replace(/<\/body><\/html>$/, ''); + return result; +}; + +const translate = async (htmlInput, language, context, format, daFetch, skipDnt = false) => { + let html = htmlInput; + if (!skipDnt) { + html = await addDnt(html, format, context, daFetch); + } + const splits = []; + const maxChunk = 30000; + let start = 0; + while (start < html.length) { + const target = Math.min(start + maxChunk, html.length); + let splitIndex = target; + if (target < html.length) { + // find the last closing tag before the target + const chunk = html.slice(start, target); + const matches = [...chunk.matchAll(/<\/[a-zA-Z][^>]*>/g)]; + if (matches.length > 0) { + const last = matches[matches.length - 1]; + splitIndex = start + last.index + last[0].length; + } + } + splits.push(html.slice(start, splitIndex)); + start = splitIndex; + } + + const translateSplit = async (split) => { + const body = new FormData(); + body.append('data', split); + body.append('fromlang', 'en'); + body.append('tolang', language); + + const opts = { method: 'POST', body }; + const resp = await fetch(TRANSLATION_SERVICE_URL, opts); + if (!resp.ok) { + throw new Error(`Translation failed: ${resp.status}`); + } + + const json = await resp.json(); + if (!json.translated) { + throw new Error(json.error || 'Failed to translate'); + } + return json.translated; + }; + + const translatedParts = await Promise.all(splits.map((split) => translateSplit(split))); + const combined = translatedParts.join(''); + return postProcess(combined, context); +}; + +export { + addDnt, removeDnt, adjustURLs, postProcess, translate, EDITOR_FORMAT, ADMIN_FORMAT, ADMIN_URL, +};