From 87917305d2b9f5df03ed9b6dd78a663e1c914574 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 22 Jan 2026 15:31:07 +0100 Subject: [PATCH 01/28] feat: translate plugin --- tools/translate/sdk.js | 74 +++++++++++++++++ tools/translate/styles.css | 146 +++++++++++++++++++++++++++++++++ tools/translate/translate.html | 32 ++++++++ tools/translate/translate.js | 104 +++++++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 tools/translate/sdk.js create mode 100644 tools/translate/styles.css create mode 100644 tools/translate/translate.html create mode 100644 tools/translate/translate.js diff --git a/tools/translate/sdk.js b/tools/translate/sdk.js new file mode 100644 index 00000000..1715f9c3 --- /dev/null +++ b/tools/translate/sdk.js @@ -0,0 +1,74 @@ +import { setImsDetails, daFetch } from 'https://da.live/nx/utils/daFetch.js'; + +let port2; + +function sendText(text) { + port2.postMessage({ action: 'sendText', details: text }); +} + +function sendHTML(text) { + port2.postMessage({ action: 'sendHTML', details: text }); +} + +function readSelection() { + return new Promise((resolve, reject) => { + const listener = (e) => { + window.removeEventListener('message', listener); + + if (e.data.action === 'sendSelection') { + resolve(e.data.details); + } + + if (e.data.action === 'error') { + reject(e.data.details); + } + }; + window.addEventListener('message', listener); + port2.postMessage({ action: 'readSelection' }); + }); +} + +function setTitle(text) { + port2.postMessage({ action: 'setTitle', details: text }); +} + +function setHref(href) { + port2.postMessage({ action: 'setHref', details: href }); +} + +function setHash(hash) { + port2.postMessage({ action: 'setHash', details: hash }); +} + +function closeLibrary() { + port2.postMessage({ action: 'closeLibrary' }); +} + +const DA_SDK = (() => new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.data) { + if (e.data.ready) { + [port2] = e.ports; + setTitle(document.title); + } + + if (e.data.token) { + setImsDetails(e.data.token); + } + + const actions = { + daFetch, + sendText, + sendHTML, + setHref, + setHash, + closeLibrary, + readSelection, + }; + + resolve({ ...e.data, actions }); + } + }); +}))(); + +export default DA_SDK; \ No newline at end of file diff --git a/tools/translate/styles.css b/tools/translate/styles.css new file mode 100644 index 00000000..3f633b63 --- /dev/null +++ b/tools/translate/styles.css @@ -0,0 +1,146 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + color-scheme: light; + --bg: #f5f6f7; + --card: #ffffff; + --text: #1d1d1f; + --muted: #5b6169; + --border: #d9dde3; + --primary: #2a64d3; + --primary-dark: #1f4da3; + --ring: rgba(42, 100, 211, 0.2); + --shadow: 0 2px 8px rgba(28, 39, 52, 0.08); + --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; +} + +.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-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: #ffffff; + 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: #ffffff; + 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: #ffffff; + 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 (max-width: 720px) { + .translate-form { + padding: 20px; + } + + .translate-input { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} + +@media (max-width: 500px) { + .translate-input textarea, + .translate-output textarea, + button[name="replace"] { + display: none; + } +} diff --git a/tools/translate/translate.html b/tools/translate/translate.html new file mode 100644 index 00000000..48b85a82 --- /dev/null +++ b/tools/translate/translate.html @@ -0,0 +1,32 @@ + + + + DA App SDK Sample + + + + + + + +
+
+
+ + +
+ +
+ +
+ +
+
+ + \ No newline at end of file diff --git a/tools/translate/translate.js b/tools/translate/translate.js new file mode 100644 index 00000000..2f28831a --- /dev/null +++ b/tools/translate/translate.js @@ -0,0 +1,104 @@ +import DA_SDK from './sdk.js'; + +const addDnt = (text) => { + // parse text into html + const html = new DOMParser().parseFromString(text, 'text/html'); + + // 1. first row of any table should be not translated + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + rows[0].setAttribute('translate', 'no'); + + // 2. first column of all rows in "metadata" table should be not translated + const metadataTable = rows[0].textContent.trim() === 'metadata'; + if (metadataTable) { + rows.forEach((row) => { + row.querySelector('td:first-child').setAttribute('translate', 'no'); + }); + } + } + }); + + return html.documentElement.outerHTML; +}; + +const removeDnt = (text, context) => { + const html = new DOMParser().parseFromString(text, 'text/html'); + html.querySelectorAll('[translate="no"]').forEach((element) => { + element.removeAttribute('translate'); + }); + return html.documentElement.outerHTML; +}; + +const translate = async (text, language, context) => { + + console.log('input text', text); + + const html = addDnt(text); + + console.log('html with dnt', html); + + const body = new FormData(); + body.append('data', html); + body.append('fromlang', 'en'); + body.append('tolang', language); + + const opts = { method: 'POST', body }; + + const resp = await fetch('https://translate.da.live/google', opts); + if (!resp.ok) return; + + const json = await resp.json(); + console.log('translated response', json); + + console.log('translated response', json.translated); + const translated = removeDnt(json.translated, context); + console.log('translated without dnt', translated); + return translated; +}; + +(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.readSelection(); + console.log('received selection', selection); + } catch (error) {} + + 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"]'); + translateBtn.addEventListener('click', async (e) => { + e.preventDefault(); + const translation = await translate(inputTextarea.value, languageSelector.value, context); + 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(); + }); +}()); + +console.log('translate.js loaded'); \ No newline at end of file From 68f438a2da4d75c1ad1dbef92588f2a506159b40 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 22 Jan 2026 15:33:12 +0100 Subject: [PATCH 02/28] chore: relative --- tools/translate/translate.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/translate/translate.html b/tools/translate/translate.html index 48b85a82..2547ccbf 100644 --- a/tools/translate/translate.html +++ b/tools/translate/translate.html @@ -4,9 +4,9 @@ DA App SDK Sample - + - +
From bac68b5dd46909d9231f3feecbc2fc0e240deb78 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 22 Jan 2026 15:36:17 +0100 Subject: [PATCH 03/28] chore: less debugs --- tools/translate/translate.js | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 2f28831a..66ce56e8 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -33,13 +33,8 @@ const removeDnt = (text, context) => { }; const translate = async (text, language, context) => { - - console.log('input text', text); - const html = addDnt(text); - console.log('html with dnt', html); - const body = new FormData(); body.append('data', html); body.append('fromlang', 'en'); @@ -51,12 +46,10 @@ const translate = async (text, language, context) => { if (!resp.ok) return; const json = await resp.json(); - console.log('translated response', json); - - console.log('translated response', json.translated); + const translated = removeDnt(json.translated, context); - console.log('translated without dnt', translated); - return translated; + // remove start tag and end tag + return translated.replace(/^<\/head>/, '').replace(/<\/body><\/html>$/, ''); }; (async function init() { @@ -68,7 +61,6 @@ const translate = async (text, language, context) => { let selection = 'No text selected.'; try { selection = await actions.readSelection(); - console.log('received selection', selection); } catch (error) {} const inputTextarea = document.querySelector('textarea[name="input"]'); @@ -99,6 +91,4 @@ const translate = async (text, language, context) => { actions.sendHTML(outputTextarea.value); actions.closeLibrary(); }); -}()); - -console.log('translate.js loaded'); \ No newline at end of file +}()); \ No newline at end of file From 81bbafee58033ebad1f59418c4c460e098649b0b Mon Sep 17 00:00:00 2001 From: kptdobe Date: Thu, 22 Jan 2026 15:52:46 +0100 Subject: [PATCH 04/28] chore: linting --- tools/translate/sdk.js | 2 +- tools/translate/translate.js | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tools/translate/sdk.js b/tools/translate/sdk.js index 1715f9c3..0cf586d9 100644 --- a/tools/translate/sdk.js +++ b/tools/translate/sdk.js @@ -71,4 +71,4 @@ const DA_SDK = (() => new Promise((resolve) => { }); }))(); -export default DA_SDK; \ No newline at end of file +export default DA_SDK; diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 66ce56e8..fcc77bc9 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -3,7 +3,7 @@ import DA_SDK from './sdk.js'; const addDnt = (text) => { // parse text into html const html = new DOMParser().parseFromString(text, 'text/html'); - + // 1. first row of any table should be not translated const tables = html.querySelectorAll('table'); tables.forEach((table) => { @@ -24,7 +24,7 @@ const addDnt = (text) => { return html.documentElement.outerHTML; }; -const removeDnt = (text, context) => { +const removeDnt = (text) => { const html = new DOMParser().parseFromString(text, 'text/html'); html.querySelectorAll('[translate="no"]').forEach((element) => { element.removeAttribute('translate'); @@ -43,12 +43,12 @@ const translate = async (text, language, context) => { const opts = { method: 'POST', body }; const resp = await fetch('https://translate.da.live/google', opts); - if (!resp.ok) return; + if (!resp.ok) return null; const json = await resp.json(); - + const translated = removeDnt(json.translated, context); - // remove start tag and end tag + // remove start tag and end tag return translated.replace(/^<\/head>/, '').replace(/<\/body><\/html>$/, ''); }; @@ -61,8 +61,10 @@ const translate = async (text, language, context) => { let selection = 'No text selected.'; try { selection = await actions.readSelection(); - } catch (error) {} - + } catch (error) { + // ignore + } + const inputTextarea = document.querySelector('textarea[name="input"]'); inputTextarea.value = selection; @@ -91,4 +93,4 @@ const translate = async (text, language, context) => { actions.sendHTML(outputTextarea.value); actions.closeLibrary(); }); -}()); \ No newline at end of file +}()); From a9991f6f3ec03cfc815c21c93b7f00d2fedbf327 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 10:13:20 +0100 Subject: [PATCH 05/28] chore: rename --- tools/translate/sdk.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/translate/sdk.js b/tools/translate/sdk.js index 0cf586d9..b6fd6a7d 100644 --- a/tools/translate/sdk.js +++ b/tools/translate/sdk.js @@ -10,7 +10,7 @@ function sendHTML(text) { port2.postMessage({ action: 'sendHTML', details: text }); } -function readSelection() { +function getSelection() { return new Promise((resolve, reject) => { const listener = (e) => { window.removeEventListener('message', listener); @@ -24,7 +24,7 @@ function readSelection() { } }; window.addEventListener('message', listener); - port2.postMessage({ action: 'readSelection' }); + port2.postMessage({ action: 'getSelection' }); }); } @@ -63,7 +63,7 @@ const DA_SDK = (() => new Promise((resolve) => { setHref, setHash, closeLibrary, - readSelection, + getSelection, }; resolve({ ...e.data, actions }); From 79f8d9d3af30bef9350421ed5ac6fbc094cbd5c5 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 10:19:21 +0100 Subject: [PATCH 06/28] chore: rename --- tools/translate/translate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index fcc77bc9..be02d2ab 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -60,7 +60,7 @@ const translate = async (text, language, context) => { let selection = 'No text selected.'; try { - selection = await actions.readSelection(); + selection = await actions.getSelection(); } catch (error) { // ignore } From 7044fa84f66d50f57acb79696121038208d695a1 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 10:29:02 +0100 Subject: [PATCH 07/28] chore: show dnt in debug mode --- tools/translate/translate.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index be02d2ab..f2fbba0c 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -32,9 +32,7 @@ const removeDnt = (text) => { return html.documentElement.outerHTML; }; -const translate = async (text, language, context) => { - const html = addDnt(text); - +const translate = async (html, language, context) => { const body = new FormData(); body.append('data', html); body.append('fromlang', 'en'); @@ -61,6 +59,7 @@ const translate = async (text, language, context) => { let selection = 'No text selected.'; try { selection = await actions.getSelection(); + selection = addDnt(selection); } catch (error) { // ignore } From 46990a8f5f84f4e6f9039039f62458ddb1cc2cfd Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 10:29:58 +0100 Subject: [PATCH 08/28] chore: prod sdk patched --- tools/translate/sdk.js | 74 ------------------------------------ tools/translate/translate.js | 2 +- 2 files changed, 1 insertion(+), 75 deletions(-) delete mode 100644 tools/translate/sdk.js diff --git a/tools/translate/sdk.js b/tools/translate/sdk.js deleted file mode 100644 index b6fd6a7d..00000000 --- a/tools/translate/sdk.js +++ /dev/null @@ -1,74 +0,0 @@ -import { setImsDetails, daFetch } from 'https://da.live/nx/utils/daFetch.js'; - -let port2; - -function sendText(text) { - port2.postMessage({ action: 'sendText', details: text }); -} - -function sendHTML(text) { - port2.postMessage({ action: 'sendHTML', details: text }); -} - -function getSelection() { - return new Promise((resolve, reject) => { - const listener = (e) => { - window.removeEventListener('message', listener); - - if (e.data.action === 'sendSelection') { - resolve(e.data.details); - } - - if (e.data.action === 'error') { - reject(e.data.details); - } - }; - window.addEventListener('message', listener); - port2.postMessage({ action: 'getSelection' }); - }); -} - -function setTitle(text) { - port2.postMessage({ action: 'setTitle', details: text }); -} - -function setHref(href) { - port2.postMessage({ action: 'setHref', details: href }); -} - -function setHash(hash) { - port2.postMessage({ action: 'setHash', details: hash }); -} - -function closeLibrary() { - port2.postMessage({ action: 'closeLibrary' }); -} - -const DA_SDK = (() => new Promise((resolve) => { - window.addEventListener('message', (e) => { - if (e.data) { - if (e.data.ready) { - [port2] = e.ports; - setTitle(document.title); - } - - if (e.data.token) { - setImsDetails(e.data.token); - } - - const actions = { - daFetch, - sendText, - sendHTML, - setHref, - setHash, - closeLibrary, - getSelection, - }; - - resolve({ ...e.data, actions }); - } - }); -}))(); - -export default DA_SDK; diff --git a/tools/translate/translate.js b/tools/translate/translate.js index f2fbba0c..69005cc0 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -1,4 +1,4 @@ -import DA_SDK from './sdk.js'; +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; const addDnt = (text) => { // parse text into html From 8a944b4e18fb3046f57bb5ef0a9cb9125757259d Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 11:10:59 +0100 Subject: [PATCH 09/28] feat: adjust urls --- tools/translate/translate.js | 53 ++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 69005cc0..794c5aa9 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-unresolved import DA_SDK from 'https://da.live/nx/utils/sdk.js'; const addDnt = (text) => { @@ -24,14 +25,57 @@ const addDnt = (text) => { return html.documentElement.outerHTML; }; -const removeDnt = (text) => { - const html = new DOMParser().parseFromString(text, 'text/html'); +const removeDnt = (html) => { html.querySelectorAll('[translate="no"]').forEach((element) => { element.removeAttribute('translate'); }); return html.documentElement.outerHTML; }; +const adjustURLs = (html, context) => { + const { path } = context; + const split = path.split('/'); + html.querySelectorAll('a[href]').forEach((element) => { + if (!element.href) return; + const { pathname } = new URL(element.href); + const splitPathname = pathname.split('/'); + + if (splitPathname.length === split.length + && splitPathname[split.length - 1] === split[split.length - 1]) { + // same path length and last segment is the same, + // maybe we can adjust the locale and language (first 2 segments) + if (split.length > 1 && splitPathname[1] !== split[1] + && (split[1].length === 2 || split[1].length === 5)) { + // eslint-disable-next-line prefer-destructuring + splitPathname[1] = split[1]; + } + + if (split.length > 2 && splitPathname[2] !== split[2] + && (split[2].length === 2 || split[2].length === 5)) { + // eslint-disable-next-line prefer-destructuring + splitPathname[2] = split[2]; + } + + const newPathname = splitPathname.join('/'); + 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 (html, language, context) => { const body = new FormData(); body.append('data', html); @@ -45,9 +89,8 @@ const translate = async (html, language, context) => { const json = await resp.json(); - const translated = removeDnt(json.translated, context); - // remove start tag and end tag - return translated.replace(/^<\/head>/, '').replace(/<\/body><\/html>$/, ''); + const translated = postProcess(json.translated, context); + return translated; }; (async function init() { From f1409f9dc6c87ab8188d9cb9c605791ff05e821e Mon Sep 17 00:00:00 2001 From: kptdobe Date: Fri, 23 Jan 2026 11:49:39 +0100 Subject: [PATCH 10/28] chore: fix url handling --- tools/translate/translate.js | 49 +++++++++++++++--------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 794c5aa9..47895a63 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -34,36 +34,27 @@ const removeDnt = (html) => { const adjustURLs = (html, context) => { const { path } = context; - const split = path.split('/'); - html.querySelectorAll('a[href]').forEach((element) => { - if (!element.href) return; - const { pathname } = new URL(element.href); - const splitPathname = pathname.split('/'); - - if (splitPathname.length === split.length - && splitPathname[split.length - 1] === split[split.length - 1]) { - // same path length and last segment is the same, - // maybe we can adjust the locale and language (first 2 segments) - if (split.length > 1 && splitPathname[1] !== split[1] - && (split[1].length === 2 || split[1].length === 5)) { - // eslint-disable-next-line prefer-destructuring - splitPathname[1] = split[1]; + // 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; } - - if (split.length > 2 && splitPathname[2] !== split[2] - && (split[2].length === 2 || split[2].length === 5)) { - // eslint-disable-next-line prefer-destructuring - splitPathname[2] = split[2]; - } - - const newPathname = splitPathname.join('/'); - const newHref = element.href.replace(pathname, newPathname); - if (element.textContent === element.href) { - element.textContent = newHref; - } - element.href = newHref; - } - }); + }); + } return html.documentElement.outerHTML; }; From 773f3c570577a8df6b5561ba4473b2d6a4e6ec14 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 11:06:06 +0100 Subject: [PATCH 11/28] chore: basic error handling --- tools/translate/translate.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 47895a63..45a8c669 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -75,13 +75,16 @@ const translate = async (html, language, context) => { const opts = { method: 'POST', body }; - const resp = await fetch('https://translate.da.live/google', opts); + const resp = await fetch('http://localhost:62879/google', opts); if (!resp.ok) return null; const json = await resp.json(); - const translated = postProcess(json.translated, context); - return translated; + if (json.translated) { + const translated = postProcess(json.translated, context); + return translated; + } + throw new Error(json.error || 'Failed to translate'); }; (async function init() { From e5e3044636f1e85f128385fec59a0631b171e08b Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 11:24:49 +0100 Subject: [PATCH 12/28] feat: split in chunks if content is too large --- tools/translate/translate.js | 46 ++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 45a8c669..93f6870e 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -68,23 +68,43 @@ const postProcess = (text, context) => { }; const translate = async (html, language, context) => { - const body = new FormData(); - body.append('data', html); - body.append('fromlang', 'en'); - body.append('tolang', language); + 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
tag before the target + const divIndex = html.indexOf('', target); + splitIndex = divIndex === -1 ? target : divIndex + 6; + } + splits.push(html.slice(start, splitIndex)); + start = splitIndex; + } - const opts = { method: 'POST', body }; + const translateSplit = async (split) => { + const body = new FormData(); + body.append('data', split); + body.append('fromlang', 'en'); + body.append('tolang', language); - const resp = await fetch('http://localhost:62879/google', opts); - if (!resp.ok) return null; + const opts = { method: 'POST', body }; + const resp = await fetch('http://translate.da.live/google', opts); + if (!resp.ok) { + throw new Error(`Translate failed: ${resp.status}`); + } - const json = await resp.json(); + const json = await resp.json(); + if (!json.translated) { + throw new Error(json.error || 'Failed to translate'); + } + return json.translated; + }; - if (json.translated) { - const translated = postProcess(json.translated, context); - return translated; - } - throw new Error(json.error || 'Failed to translate'); + const translatedParts = await Promise.all(splits.map((split) => translateSplit(split))); + const combined = translatedParts.join(''); + return postProcess(combined, context); }; (async function init() { From 50109c670fe8aefc0dc379ef87caeb503fd183e8 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 11:27:35 +0100 Subject: [PATCH 13/28] chore: https --- tools/translate/translate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 93f6870e..66fea89d 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -90,7 +90,7 @@ const translate = async (html, language, context) => { body.append('tolang', language); const opts = { method: 'POST', body }; - const resp = await fetch('http://translate.da.live/google', opts); + const resp = await fetch('https://translate.da.live/google', opts); if (!resp.ok) { throw new Error(`Translate failed: ${resp.status}`); } From 4c0e6ca59bbc2f7f933e54498ceeeac68dfc2ba8 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 11:38:30 +0100 Subject: [PATCH 14/28] feat: find last closing tag --- tools/translate/translate.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 66fea89d..0720ef25 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -75,9 +75,13 @@ const translate = async (html, language, context) => { const target = Math.min(start + maxChunk, html.length); let splitIndex = target; if (target < html.length) { - // find the last tag before the target - const divIndex = html.indexOf('', target); - splitIndex = divIndex === -1 ? target : divIndex + 6; + // 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; From c8753fac7bd6191e9b41371fe6d2604691f7547e Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 11:52:35 +0100 Subject: [PATCH 15/28] feat: basic error message --- tools/translate/styles.css | 26 +++++++++++++++++--------- tools/translate/translate.html | 1 + tools/translate/translate.js | 20 ++++++++++++++++++-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/tools/translate/styles.css b/tools/translate/styles.css index 3f633b63..db2fc007 100644 --- a/tools/translate/styles.css +++ b/tools/translate/styles.css @@ -6,22 +6,23 @@ :root { color-scheme: light; + --bg: #f5f6f7; - --card: #ffffff; + --card: #fff; --text: #1d1d1f; --muted: #5b6169; --border: #d9dde3; --primary: #2a64d3; --primary-dark: #1f4da3; - --ring: rgba(42, 100, 211, 0.2); - --shadow: 0 2px 8px rgba(28, 39, 52, 0.08); + --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; + font-family: "Adobe Clean", Inter, "Helvetica Neue", Arial, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; @@ -51,6 +52,13 @@ body { 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; @@ -66,7 +74,7 @@ textarea { font-size: 15px; line-height: 1.5; color: var(--text); - background: #ffffff; + background: #fff; box-shadow: none; } @@ -81,7 +89,7 @@ select { padding: 12px 14px; font-size: 14px; color: var(--text); - background: #ffffff; + background: #fff; box-shadow: none; } @@ -105,7 +113,7 @@ button { button[name="translate"] { background: var(--primary); - color: #ffffff; + color: #fff; box-shadow: none; } @@ -123,7 +131,7 @@ button[name="replace"]:hover { background: #e7eaee; } -@media (max-width: 720px) { +@media (width <= 720px) { .translate-form { padding: 20px; } @@ -137,7 +145,7 @@ button[name="replace"]:hover { } } -@media (max-width: 500px) { +@media (width <= 500px) { .translate-input textarea, .translate-output textarea, button[name="replace"] { diff --git a/tools/translate/translate.html b/tools/translate/translate.html index 2547ccbf..f99537cc 100644 --- a/tools/translate/translate.html +++ b/tools/translate/translate.html @@ -21,6 +21,7 @@ +
diff --git a/tools/translate/translate.js b/tools/translate/translate.js index 0720ef25..c8a1327a 100644 --- a/tools/translate/translate.js +++ b/tools/translate/translate.js @@ -96,7 +96,7 @@ const translate = async (html, language, context) => { const opts = { method: 'POST', body }; const resp = await fetch('https://translate.da.live/google', opts); if (!resp.ok) { - throw new Error(`Translate failed: ${resp.status}`); + throw new Error(`Translation failed: ${resp.status}`); } const json = await resp.json(); @@ -135,9 +135,25 @@ const translate = async (html, language, context) => { languageSelector.value = 'fr'; const translateBtn = document.querySelector('button[name="translate"]'); + const errorMessage = document.querySelector('.translate-error'); + translateBtn.addEventListener('click', async (e) => { e.preventDefault(); - const translation = await translate(inputTextarea.value, languageSelector.value, context); + if (errorMessage) { + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; + } + let translation = ''; + try { + translation = await translate(inputTextarea.value, languageSelector.value, context); + } catch (error) { + if (errorMessage) { + errorMessage.textContent = error?.message || 'Translation failed.'; + errorMessage.style.display = 'block'; + } + return; + } + if (isLightVersion) { actions.sendHTML(translation); actions.closeLibrary(); From 70630fd8f854b47cb16ca41a359be7166d0678ff Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 14:38:40 +0100 Subject: [PATCH 16/28] chore: plugin --- tools/translate/{styles.css => plugin.css} | 36 ++----------------- .../translate/{translate.html => plugin.html} | 4 +-- tools/translate/{translate.js => plugin.js} | 0 tools/translate/shared.css | 34 ++++++++++++++++++ 4 files changed, 38 insertions(+), 36 deletions(-) rename tools/translate/{styles.css => plugin.css} (77%) rename tools/translate/{translate.html => plugin.html} (91%) rename tools/translate/{translate.js => plugin.js} (100%) create mode 100644 tools/translate/shared.css diff --git a/tools/translate/styles.css b/tools/translate/plugin.css similarity index 77% rename from tools/translate/styles.css rename to tools/translate/plugin.css index db2fc007..a86838fb 100644 --- a/tools/translate/styles.css +++ b/tools/translate/plugin.css @@ -1,36 +1,4 @@ -*, -*::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; -} +@import url('./shared.css'); .translate-container { width: min(560px, 100%); @@ -151,4 +119,4 @@ button[name="replace"]:hover { button[name="replace"] { display: none; } -} +} \ No newline at end of file diff --git a/tools/translate/translate.html b/tools/translate/plugin.html similarity index 91% rename from tools/translate/translate.html rename to tools/translate/plugin.html index f99537cc..87d6960b 100644 --- a/tools/translate/translate.html +++ b/tools/translate/plugin.html @@ -1,10 +1,10 @@ - DA App SDK Sample + Translation plugin - + diff --git a/tools/translate/translate.js b/tools/translate/plugin.js similarity index 100% rename from tools/translate/translate.js rename to tools/translate/plugin.js 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; +} + From ae43a6b968bedd8732cd5b6728d326712d38f385 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 14:41:26 +0100 Subject: [PATCH 17/28] chore: rename --- tools/translate/plugin.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/translate/plugin.html b/tools/translate/plugin.html index 87d6960b..ddbac639 100644 --- a/tools/translate/plugin.html +++ b/tools/translate/plugin.html @@ -4,9 +4,9 @@ Translation plugin - + - +
From 39cbe83cbfff8b08053d7987aa8e17c47eb91801 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 16:11:33 +0100 Subject: [PATCH 18/28] chore: translation tool --- tools/translate/app.css | 146 +++++++++++++++++++++++++++ tools/translate/app.html | 32 ++++++ tools/translate/app.js | 126 +++++++++++++++++++++++ tools/translate/plugin.html | 1 - tools/translate/plugin.js | 133 ++++--------------------- tools/translate/shared.js | 192 ++++++++++++++++++++++++++++++++++++ 6 files changed, 517 insertions(+), 113 deletions(-) create mode 100644 tools/translate/app.css create mode 100644 tools/translate/app.html create mode 100644 tools/translate/app.js create mode 100644 tools/translate/shared.js diff --git a/tools/translate/app.css b/tools/translate/app.css new file mode 100644 index 00000000..174c7945 --- /dev/null +++ b/tools/translate/app.css @@ -0,0 +1,146 @@ +@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-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-output-list li::before { + content: ""; + position: absolute; + left: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--muted); +} + +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: 12px; + 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 { + color: #064; + background: #e3fcef; +} + +.status.error { + color: #bf2600; + background: #ffebe6; +} + +@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..ebadd31c --- /dev/null +++ b/tools/translate/app.html @@ -0,0 +1,32 @@ + + + + Translation tool + + + + + +
+

Translation tool

+

Enter list of URLs to translate

+
+
+ + +
+ +
+
+
    +
    +
    +
    + + \ No newline at end of file diff --git a/tools/translate/app.js b/tools/translate/app.js new file mode 100644 index 00000000..4ab7d19f --- /dev/null +++ b/tools/translate/app.js @@ -0,0 +1,126 @@ +/* + * 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_FORMAT } from './shared.js'; + +const ADMIN_URL = 'https://admin.da.live'; +// const ADMIN_URL = 'https://stage-admin.da.live'; +// const ADMIN_URL = 'http://localhost:8787'; + +(async function init() { + // eslint-disable-next-line no-unused-vars + const { context, token, actions } = await DA_SDK; + const { daFetch } = actions; + + const urlsTextarea = document.querySelector('textarea[name="urls"]'); + const languageSelect = document.querySelector('select[name="language"]'); + languageSelect.value = 'fr'; + const translateButton = document.querySelector('button[name="translate"]'); + const outputList = document.querySelector('.app-output-list'); + const errorMessage = document.querySelector('.app-error'); + + 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) { + const url = urls[i]; + const listItem = document.createElement('li'); + listItem.textContent = url; + outputList.appendChild(listItem); + + let u; + try { + u = new URL(url); + } catch (error) { + updateStatus(listItem, 'error', 'Invalid URL format'); + continue; + } + + // Validate URL format + // Expected: https://----./ + if (!u.hostname.includes(context.org) || !u.hostname.includes(context.repo)) { + updateStatus(listItem, 'error', 'Must be a URL from this organization and repository'); + } else { + updateStatus(listItem, 'loading', 'Loading'); + + try { + let sourceUrl = `${ADMIN_URL}/source/${context.org}/${context.repo}${u.pathname}`; + // if needed, append .html + if (!u.pathname.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, + ADMIN_FORMAT, + ); + + 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}${u.pathname}`; + updateStatus(listItem, 'posted', `Translated page saved! View page: ${daHref}`); + } 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.html b/tools/translate/plugin.html index ddbac639..76232c44 100644 --- a/tools/translate/plugin.html +++ b/tools/translate/plugin.html @@ -3,7 +3,6 @@ Translation plugin - diff --git a/tools/translate/plugin.js b/tools/translate/plugin.js index c8a1327a..d3d00ad9 100644 --- a/tools/translate/plugin.js +++ b/tools/translate/plugin.js @@ -1,115 +1,18 @@ +/* + * 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'; - -const addDnt = (text) => { - // parse text into html - const html = new DOMParser().parseFromString(text, 'text/html'); - - // 1. first row of any table should be not translated - const tables = html.querySelectorAll('table'); - tables.forEach((table) => { - const rows = table.querySelectorAll('tr'); - if (rows.length > 0) { - rows[0].setAttribute('translate', 'no'); - - // 2. first column of all rows in "metadata" table should be not translated - const metadataTable = rows[0].textContent.trim() === 'metadata'; - if (metadataTable) { - rows.forEach((row) => { - row.querySelector('td:first-child').setAttribute('translate', 'no'); - }); - } - } - }); - - return html.documentElement.outerHTML; -}; - -const removeDnt = (html) => { - html.querySelectorAll('[translate="no"]').forEach((element) => { - element.removeAttribute('translate'); - }); - 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 (html, language, context) => { - 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('https://translate.da.live/google', 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); -}; +import { addDnt, translate, EDITOR_FORMAT } from './shared.js'; (async function init() { // eslint-disable-next-line no-unused-vars @@ -120,7 +23,7 @@ const translate = async (html, language, context) => { let selection = 'No text selected.'; try { selection = await actions.getSelection(); - selection = addDnt(selection); + selection = addDnt(selection, EDITOR_FORMAT); } catch (error) { // ignore } @@ -145,7 +48,13 @@ const translate = async (html, language, context) => { } let translation = ''; try { - translation = await translate(inputTextarea.value, languageSelector.value, context); + translation = await translate( + inputTextarea.value, + languageSelector.value, + context, + EDITOR_FORMAT, + true, + ); } catch (error) { if (errorMessage) { errorMessage.textContent = error?.message || 'Translation failed.'; diff --git a/tools/translate/shared.js b/tools/translate/shared.js new file mode 100644 index 00000000..1fa657fd --- /dev/null +++ b/tools/translate/shared.js @@ -0,0 +1,192 @@ +/* + * 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 TRANSLATION_SERVICE_URL = 'https://translate.da.live/google'; + +const EDITOR_FORMAT = 'table'; +const ADMIN_FORMAT = 'div'; + +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: 'First column of all rows in "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 metadataTable = rows[0].textContent.toLowerCase().trim() === 'metadata'; + if (metadataTable) { + rows.forEach((row) => { + row.querySelector('td:first-child').setAttribute('translate', 'no'); + }); + } + } + }); + 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; + }, + }], + [ADMIN_FORMAT]: [{ + description: 'First column of all rows in "metadata" block should be not translated', + apply: (html) => { + const divs = html.querySelectorAll('div[class=metadata]'); + divs.forEach((div) => { + div.querySelectorAll('& > div > div:first-child').forEach((child) => { + child.setAttribute('translate', 'no'); + }); + }); + 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; + }, + }], +}; + +const addDnt = (text, format) => { + // parse text into html + let html = new DOMParser().parseFromString(text, 'text/html'); + const rules = RULES[format]; + if (rules) { + rules.forEach((rule) => { + html = rule.apply(html); + }); + } + return html.documentElement.outerHTML; +}; + +const removeDnt = (html) => { + html.querySelectorAll('[translate="no"]').forEach((element) => { + element.removeAttribute('translate'); + }); + 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, skipDnt = false) => { + let html = htmlInput; + if (!skipDnt) { + html = addDnt(html, format); + } + 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, +}; From e11e9c3105b5b0fae2f1068bf674661871f57b5d Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 26 Jan 2026 17:11:18 +0100 Subject: [PATCH 19/28] chore: color --- tools/translate/app.css | 3 ++- tools/translate/app.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/translate/app.css b/tools/translate/app.css index 174c7945..3d20283a 100644 --- a/tools/translate/app.css +++ b/tools/translate/app.css @@ -121,7 +121,8 @@ button:hover { background: #deebff; } -.status.translated { +.status.translated, +.status.saved { color: #064; background: #e3fcef; } diff --git a/tools/translate/app.js b/tools/translate/app.js index 4ab7d19f..0fbef064 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -114,7 +114,7 @@ const ADMIN_URL = 'https://admin.da.live'; updateStatus(listItem, 'error', `Failed to save translated HTML: (${resp.statusText})`); } const daHref = `https://da.live/edit#/${context.org}/${context.repo}${u.pathname}`; - updateStatus(listItem, 'posted', `Translated page saved! View page: ${daHref}`); + updateStatus(listItem, 'saved', `Translated page saved! View page: ${daHref}`); } catch (error) { // eslint-disable-next-line no-console console.error('Error retrieving page content', error); From 7725952c45f644d5844acb77ce6c8327eb35d291 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 27 Jan 2026 10:27:52 +0100 Subject: [PATCH 20/28] feaT: support da.live edit urls --- tools/translate/app.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tools/translate/app.js b/tools/translate/app.js index 0fbef064..5f44cf41 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -60,30 +60,33 @@ const ADMIN_URL = 'https://admin.da.live'; // eslint-disable-next-line no-restricted-syntax for (let i = 0; i < urls.length; i += 1) { - const url = urls[i]; const listItem = document.createElement('li'); - listItem.textContent = url; + listItem.textContent = urls[i]; outputList.appendChild(listItem); - let u; + let url; try { - u = new URL(url); + url = new URL(urls[i]); } catch (error) { updateStatus(listItem, 'error', 'Invalid URL format'); continue; } // Validate URL format - // Expected: https://----./ - if (!u.hostname.includes(context.org) || !u.hostname.includes(context.repo)) { + // 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 { - let sourceUrl = `${ADMIN_URL}/source/${context.org}/${context.repo}${u.pathname}`; + 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 (!u.pathname.endsWith('.html')) { + if (!sourceUrl.endsWith('.html')) { sourceUrl += '.html'; } @@ -113,7 +116,7 @@ const ADMIN_URL = 'https://admin.da.live'; if (!resp.ok) { updateStatus(listItem, 'error', `Failed to save translated HTML: (${resp.statusText})`); } - const daHref = `https://da.live/edit#/${context.org}/${context.repo}${u.pathname}`; + const daHref = `https://da.live/edit#/${context.org}/${context.repo}${resourcePath}`; updateStatus(listItem, 'saved', `Translated page saved! View page: ${daHref}`); } catch (error) { // eslint-disable-next-line no-console From fd73a7fa20de65d58eff1fdf26934ae8dc3c9442 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 27 Jan 2026 11:21:30 +0100 Subject: [PATCH 21/28] feat: do not translate icon names --- tools/translate/shared.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tools/translate/shared.js b/tools/translate/shared.js index 1fa657fd..d5e1fb2b 100644 --- a/tools/translate/shared.js +++ b/tools/translate/shared.js @@ -61,6 +61,22 @@ const RULES = { }); return html; }, + }, { + description: 'Icon names should be not translated', + apply: (html) => { + const ps = html.querySelectorAll('p'); + ps.forEach((p) => { + const text = p.textContent; + const regex = /:([a-zA-Z0-9-_]+):/g; + const matches = text.match(regex); + if (matches) { + matches.forEach((match) => { + p.innerHTML = p.innerHTML.replace(match, `${match}`); + }); + } + }); + return html; + }, }], [ADMIN_FORMAT]: [{ description: 'First column of all rows in "metadata" block should be not translated', @@ -82,6 +98,22 @@ const RULES = { }); return html; }, + }, { + description: 'Icon names should be not translated', + apply: (html) => { + const ps = html.querySelectorAll('p'); + ps.forEach((p) => { + const text = p.textContent; + const regex = /:([a-zA-Z0-9-_]+):/g; + const matches = text.match(regex); + if (matches) { + matches.forEach((match) => { + p.innerHTML = p.innerHTML.replace(match, `${match}`); + }); + } + }); + return html; + }, }], }; @@ -100,6 +132,10 @@ const addDnt = (text, format) => { const removeDnt = (html) => { html.querySelectorAll('[translate="no"]').forEach((element) => { element.removeAttribute('translate'); + + if (element.tagName === 'SPAN') { + element.replaceWith(element.textContent); + } }); return html.documentElement.outerHTML; }; From 68d6ed42e7cc482d0129b793e35387e35ca78bae Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 27 Jan 2026 12:08:21 +0100 Subject: [PATCH 22/28] feat: folder loader --- tools/translate/app.css | 57 ++++++++++++++++++++++++++++++++++++++++ tools/translate/app.html | 15 ++++++++--- tools/translate/app.js | 47 ++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/tools/translate/app.css b/tools/translate/app.css index 3d20283a..dc78617f 100644 --- a/tools/translate/app.css +++ b/tools/translate/app.css @@ -26,6 +26,47 @@ body { 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; @@ -132,6 +173,22 @@ button:hover { background: #ffebe6; } +.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; diff --git a/tools/translate/app.html b/tools/translate/app.html index ebadd31c..7c880779 100644 --- a/tools/translate/app.html +++ b/tools/translate/app.html @@ -9,19 +9,26 @@

    Translation tool

    -

    Enter list of URLs to translate

    +

    Enter list of URLs to translate or load from folder

    +
    +
    + + +
    +
    +
    -
    +
      @@ -29,4 +36,4 @@

      Translation tool

      - \ No newline at end of file + \ No newline at end of file diff --git a/tools/translate/app.js b/tools/translate/app.js index 5f44cf41..99b0d382 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -27,10 +27,55 @@ const ADMIN_URL = 'https://admin.da.live'; const urlsTextarea = document.querySelector('textarea[name="urls"]'); const languageSelect = document.querySelector('select[name="language"]'); - languageSelect.value = 'fr'; 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'); From 0cafa968e16fd3c498af2d429c4c463a698ce433 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 27 Jan 2026 14:36:42 +0100 Subject: [PATCH 23/28] feat: dnt content via config --- tools/translate/app.js | 10 ++-- tools/translate/plugin.js | 3 +- tools/translate/shared.js | 103 +++++++++++++++++++++++--------------- 3 files changed, 68 insertions(+), 48 deletions(-) diff --git a/tools/translate/app.js b/tools/translate/app.js index 99b0d382..61bfe230 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -14,15 +14,10 @@ // eslint-disable-next-line import/no-unresolved import DA_SDK from 'https://da.live/nx/utils/sdk.js'; -import { translate, ADMIN_FORMAT } from './shared.js'; - -const ADMIN_URL = 'https://admin.da.live'; -// const ADMIN_URL = 'https://stage-admin.da.live'; -// const ADMIN_URL = 'http://localhost:8787'; +import { translate, ADMIN_FORMAT, ADMIN_URL } from './shared.js'; (async function init() { - // eslint-disable-next-line no-unused-vars - const { context, token, actions } = await DA_SDK; + const { context, actions } = await DA_SDK; const { daFetch } = actions; const urlsTextarea = document.querySelector('textarea[name="urls"]'); @@ -149,6 +144,7 @@ const ADMIN_URL = 'https://admin.da.live'; languageSelect.value, context, ADMIN_FORMAT, + daFetch, ); updateStatus(listItem, 'translated', 'Translated'); diff --git a/tools/translate/plugin.js b/tools/translate/plugin.js index d3d00ad9..84379ea1 100644 --- a/tools/translate/plugin.js +++ b/tools/translate/plugin.js @@ -23,7 +23,7 @@ import { addDnt, translate, EDITOR_FORMAT } from './shared.js'; let selection = 'No text selected.'; try { selection = await actions.getSelection(); - selection = addDnt(selection, EDITOR_FORMAT); + selection = await addDnt(selection, EDITOR_FORMAT, context, actions.daFetch); } catch (error) { // ignore } @@ -53,6 +53,7 @@ import { addDnt, translate, EDITOR_FORMAT } from './shared.js'; languageSelector.value, context, EDITOR_FORMAT, + actions.daFetch, true, ); } catch (error) { diff --git a/tools/translate/shared.js b/tools/translate/shared.js index d5e1fb2b..80b376f2 100644 --- a/tools/translate/shared.js +++ b/tools/translate/shared.js @@ -10,11 +10,57 @@ * 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 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) => { + const text = p.textContent; + const regex = /:([a-zA-Z0-9-_]+):/g; + const matches = text.match(regex); + if (matches) { + matches.forEach((match) => { + 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', @@ -61,23 +107,7 @@ const RULES = { }); return html; }, - }, { - description: 'Icon names should be not translated', - apply: (html) => { - const ps = html.querySelectorAll('p'); - ps.forEach((p) => { - const text = p.textContent; - const regex = /:([a-zA-Z0-9-_]+):/g; - const matches = text.match(regex); - if (matches) { - matches.forEach((match) => { - p.innerHTML = p.innerHTML.replace(match, `${match}`); - }); - } - }); - return html; - }, - }], + }, ICONS_RULE, CONTENT_DNT_RULE], [ADMIN_FORMAT]: [{ description: 'First column of all rows in "metadata" block should be not translated', apply: (html) => { @@ -98,32 +128,25 @@ const RULES = { }); return html; }, - }, { - description: 'Icon names should be not translated', - apply: (html) => { - const ps = html.querySelectorAll('p'); - ps.forEach((p) => { - const text = p.textContent; - const regex = /:([a-zA-Z0-9-_]+):/g; - const matches = text.match(regex); - if (matches) { - matches.forEach((match) => { - p.innerHTML = p.innerHTML.replace(match, `${match}`); - }); - } - }); - 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 = (text, format) => { - // parse text into html +const addDnt = async (text, format, context, daFetch) => { + const config = await getConfig(context, daFetch); let html = new DOMParser().parseFromString(text, 'text/html'); const rules = RULES[format]; if (rules) { rules.forEach((rule) => { - html = rule.apply(html); + html = rule.apply(html, config); }); } return html.documentElement.outerHTML; @@ -175,10 +198,10 @@ const postProcess = (text, context) => { return result; }; -const translate = async (htmlInput, language, context, format, skipDnt = false) => { +const translate = async (htmlInput, language, context, format, daFetch, skipDnt = false) => { let html = htmlInput; if (!skipDnt) { - html = addDnt(html, format); + html = await addDnt(html, format, context, daFetch); } const splits = []; const maxChunk = 30000; @@ -224,5 +247,5 @@ const translate = async (htmlInput, language, context, format, skipDnt = false) }; export { - addDnt, removeDnt, adjustURLs, postProcess, translate, EDITOR_FORMAT, ADMIN_FORMAT, + addDnt, removeDnt, adjustURLs, postProcess, translate, EDITOR_FORMAT, ADMIN_FORMAT, ADMIN_URL, }; From b5cb4eb7ad730afc2490e8187d5ebddab89d56bf Mon Sep 17 00:00:00 2001 From: kptdobe Date: Tue, 27 Jan 2026 15:28:27 +0100 Subject: [PATCH 24/28] feat: minimun of styling --- tools/translate/app.css | 21 ++++++++++++--------- tools/translate/app.js | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/tools/translate/app.css b/tools/translate/app.css index dc78617f..03e0ae15 100644 --- a/tools/translate/app.css +++ b/tools/translate/app.css @@ -86,14 +86,9 @@ body { padding-left: 16px; } -.app-output-list li::before { - content: ""; - position: absolute; - left: 0; - width: 6px; - height: 6px; - border-radius: 50%; - background-color: var(--muted); +.app-li-number { + font-weight: bold; + margin-right: 8px; } textarea { @@ -147,7 +142,7 @@ button:hover { } .status { - font-size: 12px; + font-size: 14px; font-weight: 600; padding: 2px 8px; border-radius: 12px; @@ -173,6 +168,14 @@ button:hover { background: #ffebe6; } +.status svg { + vertical-align: middle; + margin-right: 4px; + margin-left: 4px; + width: 16px; + height: 16px; +} + .app-error { color: #c62828; font-size: 13px; diff --git a/tools/translate/app.js b/tools/translate/app.js index 61bfe230..3a165879 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -16,6 +16,8 @@ import DA_SDK from 'https://da.live/nx/utils/sdk.js'; import { translate, ADMIN_FORMAT, ADMIN_URL } from './shared.js'; +const EDIT_ICON_SVG = ''; + (async function init() { const { context, actions } = await DA_SDK; const { daFetch } = actions; @@ -100,8 +102,19 @@ import { translate, ADMIN_FORMAT, ADMIN_URL } from './shared.js'; // 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'); - listItem.textContent = urls[i]; + 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; @@ -158,7 +171,7 @@ import { translate, ADMIN_FORMAT, ADMIN_URL } from './shared.js'; 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! View page: ${daHref}`); + updateStatus(listItem, 'saved', `Translated page saved! ${EDIT_ICON_SVG}`); } catch (error) { // eslint-disable-next-line no-console console.error('Error retrieving page content', error); From 40e1213a605be5058e9ae2c739f4b79786fc0c54 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Wed, 28 Jan 2026 11:06:55 +0100 Subject: [PATCH 25/28] feat: metadata dt sheet --- tools/translate/shared.js | 53 +++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/tools/translate/shared.js b/tools/translate/shared.js index 80b376f2..70053267 100644 --- a/tools/translate/shared.js +++ b/tools/translate/shared.js @@ -18,6 +18,8 @@ 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'; @@ -27,11 +29,20 @@ const ICONS_RULE = { 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}`); }); } @@ -75,16 +86,30 @@ const RULES = { return html; }, }, { - description: 'First column of all rows in "metadata" table should be not translated', - apply: (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) => { - row.querySelector('td:first-child').setAttribute('translate', 'no'); + 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'); + } }); } } @@ -110,11 +135,25 @@ const RULES = { }, ICONS_RULE, CONTENT_DNT_RULE], [ADMIN_FORMAT]: [{ description: 'First column of all rows in "metadata" block should be not translated', - apply: (html) => { + 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((div) => { - div.querySelectorAll('& > div > div:first-child').forEach((child) => { - child.setAttribute('translate', 'no'); + 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; From a22ef49d692b9a1ea9a87fcd03cd152cbbd73d33 Mon Sep 17 00:00:00 2001 From: kptdobe Date: Mon, 2 Feb 2026 10:07:57 +0100 Subject: [PATCH 26/28] fix: stored html could be both format --- tools/translate/app.js | 4 ++-- tools/translate/shared.js | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/translate/app.js b/tools/translate/app.js index 3a165879..93d5bed0 100644 --- a/tools/translate/app.js +++ b/tools/translate/app.js @@ -14,7 +14,7 @@ // eslint-disable-next-line import/no-unresolved import DA_SDK from 'https://da.live/nx/utils/sdk.js'; -import { translate, ADMIN_FORMAT, ADMIN_URL } from './shared.js'; +import { translate, ADMIN_URL } from './shared.js'; const EDIT_ICON_SVG = ''; @@ -156,7 +156,7 @@ const EDIT_ICON_SVG = ' { const addDnt = async (text, format, context, daFetch) => { const config = await getConfig(context, daFetch); let html = new DOMParser().parseFromString(text, 'text/html'); - const rules = RULES[format]; + 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); @@ -192,7 +197,7 @@ const addDnt = async (text, format, context, daFetch) => { }; const removeDnt = (html) => { - html.querySelectorAll('[translate="no"]').forEach((element) => { + html.querySelectorAll('[translate]').forEach((element) => { element.removeAttribute('translate'); if (element.tagName === 'SPAN') { From 82420f60b3b9c10f7a78695a341a0e4f8889543c Mon Sep 17 00:00:00 2001 From: Alexandre Capt Date: Tue, 3 Feb 2026 16:33:14 +0100 Subject: [PATCH 27/28] feat: fr by default --- tools/translate/plugin.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/translate/plugin.html b/tools/translate/plugin.html index 76232c44..eab21412 100644 --- a/tools/translate/plugin.html +++ b/tools/translate/plugin.html @@ -15,7 +15,7 @@ @@ -29,4 +29,4 @@
      - \ No newline at end of file + From a04fdb7bb5bb74e1f7b8a8f1c0111c9c86320f6e Mon Sep 17 00:00:00 2001 From: Alexandre Capt Date: Tue, 3 Feb 2026 16:34:19 +0100 Subject: [PATCH 28/28] Fix selected attribute for French language option --- tools/translate/plugin.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/translate/plugin.html b/tools/translate/plugin.html index eab21412..bad8087e 100644 --- a/tools/translate/plugin.html +++ b/tools/translate/plugin.html @@ -15,7 +15,7 @@