From 31e4f3858aef997eb2c1fb43597f4b191ed3d57b Mon Sep 17 00:00:00 2001 From: Daniel <70107167+Programador-jr@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:28:36 -0500 Subject: [PATCH 1/4] test(i18n): validate locales and generated documentation --- tools/validate-locales.js | 182 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tools/validate-locales.js diff --git a/tools/validate-locales.js b/tools/validate-locales.js new file mode 100644 index 0000000..fe100e6 --- /dev/null +++ b/tools/validate-locales.js @@ -0,0 +1,182 @@ +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const DOCS = path.join(ROOT, "docs"); +const DIST = path.join(ROOT, "dist"); +const LANGS = ["en", "pt", "es"]; +const GENERATED_PAGES = ["index.html", "docs.html", "terms.html", "privacy.html"]; +const REQUIRED_DOC_SECTIONS = [ + "overview", + "item-1", + "item-1-1", + "item-1-2", + "item-1-3", + "item-1-4", + "item-1-5", + "item-1-6", + "item-2", + "item-3", + "item-3-1", +]; + +function read(file) { + return fs.readFileSync(file, "utf8"); +} + +function fail(message) { + throw new Error(message); +} + +function extractIds(html) { + return new Set( + [...html.matchAll(/\bid=["']([^"']+)["']/gi)].map((match) => match[1]), + ); +} + +function assertNoUnresolvedPlaceholders(html, file) { + const unresolved = html.match(/\[\[[\w.]+\]\]|\{\{\{?[\w.]+\}?\}\}?/g); + if (unresolved?.length) { + fail( + `Unresolved template placeholders in ${file}: ${[...new Set(unresolved)].join(", ")}`, + ); + } +} + +function resolveOutputPath(pathname) { + const normalized = pathname.replace(/\/+$/, "") || "/"; + const routes = new Map([ + ["/", "en/index.html"], + ["/docs", "en/docs.html"], + ["/404", "404.html"], + ]); + + for (const lang of LANGS) { + routes.set(`/${lang}`, `${lang}/index.html`); + routes.set(`/${lang}/docs`, `${lang}/docs.html`); + routes.set(`/${lang}/terms`, `${lang}/terms.html`); + routes.set(`/${lang}/privacy`, `${lang}/privacy.html`); + } + + const relativePath = routes.get(normalized) ?? normalized.replace(/^\//, ""); + const outputPath = path.resolve(DIST, relativePath); + + if (outputPath !== DIST && !outputPath.startsWith(`${DIST}${path.sep}`)) { + fail(`Internal route escapes the build output: ${pathname}`); + } + + return outputPath; +} + +function validateInternalLinks(html, sourcePath, publicPath) { + const baseUrl = new URL(publicPath, "https://musclelib.local"); + const links = [...html.matchAll(/\bhref\s*=\s*(["'])(.*?)\1/gi)]; + + for (const match of links) { + const href = match[2].replace(/&/g, "&").trim(); + if (!href) continue; + + let target; + try { + target = new URL(href, baseUrl); + } catch { + fail(`Invalid link in ${sourcePath}: ${href}`); + } + + if (target.protocol !== "http:" && target.protocol !== "https:") continue; + if (target.origin !== baseUrl.origin) continue; + + const targetPath = resolveOutputPath(decodeURIComponent(target.pathname)); + if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isFile()) { + fail(`Broken internal route in ${sourcePath}: ${href}`); + } + + if (target.hash) { + const targetHtml = read(targetPath); + const targetIds = extractIds(targetHtml); + const fragment = decodeURIComponent(target.hash.slice(1)); + if (!targetIds.has(fragment)) { + fail(`Broken internal anchor in ${sourcePath}: ${href}`); + } + } + } +} + +const localeData = Object.fromEntries( + LANGS.map((lang) => { + const file = path.join(DOCS, "content", lang, "ui.json"); + if (!fs.existsSync(file)) fail(`Missing locale file: ${file}`); + + let data; + try { + data = JSON.parse(read(file)); + } catch (error) { + fail(`Invalid JSON in ${file}: ${error.message}`); + } + + for (const [key, value] of Object.entries(data)) { + if (typeof value !== "string" || value.trim() === "") { + fail(`Invalid or empty translation value in ${file}: ${key}`); + } + } + + return [lang, data]; + }), +); + +const referenceKeys = new Set(Object.keys(localeData.en)); +for (const lang of LANGS.slice(1)) { + const keys = new Set(Object.keys(localeData[lang])); + const missing = [...referenceKeys].filter((key) => !keys.has(key)); + const extra = [...keys].filter((key) => !referenceKeys.has(key)); + + if (missing.length || extra.length) { + fail( + `Locale key mismatch for ${lang}. Missing: ${missing.join(", ") || "none"}. Extra: ${extra.join(", ") || "none"}.`, + ); + } +} + +for (const lang of LANGS) { + for (const page of GENERATED_PAGES) { + const relativePath = `${lang}/${page}`; + const file = path.join(DIST, relativePath); + if (!fs.existsSync(file)) fail(`Missing generated page: ${relativePath}`); + + const html = read(file); + const languageAttribute = new RegExp( + `]*\\blang=["']${lang}["']`, + "i", + ); + + if (!languageAttribute.test(html)) { + fail(`Invalid or missing lang attribute in ${relativePath}`); + } + + assertNoUnresolvedPlaceholders(html, relativePath); + validateInternalLinks(html, relativePath, `/${lang}/${page}`); + + if (page === "docs.html") { + const ids = extractIds(html); + const missingSections = REQUIRED_DOC_SECTIONS.filter((id) => !ids.has(id)); + if (missingSections.length) { + fail( + `Missing required documentation sections in ${relativePath}: ${missingSections.join(", ")}`, + ); + } + } + } +} + +const notFoundPath = path.join(DIST, "404.html"); +if (!fs.existsSync(notFoundPath)) fail("Missing generated page: 404.html"); +const notFoundHtml = read(notFoundPath); +if (!/]*\blang=["']en["']/i.test(notFoundHtml)) { + fail("Invalid or missing lang attribute in 404.html"); +} +assertNoUnresolvedPlaceholders(notFoundHtml, "404.html"); +validateInternalLinks(notFoundHtml, "404.html", "/404"); + +console.log( + "Localization and generated-page checks passed for en, pt, and es.", +); From 85acaf7fbf454a5ff9ff3eb938eea82c7c6c68c2 Mon Sep 17 00:00:00 2001 From: Daniel <70107167+Programador-jr@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:28:38 -0500 Subject: [PATCH 2/4] ci: add documentation build workflow --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2af178d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-and-validate: + name: Build and validate documentation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build and validate documentation + run: npm run build From 3fefe55ab89b9c884ad159f2e09928a92a64469d Mon Sep 17 00:00:00 2001 From: Daniel <70107167+Programador-jr@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:28:41 -0500 Subject: [PATCH 3/4] test(ci): add localization validation scripts --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index abdc7e8..b320526 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "private": true, "type": "module", "scripts": { - "build": "node tools/build.js && node tools/validate-privacy.js", + "build": "node tools/build.js && node tools/validate-privacy.js && npm run validate", "dev": "node tools/dev.js", + "validate": "node tools/validate-locales.js", + "test": "npm run build", "vercel-build": "npm run build" }, "dependencies": { From fe43511c6308d79fe8996c359883477767dc0bcc Mon Sep 17 00:00:00 2001 From: Daniel <70107167+Programador-jr@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:29:30 -0500 Subject: [PATCH 4/4] test(i18n): detect unresolved translation expressions --- tools/validate-locales.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/validate-locales.js b/tools/validate-locales.js index fe100e6..7fd0815 100644 --- a/tools/validate-locales.js +++ b/tools/validate-locales.js @@ -35,7 +35,7 @@ function extractIds(html) { } function assertNoUnresolvedPlaceholders(html, file) { - const unresolved = html.match(/\[\[[\w.]+\]\]|\{\{\{?[\w.]+\}?\}\}?/g); + const unresolved = html.match(/\[\[[\w.]+\]\]|\{\{\{?[\w.:]+\}?\}\}?/g); if (unresolved?.length) { fail( `Unresolved template placeholders in ${file}: ${[...new Set(unresolved)].join(", ")}`,