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

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