diff --git a/libretranslator-seo/BLOG_DRAFT.md b/libretranslator-seo/BLOG_DRAFT.md
new file mode 100644
index 0000000..4dba3d9
--- /dev/null
+++ b/libretranslator-seo/BLOG_DRAFT.md
@@ -0,0 +1,63 @@
+# Building an SEO-Friendly Webpage Translator on Codesphere with LibreTranslate
+
+Language support is often treated as a large rewrite: clone the page, translate every string, rebuild navigation, and keep all versions updated forever. This template takes a lighter path. It keeps the original page as the source of truth, sends text segments through a self-hosted LibreTranslate-compatible service, and returns translated HTML plus metadata that can be used for SEO previews.
+
+The project is made of two pieces:
+
+- a small Node.js proxy that calls LibreTranslate, caches repeated text, and applies glossary rules
+- a browser widget that adds language buttons to an existing webpage
+
+The result is useful for prototypes, blogs, product documentation, and content sites where the team wants to test multilingual reach without adopting a hosted translation SaaS.
+
+## Why Self-Host Translation?
+
+Hosted translation platforms are convenient, but they can become expensive and may raise compliance concerns for private product copy. A self-hosted LibreTranslate service gives teams more control over where content goes. Codesphere is a good fit because the app, proxy, and translation service can live close together in one workspace.
+
+## How the Template Works
+
+The demo page marks translatable content with `data-i18n` attributes. When a user selects a target language, the browser sends those text segments to the local proxy:
+
+```http
+POST /api/translate
+```
+
+The proxy forwards text to LibreTranslate, stores repeated translations in an in-memory cache, and applies glossary overrides. Product names such as `Codesphere` can stay unchanged, while terms like `CI pipeline` can follow approved wording.
+
+For SEO-oriented workflows, the app also exposes:
+
+```http
+POST /api/translate-page
+```
+
+That endpoint accepts HTML, translates text nodes with a structured parser, preserves the page shape, and returns `hreflang` metadata that can be used when generating static translated pages.
+
+## Deploying on Codesphere
+
+1. Create a workspace from the template.
+2. Configure `LIBRETRANSLATE_URL`.
+3. Run the CI pipeline `Prepare` stage.
+4. Run the `Run` stage.
+5. Open the deployment URL.
+
+For local UI testing, enable mock mode:
+
+```bash
+ALLOW_MOCK_TRANSLATION=true npm start
+```
+
+Mock mode is intentionally labeled as a demo fallback and should not be used as real translation proof.
+
+## What This Template Is Good For
+
+This template is a starting point for:
+
+- translating blog pages without manually duplicating content
+- testing multilingual landing pages
+- adding terminology feedback through glossary overrides
+- generating translated HTML previews for editors
+
+It is not a full translation management system. Production sites should persist translated output, review translations with native speakers, and serve localized URLs with stable canonical and alternate links.
+
+## Next Steps
+
+The next improvements would be persistent translation storage, authenticated editor feedback, a build-time static export command, and automatic sitemap generation for translated routes.
diff --git a/libretranslator-seo/README.md b/libretranslator-seo/README.md
new file mode 100644
index 0000000..9255bbe
--- /dev/null
+++ b/libretranslator-seo/README.md
@@ -0,0 +1,109 @@
+# LibreTranslator SEO Webpage Translator
+
+This template shows how to add plug-in style translation controls to an existing webpage while keeping a self-hosted LibreTranslate-compatible endpoint in the loop.
+
+It includes:
+
+- a Node.js proxy that talks to `LIBRETRANSLATE_URL`
+- in-memory caching for repeated text segments
+- glossary overrides for company-specific terminology
+- a demo webpage with language buttons
+- an endpoint that returns translated HTML and `hreflang` metadata for SEO previews
+
+## Quick Start on Codesphere
+
+1. Open the CI pipeline.
+2. Run `Prepare` to install dependencies.
+3. Set `LIBRETRANSLATE_URL` to your LibreTranslate service URL.
+4. Run `Run`.
+5. Open the deployment URL and translate the demo page.
+
+For local demos without a translation service, set:
+
+```bash
+ALLOW_MOCK_TRANSLATION=true
+```
+
+The mock mode keeps the app usable for UI testing, but it is not a real translation engine.
+
+## Environment
+
+```bash
+LIBRETRANSLATE_URL=http://localhost:5000
+LIBRETRANSLATE_API_KEY=
+PORT=3000
+ALLOW_MOCK_TRANSLATION=false
+```
+
+## Running Locally
+
+```bash
+npm install
+npm test
+npm start
+```
+
+Then visit `http://localhost:3000`.
+
+## Optional LibreTranslate Service
+
+Run a LibreTranslate-compatible service in the same workspace or point the app at an existing private endpoint:
+
+```bash
+docker run -p 5000:5000 libretranslate/libretranslate
+```
+
+Then start this template with:
+
+```bash
+LIBRETRANSLATE_URL=http://localhost:5000 npm start
+```
+
+## API
+
+### `GET /api/health`
+
+Returns app and translator endpoint status.
+
+### `GET /api/languages`
+
+Returns languages from LibreTranslate. If the translator is unavailable, the app returns a small fallback list and marks it as fallback data.
+
+### `POST /api/translate`
+
+Translates one or more text segments.
+
+```json
+{
+ "q": ["Scale support without copying pages.", "Codesphere"],
+ "source": "en",
+ "target": "de",
+ "glossary": {
+ "Codesphere": "Codesphere"
+ }
+}
+```
+
+### `POST /api/translate-page`
+
+Accepts HTML, translates text nodes, preserves structure, and returns SEO metadata.
+
+```json
+{
+ "html": "Scale support Translate your page.
",
+ "source": "en",
+ "target": "fr",
+ "canonicalUrl": "https://example.com/blog/support",
+ "glossary": {
+ "Codesphere": "Codesphere"
+ }
+}
+```
+
+## Glossary Overrides
+
+Glossary replacements are applied after translation so product names and approved terminology stay consistent. The demo includes defaults for `Codesphere`, `LibreTranslate`, and `CI pipeline`.
+
+## Blog Draft
+
+The issue asks for a blog article. This PR includes a ready-to-publish draft at [`BLOG_DRAFT.md`](./BLOG_DRAFT.md). It is intentionally not presented as published externally.
diff --git a/libretranslator-seo/ci.yml b/libretranslator-seo/ci.yml
new file mode 100644
index 0000000..a75feb7
--- /dev/null
+++ b/libretranslator-seo/ci.yml
@@ -0,0 +1,14 @@
+prepare:
+ steps:
+ - name: Install dependencies
+ command: npm ci
+ - name: Run tests
+ command: npm test
+test:
+ steps:
+ - name: Test
+ command: npm test
+run:
+ steps:
+ - name: Run
+ command: npm start
diff --git a/libretranslator-seo/libretranslator-seo.webp b/libretranslator-seo/libretranslator-seo.webp
new file mode 100644
index 0000000..6eff16b
Binary files /dev/null and b/libretranslator-seo/libretranslator-seo.webp differ
diff --git a/libretranslator-seo/metadata.json b/libretranslator-seo/metadata.json
new file mode 100644
index 0000000..d797210
--- /dev/null
+++ b/libretranslator-seo/metadata.json
@@ -0,0 +1,10 @@
+{
+ "Workspace": "free",
+ "Links": {
+ "LibreTranslate": "https://libretranslate.com/",
+ "Codesphere CI pipelines": "https://docs.codesphere.com/getting-started/ci-pipelines"
+ },
+ "Categories": ["Application", "SEO", "Translation"],
+ "Contributors": ["OpenAI-Codex"],
+ "Title": "LibreTranslator SEO Webpage Translator"
+}
diff --git a/libretranslator-seo/package-lock.json b/libretranslator-seo/package-lock.json
new file mode 100644
index 0000000..1561b80
--- /dev/null
+++ b/libretranslator-seo/package-lock.json
@@ -0,0 +1,1132 @@
+{
+ "name": "libretranslator-seo-template",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "libretranslator-seo-template",
+ "version": "1.0.0",
+ "dependencies": {
+ "cheerio": "^1.0.0",
+ "express": "^4.19.2"
+ },
+ "devDependencies": {}
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/libretranslator-seo/package.json b/libretranslator-seo/package.json
new file mode 100644
index 0000000..e78177f
--- /dev/null
+++ b/libretranslator-seo/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "libretranslator-seo-template",
+ "version": "1.0.0",
+ "description": "Codesphere template for SEO-friendly webpage translation with a self-hosted LibreTranslate endpoint.",
+ "type": "module",
+ "main": "src/server.js",
+ "scripts": {
+ "start": "node src/server.js",
+ "test": "node --test"
+ },
+ "dependencies": {
+ "cheerio": "^1.0.0",
+ "express": "^4.19.2"
+ },
+ "devDependencies": {}
+}
diff --git a/libretranslator-seo/public/index.html b/libretranslator-seo/public/index.html
new file mode 100644
index 0000000..d2508ad
--- /dev/null
+++ b/libretranslator-seo/public/index.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ LibreTranslator SEO Demo
+
+
+
+
+
+ LibreTranslator SEO
+
+ EN
+ DE
+ ES
+ FR
+
+
+
+
+
+
+
Self-hosted translation for content teams
+
Translate pages without duplicating your whole website.
+
+ Use Codesphere and LibreTranslate to test multilingual pages, keep product terminology consistent, and export translated HTML for SEO review.
+
+
+
+ Translator
+ Checking...
+ Cache entries: 0
+
+
+
+
+
+ Plugin-style language buttons
+
+ Add a small script to your page, mark translatable copy with data attributes, and let the widget replace text in place.
+
+
+
+ Terminology feedback
+
+ Glossary overrides keep Codesphere, LibreTranslate, and CI pipeline wording stable across languages.
+
+
+
+ SEO-ready previews
+
+ The server can translate HTML and return canonical plus alternate language metadata for review workflows.
+
+
+
+
+
+
+
Translated HTML preview
+
Generate a page-level translation response using the same source content.
+
+ Build preview
+
+
+
+
+
+
+
diff --git a/libretranslator-seo/public/styles.css b/libretranslator-seo/public/styles.css
new file mode 100644
index 0000000..8a8f163
--- /dev/null
+++ b/libretranslator-seo/public/styles.css
@@ -0,0 +1,180 @@
+:root {
+ color-scheme: light;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #f5f7fb;
+ color: #172033;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+button {
+ font: inherit;
+}
+
+.topbar {
+ align-items: center;
+ background: #ffffff;
+ border-bottom: 1px solid #d9e1ee;
+ display: flex;
+ justify-content: space-between;
+ min-height: 72px;
+ padding: 0 32px;
+ position: sticky;
+ top: 0;
+}
+
+.topbar nav {
+ display: flex;
+ gap: 8px;
+}
+
+.lang-button,
+#preview-button {
+ background: #ffffff;
+ border: 1px solid #b8c4d6;
+ border-radius: 6px;
+ color: #172033;
+ cursor: pointer;
+ min-height: 38px;
+ padding: 0 14px;
+}
+
+.lang-button.active,
+#preview-button {
+ background: #176b87;
+ border-color: #176b87;
+ color: #ffffff;
+}
+
+main {
+ margin: 0 auto;
+ max-width: 1120px;
+ padding: 48px 24px;
+}
+
+.hero {
+ align-items: stretch;
+ display: grid;
+ gap: 28px;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ margin-bottom: 32px;
+}
+
+.hero h1 {
+ font-size: 48px;
+ letter-spacing: 0;
+ line-height: 1.06;
+ margin: 0 0 18px;
+ max-width: 760px;
+}
+
+.hero p {
+ color: #46566f;
+ font-size: 18px;
+ line-height: 1.6;
+ margin: 0;
+ max-width: 760px;
+}
+
+.eyebrow {
+ color: #176b87;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0;
+ margin-bottom: 14px;
+ text-transform: uppercase;
+}
+
+.status-panel,
+.content-grid article,
+.preview {
+ background: #ffffff;
+ border: 1px solid #d9e1ee;
+ border-radius: 8px;
+ box-shadow: 0 18px 45px rgba(23, 32, 51, 0.08);
+}
+
+.status-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ justify-content: center;
+ padding: 22px;
+}
+
+.status-panel span,
+.status-panel small {
+ color: #63728a;
+}
+
+.content-grid {
+ display: grid;
+ gap: 18px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.content-grid article {
+ min-height: 190px;
+ padding: 22px;
+}
+
+.content-grid h2,
+.preview h2 {
+ font-size: 21px;
+ line-height: 1.25;
+ margin: 0 0 10px;
+}
+
+.content-grid p,
+.preview p {
+ color: #46566f;
+ line-height: 1.55;
+ margin: 0;
+}
+
+.preview {
+ display: grid;
+ gap: 18px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ margin-top: 18px;
+ padding: 22px;
+}
+
+#preview-output {
+ background: #101827;
+ border-radius: 8px;
+ color: #d7e0ee;
+ display: none;
+ grid-column: 1 / -1;
+ line-height: 1.5;
+ margin: 0;
+ max-height: 320px;
+ overflow: auto;
+ padding: 16px;
+ white-space: pre-wrap;
+}
+
+@media (max-width: 760px) {
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px;
+ }
+
+ .hero,
+ .content-grid,
+ .preview {
+ grid-template-columns: 1fr;
+ }
+
+ .hero h1 {
+ font-size: 36px;
+ }
+}
diff --git a/libretranslator-seo/public/translator-widget.js b/libretranslator-seo/public/translator-widget.js
new file mode 100644
index 0000000..dc8570f
--- /dev/null
+++ b/libretranslator-seo/public/translator-widget.js
@@ -0,0 +1,98 @@
+const sourceLanguage = "en";
+const originals = new Map();
+const glossary = {
+ Codesphere: "Codesphere",
+ LibreTranslate: "LibreTranslate",
+ "CI pipeline": "CI pipeline"
+};
+
+const status = document.querySelector("#status");
+const cache = document.querySelector("#cache");
+const buttons = Array.from(document.querySelectorAll(".lang-button"));
+const translatableNodes = Array.from(document.querySelectorAll("[data-i18n]"));
+const previewButton = document.querySelector("#preview-button");
+const previewOutput = document.querySelector("#preview-output");
+
+for (const node of translatableNodes) {
+ originals.set(node, node.textContent.trim());
+}
+
+async function updateHealth() {
+ const response = await fetch("/api/health");
+ const data = await response.json();
+ status.textContent = data.translatorConfigured
+ ? data.languageFallback
+ ? "Fallback languages"
+ : "Connected"
+ : "Mock or unconfigured";
+ cache.textContent = `Cache entries: ${data.cacheEntries}`;
+}
+
+async function translatePage(target) {
+ buttons.forEach((button) => button.classList.toggle("active", button.dataset.lang === target));
+
+ if (target === sourceLanguage) {
+ for (const [node, text] of originals.entries()) {
+ node.textContent = text;
+ }
+ await updateHealth();
+ return;
+ }
+
+ status.textContent = "Translating...";
+ const response = await fetch("/api/translate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ q: Array.from(originals.values()),
+ source: sourceLanguage,
+ target,
+ glossary
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ status.textContent = "Needs translator";
+ previewOutput.style.display = "block";
+ previewOutput.textContent = error.hint ?? error.error;
+ return;
+ }
+
+ const data = await response.json();
+ Array.from(originals.keys()).forEach((node, index) => {
+ node.textContent = data.translated[index];
+ });
+ cache.textContent = `Cache entries: ${data.cacheEntries}`;
+ status.textContent = data.mock ? "Mock mode" : "Connected";
+}
+
+async function buildPreview() {
+ const html = document.querySelector("main").outerHTML;
+ const activeLanguage = document.querySelector(".lang-button.active")?.dataset.lang ?? "de";
+ const target = activeLanguage === sourceLanguage ? "de" : activeLanguage;
+
+ const response = await fetch("/api/translate-page", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ html,
+ source: sourceLanguage,
+ target,
+ canonicalUrl: "https://example.com/blog/global-support",
+ glossary
+ })
+ });
+
+ const data = await response.json();
+ previewOutput.style.display = "block";
+ previewOutput.textContent = JSON.stringify(data.meta ?? data, null, 2);
+}
+
+buttons.forEach((button) => {
+ button.addEventListener("click", () => translatePage(button.dataset.lang));
+});
+
+previewButton.addEventListener("click", buildPreview);
+
+updateHealth();
diff --git a/libretranslator-seo/src/server.js b/libretranslator-seo/src/server.js
new file mode 100644
index 0000000..c282a67
--- /dev/null
+++ b/libretranslator-seo/src/server.js
@@ -0,0 +1,91 @@
+import express from "express";
+import { fileURLToPath } from "node:url";
+import {
+ createTranslationCache,
+ fetchLanguages,
+ translateHtml,
+ translateMany,
+ TranslationError
+} from "./translator.js";
+
+const app = express();
+const cache = createTranslationCache();
+const port = Number(process.env.PORT ?? 3000);
+const endpoint = process.env.LIBRETRANSLATE_URL;
+const apiKey = process.env.LIBRETRANSLATE_API_KEY;
+const allowMock = process.env.ALLOW_MOCK_TRANSLATION === "true";
+const publicDir = fileURLToPath(new URL("../public", import.meta.url));
+
+app.use(express.json({ limit: "1mb" }));
+app.use(express.static(publicDir));
+
+app.get("/api/health", async (_req, res) => {
+ const languages = await fetchLanguages({ endpoint });
+ res.json({
+ ok: true,
+ translatorConfigured: Boolean(endpoint),
+ languageFallback: languages.fallback,
+ cacheEntries: cache.size()
+ });
+});
+
+app.get("/api/languages", async (_req, res) => {
+ const result = await fetchLanguages({ endpoint });
+ res.json(result);
+});
+
+app.post("/api/translate", async (req, res, next) => {
+ try {
+ const translated = await translateMany({
+ q: req.body.q,
+ source: req.body.source,
+ target: req.body.target,
+ glossary: req.body.glossary,
+ endpoint,
+ apiKey,
+ cache,
+ allowMock
+ });
+ res.json({ translated, cacheEntries: cache.size(), mock: allowMock && !endpoint });
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.post("/api/translate-page", async (req, res, next) => {
+ try {
+ const result = await translateHtml({
+ html: req.body.html,
+ source: req.body.source,
+ target: req.body.target,
+ canonicalUrl: req.body.canonicalUrl,
+ glossary: req.body.glossary,
+ endpoint,
+ apiKey,
+ cache,
+ allowMock
+ });
+ res.json({ ...result, cacheEntries: cache.size(), mock: allowMock && !endpoint });
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.use((error, _req, res, _next) => {
+ const status = error instanceof TranslationError ? error.status : 500;
+ res.status(status).json({
+ error: error.message,
+ hint:
+ status === 502
+ ? "Check LIBRETRANSLATE_URL or enable ALLOW_MOCK_TRANSLATION=true for local UI testing."
+ : undefined
+ });
+});
+
+if (process.env.NODE_ENV !== "test") {
+ app.listen(port, () => {
+ console.log(`LibreTranslator SEO template listening on http://localhost:${port}`);
+ });
+}
+
+export default app;
diff --git a/libretranslator-seo/src/translator.js b/libretranslator-seo/src/translator.js
new file mode 100644
index 0000000..ac1e71a
--- /dev/null
+++ b/libretranslator-seo/src/translator.js
@@ -0,0 +1,207 @@
+import * as cheerio from "cheerio";
+
+const DEFAULT_LANGUAGES = [
+ { code: "en", name: "English" },
+ { code: "de", name: "German" },
+ { code: "es", name: "Spanish" },
+ { code: "fr", name: "French" }
+];
+
+export class TranslationError extends Error {
+ constructor(message, options = {}) {
+ super(message);
+ this.name = "TranslationError";
+ this.status = options.status ?? 502;
+ this.cause = options.cause;
+ }
+}
+
+export function createTranslationCache() {
+ const store = new Map();
+ return {
+ get(key) {
+ return store.get(key);
+ },
+ set(key, value) {
+ store.set(key, value);
+ },
+ size() {
+ return store.size;
+ },
+ clear() {
+ store.clear();
+ }
+ };
+}
+
+export function normalizeLanguage(value, fallback) {
+ if (typeof value !== "string" || value.trim() === "") return fallback;
+ return value.trim().toLowerCase();
+}
+
+export function makeCacheKey({ source, target, text, glossary }) {
+ const glossaryPairs = Object.entries(glossary ?? {})
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([term, replacement]) => `${term}:${replacement}`)
+ .join("|");
+ return `${source}->${target}:${glossaryPairs}:${text}`;
+}
+
+export function applyGlossary(text, glossary = {}) {
+ let result = text;
+ for (const [term, replacement] of Object.entries(glossary)) {
+ if (!term) continue;
+ result = result.split(term).join(replacement);
+ }
+ return result;
+}
+
+export function buildHreflangLinks(canonicalUrl, source, target) {
+ if (!canonicalUrl) return [];
+ const url = new URL(canonicalUrl);
+ const path = url.pathname.replace(/\/$/, "");
+ const localizedPath = target === source ? path || "/" : `/${target}${path || ""}`;
+ url.pathname = localizedPath;
+ return [
+ { rel: "canonical", href: canonicalUrl },
+ { rel: "alternate", hreflang: source, href: canonicalUrl },
+ { rel: "alternate", hreflang: target, href: url.toString() }
+ ];
+}
+
+export async function fetchLanguages({ endpoint, fetchImpl = fetch }) {
+ if (!endpoint) return { languages: DEFAULT_LANGUAGES, fallback: true };
+ try {
+ const response = await fetchImpl(new URL("/languages", endpoint));
+ if (!response.ok) throw new TranslationError("LibreTranslate languages endpoint failed");
+ return { languages: await response.json(), fallback: false };
+ } catch {
+ return { languages: DEFAULT_LANGUAGES, fallback: true };
+ }
+}
+
+export async function translateText({
+ text,
+ source = "en",
+ target,
+ endpoint,
+ apiKey,
+ glossary = {},
+ cache = createTranslationCache(),
+ fetchImpl = fetch,
+ allowMock = false
+}) {
+ if (typeof text !== "string") {
+ throw new TranslationError("Text must be a string", { status: 400 });
+ }
+ if (!target) {
+ throw new TranslationError("Target language is required", { status: 400 });
+ }
+ if (text.trim() === "") return text;
+
+ const normalizedSource = normalizeLanguage(source, "en");
+ const normalizedTarget = normalizeLanguage(target, "en");
+ const key = makeCacheKey({ source: normalizedSource, target: normalizedTarget, text, glossary });
+ const cached = cache.get(key);
+ if (cached) return cached;
+
+ if (!endpoint) {
+ if (!allowMock) {
+ throw new TranslationError("LIBRETRANSLATE_URL is not configured");
+ }
+ const mocked = applyGlossary(`[${normalizedTarget}] ${text}`, glossary);
+ cache.set(key, mocked);
+ return mocked;
+ }
+
+ const payload = {
+ q: text,
+ source: normalizedSource,
+ target: normalizedTarget,
+ format: "text"
+ };
+ if (apiKey) payload.api_key = apiKey;
+
+ try {
+ const response = await fetchImpl(new URL("/translate", endpoint), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ throw new TranslationError(`LibreTranslate returned HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ const translated = applyGlossary(data.translatedText ?? "", glossary);
+ cache.set(key, translated);
+ return translated;
+ } catch (error) {
+ if (allowMock) {
+ const mocked = applyGlossary(`[${normalizedTarget}] ${text}`, glossary);
+ cache.set(key, mocked);
+ return mocked;
+ }
+ throw new TranslationError("Unable to translate text", { cause: error });
+ }
+}
+
+export async function translateMany(options) {
+ const values = Array.isArray(options.q) ? options.q : [options.q];
+ return Promise.all(values.map((text) => translateText({ ...options, text })));
+}
+
+export async function translateHtml({
+ html,
+ source = "en",
+ target,
+ canonicalUrl,
+ endpoint,
+ apiKey,
+ glossary = {},
+ cache,
+ fetchImpl = fetch,
+ allowMock = false
+}) {
+ if (typeof html !== "string" || html.trim() === "") {
+ throw new TranslationError("HTML is required", { status: 400 });
+ }
+
+ const $ = cheerio.load(html, { decodeEntities: false });
+ const nodes = [];
+ $("body, main, article, section, header, footer, nav, h1, h2, h3, p, li, a, button, span")
+ .contents()
+ .filter((_, node) => node.type === "text" && $(node).text().trim() !== "")
+ .each((_, node) => nodes.push(node));
+
+ const translated = await Promise.all(
+ nodes.map((node) =>
+ translateText({
+ text: $(node).text(),
+ source,
+ target,
+ endpoint,
+ apiKey,
+ glossary,
+ cache,
+ fetchImpl,
+ allowMock
+ })
+ )
+ );
+
+ nodes.forEach((node, index) => {
+ node.data = translated[index];
+ });
+
+ return {
+ html: $.html(),
+ meta: {
+ source: normalizeLanguage(source, "en"),
+ target: normalizeLanguage(target, "en"),
+ translatedTextNodes: nodes.length,
+ hreflang: buildHreflangLinks(canonicalUrl, source, target)
+ }
+ };
+}
diff --git a/libretranslator-seo/test/translator.test.js b/libretranslator-seo/test/translator.test.js
new file mode 100644
index 0000000..2113258
--- /dev/null
+++ b/libretranslator-seo/test/translator.test.js
@@ -0,0 +1,98 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ applyGlossary,
+ buildHreflangLinks,
+ createTranslationCache,
+ fetchLanguages,
+ makeCacheKey,
+ translateHtml,
+ translateText
+} from "../src/translator.js";
+
+test("applyGlossary preserves approved terms", () => {
+ assert.equal(
+ applyGlossary("Use Kodesphere with Libre Translate", {
+ Kodesphere: "Codesphere",
+ "Libre Translate": "LibreTranslate"
+ }),
+ "Use Codesphere with LibreTranslate"
+ );
+});
+
+test("makeCacheKey is stable for glossary order", () => {
+ const first = makeCacheKey({
+ source: "en",
+ target: "de",
+ text: "Hello",
+ glossary: { b: "B", a: "A" }
+ });
+ const second = makeCacheKey({
+ source: "en",
+ target: "de",
+ text: "Hello",
+ glossary: { a: "A", b: "B" }
+ });
+ assert.equal(first, second);
+});
+
+test("translateText caches successful translations", async () => {
+ const cache = createTranslationCache();
+ let calls = 0;
+ const fetchImpl = async () => {
+ calls += 1;
+ return Response.json({ translatedText: "Hallo Codesphere" });
+ };
+
+ const options = {
+ text: "Hello Codesphere",
+ source: "en",
+ target: "de",
+ endpoint: "http://translator.local",
+ glossary: { Kodesphere: "Codesphere" },
+ cache,
+ fetchImpl
+ };
+
+ assert.equal(await translateText(options), "Hallo Codesphere");
+ assert.equal(await translateText(options), "Hallo Codesphere");
+ assert.equal(calls, 1);
+});
+
+test("fetchLanguages falls back clearly when endpoint is unavailable", async () => {
+ const result = await fetchLanguages({
+ endpoint: "http://translator.local",
+ fetchImpl: async () => {
+ throw new Error("offline");
+ }
+ });
+
+ assert.equal(result.fallback, true);
+ assert.ok(result.languages.some((language) => language.code === "en"));
+});
+
+test("translateHtml preserves markup while translating text nodes", async () => {
+ const result = await translateHtml({
+ html: "Hello Codesphere helps teams.
",
+ source: "en",
+ target: "es",
+ endpoint: "http://translator.local",
+ canonicalUrl: "https://example.com/blog/demo",
+ fetchImpl: async (_url, init) => {
+ const payload = JSON.parse(init.body);
+ return Response.json({ translatedText: `es:${payload.q}` });
+ }
+ });
+
+ assert.match(result.html, /es:Hello<\/h1>/);
+ assert.match(result.html, / es:Codesphere helps teams\.<\/p>/);
+ assert.match(result.html, /