From 6f72d02c740800288041b101ec04d7a53845313c Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Sat, 16 May 2026 23:38:14 -0400 Subject: [PATCH] Add LibreTranslator SEO template --- libretranslator-seo/BLOG_DRAFT.md | 63 + libretranslator-seo/README.md | 109 ++ libretranslator-seo/ci.yml | 14 + libretranslator-seo/libretranslator-seo.webp | Bin 0 -> 20848 bytes libretranslator-seo/metadata.json | 10 + libretranslator-seo/package-lock.json | 1132 +++++++++++++++++ libretranslator-seo/package.json | 16 + libretranslator-seo/public/index.html | 70 + libretranslator-seo/public/styles.css | 180 +++ .../public/translator-widget.js | 98 ++ libretranslator-seo/src/server.js | 91 ++ libretranslator-seo/src/translator.js | 207 +++ libretranslator-seo/test/translator.test.js | 98 ++ 13 files changed, 2088 insertions(+) create mode 100644 libretranslator-seo/BLOG_DRAFT.md create mode 100644 libretranslator-seo/README.md create mode 100644 libretranslator-seo/ci.yml create mode 100644 libretranslator-seo/libretranslator-seo.webp create mode 100644 libretranslator-seo/metadata.json create mode 100644 libretranslator-seo/package-lock.json create mode 100644 libretranslator-seo/package.json create mode 100644 libretranslator-seo/public/index.html create mode 100644 libretranslator-seo/public/styles.css create mode 100644 libretranslator-seo/public/translator-widget.js create mode 100644 libretranslator-seo/src/server.js create mode 100644 libretranslator-seo/src/translator.js create mode 100644 libretranslator-seo/test/translator.test.js 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 0000000000000000000000000000000000000000..6eff16b10d843882ab5a00e14ecf0d34c85e8146 GIT binary patch literal 20848 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 G8~z91wH7D< literal 0 HcmV?d00001 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 + +
+ +
+
+
+

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. +

+
+ +
+ +
+
+

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.

+
+ +

+      
+
+ + + + 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, /