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
+
+
+
+
+
+
+
+
\ 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,
+};