From 0eb9655e0404e9ea9e89caa0bd4156190054ef5d Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 11 May 2026 12:00:11 -0600 Subject: [PATCH 1/2] docs: add docs-check-links target using lychee Validates internal links across the unified .docs-dist/chant/ tree assembled by scripts/build-docs.sh. Each Starlight site only validates its own slug links at build time, so cross-site references (sidebar links, MDX hrefs) between the main docs and the 12 lexicon sites go unchecked until they 404 in prod. Runs lychee in offline mode with absolute paths resolved against .docs-dist/, mirroring how GitHub Pages serves the site. Asset extensions and the Starlight pagefind index are excluded so the signal is page-to-page navigation only. Refs #75. --- justfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/justfile b/justfile index 2d3f8b3..7678b72 100644 --- a/justfile +++ b/justfile @@ -65,6 +65,20 @@ docs-build: docs-serve: docs-build npx serve .docs-dist +# Check internal doc links across the unified site (requires lychee: brew install lychee) +docs-check-links: docs-build + #!/usr/bin/env bash + set -euo pipefail + if ! command -v lychee >/dev/null 2>&1; then + echo "lychee not installed. Install with: brew install lychee" >&2 + exit 127 + fi + lychee --offline --no-progress \ + --root-dir "$PWD/.docs-dist" \ + --exclude '\.(css|js|mjs|svg|png|jpe?g|ico|woff2?|map|json|xml|webp|avif|gif)$' \ + --exclude 'pagefind/' \ + '.docs-dist/chant/**/*.html' + # Build VS Code extension ext-vscode-build: cd editors/vscode && npm install && npm run build From c1062c4cd21143cf52f4c80871784f4d4d588c97 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 11 May 2026 17:42:04 -0600 Subject: [PATCH 2/2] docs: auto-prefix root-relative links via rehype plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-relative links written in MDX content body (TypeDoc-generated /api/* pages, hand-written /lexicons/* references, one codegen literal) do not get the configured Astro `base` prepended at build time. Only sidebar `link:` and Starlight `slug:` entries are base-prefixed. Result: 485 broken links across 120 source files in the main docs alone. Adds rehype-base-url, a small HAST visitor (~70 lines, no deps) that walks the tree and rewrites attributes starting with "/" to start with the configured site `base` instead. Idempotent against already-prefixed links via an optional `projectBase` guard that lets cross-site /chant/... references pass through unchanged in lexicon builds. The plugin lives at packages/core/src/codegen/rehype-base-url.mjs as the single source of truth. The main docs imports it via cross-package relative path; the lexicon codegen copies it into each generated docs site so the generated Astro config can import locally. Docker and Slurm docs are hand-maintained — their astro.config.mjs files use the same relative-path import. Slurm's missing `base` config (a pre-existing bug) is also fixed in passing. Also fixes one hardcoded /serialization/output-formats link in docs-sections.ts:153 at source (writing /chant/...) since the plugin in a lexicon context would have mis-prefixed it. Verification: just docs-check-links drops from ~603 errors to 18. The remaining 18 are pre-existing cross-doc content bugs (missing chant/guide/multi-lexicon target, mis-prefixed temporal links, k8s composite relative-link confusion) tracked separately under #75. Refs #75. --- docs/astro.config.mjs | 4 + lexicons/aws/docs/astro.config.mjs | 4 + lexicons/aws/docs/src/content/docs/index.mdx | 6 +- lexicons/aws/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/azure/docs/astro.config.mjs | 4 + .../azure/docs/src/content/docs/index.mdx | 6 +- lexicons/azure/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/docker/docs/astro.config.mjs | 4 + lexicons/flyway/docs/astro.config.mjs | 4 + lexicons/flyway/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/gcp/docs/astro.config.mjs | 4 + lexicons/gcp/docs/src/content/docs/index.mdx | 8 +- lexicons/gcp/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/github/docs/astro.config.mjs | 4 + lexicons/github/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/gitlab/docs/astro.config.mjs | 4 + lexicons/gitlab/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/helm/docs/astro.config.mjs | 4 + lexicons/helm/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/k8s/docs/astro.config.mjs | 4 + lexicons/k8s/docs/src/content/docs/index.mdx | 12 +- lexicons/k8s/docs/src/rehype-base-url.mjs | 68 ++++++++ lexicons/slurm/docs/astro.config.mjs | 5 + lexicons/temporal/docs/astro.config.mjs | 4 + .../docs/src/content/docs/serialization.mdx | 2 +- .../temporal/docs/src/rehype-base-url.mjs | 68 ++++++++ packages/core/src/codegen/docs-sections.ts | 2 +- packages/core/src/codegen/docs.ts | 16 +- .../core/src/codegen/rehype-base-url.d.mts | 17 ++ packages/core/src/codegen/rehype-base-url.mjs | 68 ++++++++ .../core/src/codegen/rehype-base-url.test.ts | 161 ++++++++++++++++++ 31 files changed, 939 insertions(+), 20 deletions(-) create mode 100644 lexicons/aws/docs/src/rehype-base-url.mjs create mode 100644 lexicons/azure/docs/src/rehype-base-url.mjs create mode 100644 lexicons/flyway/docs/src/rehype-base-url.mjs create mode 100644 lexicons/gcp/docs/src/rehype-base-url.mjs create mode 100644 lexicons/github/docs/src/rehype-base-url.mjs create mode 100644 lexicons/gitlab/docs/src/rehype-base-url.mjs create mode 100644 lexicons/helm/docs/src/rehype-base-url.mjs create mode 100644 lexicons/k8s/docs/src/rehype-base-url.mjs create mode 100644 lexicons/temporal/docs/src/rehype-base-url.mjs create mode 100644 packages/core/src/codegen/rehype-base-url.d.mts create mode 100644 packages/core/src/codegen/rehype-base-url.mjs create mode 100644 packages/core/src/codegen/rehype-base-url.test.ts diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index e5eda0b..6a8a8c9 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -1,11 +1,15 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from '../packages/core/src/codegen/rehype-base-url.mjs'; // https://astro.build/config export default defineConfig({ site: 'https://intentius.io', base: '/chant', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant' }]], + }, integrations: [ starlight({ title: 'chant', diff --git a/lexicons/aws/docs/astro.config.mjs b/lexicons/aws/docs/astro.config.mjs index ae9be3b..67766a0 100644 --- a/lexicons/aws/docs/astro.config.mjs +++ b/lexicons/aws/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/aws/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/aws/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'AWS CloudFormation', diff --git a/lexicons/aws/docs/src/content/docs/index.mdx b/lexicons/aws/docs/src/content/docs/index.mdx index f2c5f47..f143563 100644 --- a/lexicons/aws/docs/src/content/docs/index.mdx +++ b/lexicons/aws/docs/src/content/docs/index.mdx @@ -17,9 +17,9 @@ npm install --save-dev @intentius/chant-lexicon-aws | Metric | Count | |--------|-------| -| Resources | 1,500+ | -| Property types | 13,000+ | -| Services | 270+ | +| Resources | 1592 | +| Property types | 13357 | +| Services | 278 | | Intrinsic functions | 9 | | Pseudo-parameters | 8 | | Lint rules | 32 | diff --git a/lexicons/aws/docs/src/rehype-base-url.mjs b/lexicons/aws/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/aws/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/azure/docs/astro.config.mjs b/lexicons/azure/docs/astro.config.mjs index 5591c83..8dcc718 100644 --- a/lexicons/azure/docs/astro.config.mjs +++ b/lexicons/azure/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/azure/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/azure/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Azure Resource Manager', diff --git a/lexicons/azure/docs/src/content/docs/index.mdx b/lexicons/azure/docs/src/content/docs/index.mdx index a7e1135..43c30c8 100644 --- a/lexicons/azure/docs/src/content/docs/index.mdx +++ b/lexicons/azure/docs/src/content/docs/index.mdx @@ -17,9 +17,9 @@ npm install --save-dev @intentius/chant-lexicon-azure | Metric | Count | |--------|-------| -| Resources | 1,900+ | -| Property types | 300,000+ | -| Services | 230+ | +| Resources | 2039 | +| Property types | 312218 | +| Services | 243 | | Intrinsic functions | 9 | | Pseudo-parameters | 6 | | Lint rules | 23 | diff --git a/lexicons/azure/docs/src/rehype-base-url.mjs b/lexicons/azure/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/azure/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/docker/docs/astro.config.mjs b/lexicons/docker/docs/astro.config.mjs index 942c656..569c283 100644 --- a/lexicons/docker/docs/astro.config.mjs +++ b/lexicons/docker/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from '../../../packages/core/src/codegen/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/docker/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/docker/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Docker', diff --git a/lexicons/flyway/docs/astro.config.mjs b/lexicons/flyway/docs/astro.config.mjs index fd38f6e..0ed3e1c 100644 --- a/lexicons/flyway/docs/astro.config.mjs +++ b/lexicons/flyway/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/flyway/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/flyway/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Flyway', diff --git a/lexicons/flyway/docs/src/rehype-base-url.mjs b/lexicons/flyway/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/flyway/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/gcp/docs/astro.config.mjs b/lexicons/gcp/docs/astro.config.mjs index 8910d05..30e1765 100644 --- a/lexicons/gcp/docs/astro.config.mjs +++ b/lexicons/gcp/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/gcp/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/gcp/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'GCP Config Connector', diff --git a/lexicons/gcp/docs/src/content/docs/index.mdx b/lexicons/gcp/docs/src/content/docs/index.mdx index bf56cd1..e321b4d 100644 --- a/lexicons/gcp/docs/src/content/docs/index.mdx +++ b/lexicons/gcp/docs/src/content/docs/index.mdx @@ -40,14 +40,14 @@ The lexicon provides **300+ resource types** across Compute, Storage, IAM, Netwo | Metric | Count | |--------|-------| -| Resources | 450+ | -| Property types | 1,200+ | -| Services | 120+ | +| Resources | 453 | +| Property types | 1257 | +| Services | 124 | | Intrinsic functions | 0 | | Pseudo-parameters | 3 | | Lint rules | 26 | -**Lexicon version:** 0.1.5 +**Lexicon version:** 0.1.6 **Namespace:** `GCP` - [Getting Started](./getting-started) diff --git a/lexicons/gcp/docs/src/rehype-base-url.mjs b/lexicons/gcp/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/gcp/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/github/docs/astro.config.mjs b/lexicons/github/docs/astro.config.mjs index 33c4018..20d9727 100644 --- a/lexicons/github/docs/astro.config.mjs +++ b/lexicons/github/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/github/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/github/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'GitHub Actions', diff --git a/lexicons/github/docs/src/rehype-base-url.mjs b/lexicons/github/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/github/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/gitlab/docs/astro.config.mjs b/lexicons/gitlab/docs/astro.config.mjs index d1a01c9..f5daea5 100644 --- a/lexicons/gitlab/docs/astro.config.mjs +++ b/lexicons/gitlab/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/gitlab/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/gitlab/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'GitLab CI/CD', diff --git a/lexicons/gitlab/docs/src/rehype-base-url.mjs b/lexicons/gitlab/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/gitlab/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/helm/docs/astro.config.mjs b/lexicons/helm/docs/astro.config.mjs index ac4e725..26de30c 100644 --- a/lexicons/helm/docs/astro.config.mjs +++ b/lexicons/helm/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/helm/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/helm/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Helm', diff --git a/lexicons/helm/docs/src/rehype-base-url.mjs b/lexicons/helm/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/helm/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/k8s/docs/astro.config.mjs b/lexicons/k8s/docs/astro.config.mjs index 9ee6c58..edcbf91 100644 --- a/lexicons/k8s/docs/astro.config.mjs +++ b/lexicons/k8s/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/k8s/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/k8s/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Kubernetes', diff --git a/lexicons/k8s/docs/src/content/docs/index.mdx b/lexicons/k8s/docs/src/content/docs/index.mdx index 9ee4435..ba68cc2 100644 --- a/lexicons/k8s/docs/src/content/docs/index.mdx +++ b/lexicons/k8s/docs/src/content/docs/index.mdx @@ -5,7 +5,7 @@ description: "Typed constructors for Kubernetes resource manifests" The **Kubernetes** lexicon provides typed constructors for Kubernetes resource manifests. It covers Deployments, Services, ConfigMaps, StatefulSets, Jobs, -Ingress, RBAC, and 150+ resources and 70+ property types. +Ingress, RBAC, and 147 resources and 50 property types. New? Start with the [Getting Started](/chant/lexicons/k8s/getting-started/) guide. @@ -51,21 +51,21 @@ export const service = new Service({ }); ``` -The lexicon provides **150+ resource types** (Deployment, Service, ConfigMap, StatefulSet, and more), **70+ property types** (Container, Probe, Volume, SecurityContext, etc.), and composites (WebApp, StatefulApp, CronWorkload, AutoscaledService, WorkerPool, NamespaceEnv, NodeAgent) for common patterns. +The lexicon provides **147 resource types** (Deployment, Service, ConfigMap, StatefulSet, and more), **50 property types** (Container, Probe, Volume, SecurityContext, etc.), and composites (WebApp, StatefulApp, CronWorkload, AutoscaledService, WorkerPool, NamespaceEnv, NodeAgent) for common patterns. ## At a Glance | Metric | Count | |--------|-------| -| Resources | 150+ | -| Property types | 70+ | -| Services | 22+ | +| Resources | 150 | +| Property types | 70 | +| Services | 22 | | Intrinsic functions | 0 | | Pseudo-parameters | 0 | | Lint rules | 29 | -**Lexicon version:** 0.1.5 +**Lexicon version:** 0.1.6 **Namespace:** `K8s` - [Getting Started](./getting-started) diff --git a/lexicons/k8s/docs/src/rehype-base-url.mjs b/lexicons/k8s/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/k8s/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/lexicons/slurm/docs/astro.config.mjs b/lexicons/slurm/docs/astro.config.mjs index 1b1f62f..05a3504 100644 --- a/lexicons/slurm/docs/astro.config.mjs +++ b/lexicons/slurm/docs/astro.config.mjs @@ -1,8 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from '../../../packages/core/src/codegen/rehype-base-url.mjs'; export default defineConfig({ + base: '/chant/lexicons/slurm/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/slurm/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Slurm', diff --git a/lexicons/temporal/docs/astro.config.mjs b/lexicons/temporal/docs/astro.config.mjs index 690ff31..efc9b7e 100644 --- a/lexicons/temporal/docs/astro.config.mjs +++ b/lexicons/temporal/docs/astro.config.mjs @@ -1,9 +1,13 @@ // @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; export default defineConfig({ base: '/chant/lexicons/temporal/', + markdown: { + rehypePlugins: [[rehypeBaseUrl, { base: '/chant/lexicons/temporal/', projectBase: '/chant' }]], + }, integrations: [ starlight({ title: 'Temporal', diff --git a/lexicons/temporal/docs/src/content/docs/serialization.mdx b/lexicons/temporal/docs/src/content/docs/serialization.mdx index f1ae1d9..627bbd2 100644 --- a/lexicons/temporal/docs/src/content/docs/serialization.mdx +++ b/lexicons/temporal/docs/src/content/docs/serialization.mdx @@ -5,4 +5,4 @@ description: "Output format for the Temporal lexicon" The Temporal lexicon serializes resources into its native output format during the build step. -See the [Serialization](/serialization/output-formats) guide for general information about output formats in chant. +See the [Serialization](/chant/serialization/output-formats) guide for general information about output formats in chant. diff --git a/lexicons/temporal/docs/src/rehype-base-url.mjs b/lexicons/temporal/docs/src/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/lexicons/temporal/docs/src/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/packages/core/src/codegen/docs-sections.ts b/packages/core/src/codegen/docs-sections.ts index 79ac733..c2b46ed 100644 --- a/packages/core/src/codegen/docs-sections.ts +++ b/packages/core/src/codegen/docs-sections.ts @@ -150,7 +150,7 @@ export function generateSerialization(config: DocsConfig): string { lines.push( `The ${config.displayName} lexicon serializes resources into its native output format during the build step.`, "", - "See the [Serialization](/serialization/output-formats) guide for general information about output formats in chant.", + "See the [Serialization](/chant/serialization/output-formats) guide for general information about output formats in chant.", ); } diff --git a/packages/core/src/codegen/docs.ts b/packages/core/src/codegen/docs.ts index 4256e38..e14309e 100644 --- a/packages/core/src/codegen/docs.ts +++ b/packages/core/src/codegen/docs.ts @@ -7,8 +7,9 @@ * (service grouping, resource type URLs, custom overview content). */ -import { readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; +import { copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; import { join } from "path"; +import { fileURLToPath } from "url"; import { expandFileMarkers } from "./docs-file-markers"; import { scanRules, generateRules } from "./docs-rule-scanning"; @@ -222,14 +223,25 @@ export const collections = { `, ); + // src/rehype-base-url.mjs — copied from chant core so Astro can import it + // without the generated docs site needing a workspace dep on @intentius/chant. + const pluginSrcPath = fileURLToPath( + new URL("./rehype-base-url.mjs", import.meta.url), + ); + copyFileSync(pluginSrcPath, join(outDir, "src", "rehype-base-url.mjs")); + // astro.config.mjs + const rehypeLine = config.basePath + ? `\n markdown: {\n rehypePlugins: [[rehypeBaseUrl, { base: '${config.basePath}', projectBase: '/chant' }]],\n },` + : ""; writeFileSync( join(outDir, "astro.config.mjs"), `// @ts-check import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; +import rehypeBaseUrl from './src/rehype-base-url.mjs'; -export default defineConfig({${config.basePath ? `\n base: '${config.basePath}',` : ""} +export default defineConfig({${config.basePath ? `\n base: '${config.basePath}',` : ""}${rehypeLine} integrations: [ starlight({ title: '${config.displayName}', diff --git a/packages/core/src/codegen/rehype-base-url.d.mts b/packages/core/src/codegen/rehype-base-url.d.mts new file mode 100644 index 0000000..36246a5 --- /dev/null +++ b/packages/core/src/codegen/rehype-base-url.d.mts @@ -0,0 +1,17 @@ +export interface RehypeBaseUrlOptions { + /** Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. */ + base: string; + /** Project-wide base used to detect already-correctly-prefixed cross-site links. */ + projectBase?: string; +} + +type HastNode = { + type: string; + tagName?: string; + properties?: Record; + children?: HastNode[]; +}; + +export default function rehypeBaseUrl( + opts: RehypeBaseUrlOptions, +): (tree: HastNode) => void; diff --git a/packages/core/src/codegen/rehype-base-url.mjs b/packages/core/src/codegen/rehype-base-url.mjs new file mode 100644 index 0000000..b92d422 --- /dev/null +++ b/packages/core/src/codegen/rehype-base-url.mjs @@ -0,0 +1,68 @@ +/** + * Rehype plugin: prepend a configured `base` to root-relative `` attributes. + * + * Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:` + * entries, `slug:` entries). Root-relative links written in MD/MDX content body + * — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production + * when the site is served from a non-root `base`. + * + * This plugin walks the HAST tree, finds `` elements whose href starts with + * `/` (single leading slash, not `//`), and prepends the site's `base`. It + * idempotently skips hrefs that already start with the site's own base or the + * project-wide base. + * + * @typedef {Object} RehypeBaseUrlOptions + * @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. + * @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links. + */ + +const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i; + +function normalizeBase(value) { + return "/" + value.replace(/^\/+|\/+$/g, ""); +} + +/** + * @param {RehypeBaseUrlOptions} opts + */ +export default function rehypeBaseUrl(opts) { + const base = normalizeBase(opts.base); + if (base === "/") { + return () => {}; + } + const ownPrefix = base + "/"; + const projectPrefix = opts.projectBase + ? normalizeBase(opts.projectBase) + "/" + : null; + + function rewrite(node) { + if ( + node && + node.type === "element" && + node.tagName === "a" && + node.properties && + typeof node.properties.href === "string" + ) { + const href = node.properties.href; + if ( + href.length > 0 && + !href.startsWith("//") && + !PROTOCOL_RE.test(href) && + !href.startsWith("#") && + href.startsWith("/") && + href !== base && + !href.startsWith(ownPrefix) && + !(projectPrefix && href.startsWith(projectPrefix)) + ) { + node.properties.href = base + href; + } + } + if (node && node.children) { + for (const child of node.children) rewrite(child); + } + } + + return (tree) => { + rewrite(tree); + }; +} diff --git a/packages/core/src/codegen/rehype-base-url.test.ts b/packages/core/src/codegen/rehype-base-url.test.ts new file mode 100644 index 0000000..f3861ef --- /dev/null +++ b/packages/core/src/codegen/rehype-base-url.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; +import rehypeBaseUrl from "./rehype-base-url.mjs"; + +type Element = { + type: "element"; + tagName: string; + properties: Record; + children: Element[]; +}; + +function a(href: string): Element { + return { type: "element", tagName: "a", properties: { href }, children: [] }; +} + +function tree(...links: Element[]): Element { + return { type: "element", tagName: "root", properties: {}, children: links }; +} + +function run(opts: { base: string; projectBase?: string }, href: string): string { + const plugin = rehypeBaseUrl(opts); + const root = tree(a(href)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (plugin as any)(root); + return root.children[0].properties.href as string; +} + +describe("rehypeBaseUrl — main docs (base=/chant)", () => { + const opts = { base: "/chant", projectBase: "/chant" }; + + it("prepends base to plain root-relative link", () => { + expect(run(opts, "/lexicons/aws/")).toBe("/chant/lexicons/aws/"); + }); + + it("prepends base to /api/* TypeDoc link", () => { + expect(run(opts, "/api/classes/attrref/")).toBe("/chant/api/classes/attrref/"); + }); + + it("leaves already-prefixed /chant/ link unchanged", () => { + expect(run(opts, "/chant/concepts/philosophy/")).toBe( + "/chant/concepts/philosophy/", + ); + }); + + it("leaves the bare base unchanged", () => { + expect(run(opts, "/chant")).toBe("/chant"); + }); + + it("leaves https://… unchanged", () => { + expect(run(opts, "https://example.com/foo")).toBe("https://example.com/foo"); + }); + + it("leaves protocol-relative // unchanged", () => { + expect(run(opts, "//cdn.example.com/x")).toBe("//cdn.example.com/x"); + }); + + it("leaves mailto: unchanged", () => { + expect(run(opts, "mailto:a@b")).toBe("mailto:a@b"); + }); + + it("leaves anchor #foo unchanged", () => { + expect(run(opts, "#section")).toBe("#section"); + }); + + it("leaves relative path unchanged", () => { + expect(run(opts, "foo/bar")).toBe("foo/bar"); + }); + + it("leaves dot-relative path unchanged", () => { + expect(run(opts, "../sibling/")).toBe("../sibling/"); + }); + + it("leaves empty href unchanged", () => { + expect(run(opts, "")).toBe(""); + }); +}); + +describe("rehypeBaseUrl — lexicon (base=/chant/lexicons/aws, projectBase=/chant)", () => { + const opts = { base: "/chant/lexicons/aws", projectBase: "/chant" }; + + it("leaves cross-site /chant/… unchanged (projectBase guard)", () => { + expect(run(opts, "/chant/concepts/philosophy/")).toBe( + "/chant/concepts/philosophy/", + ); + }); + + it("leaves cross-lexicon /chant/lexicons/k8s/… unchanged", () => { + expect(run(opts, "/chant/lexicons/k8s/")).toBe("/chant/lexicons/k8s/"); + }); + + it("leaves the lexicon's own base prefix unchanged", () => { + expect(run(opts, "/chant/lexicons/aws/composites/")).toBe( + "/chant/lexicons/aws/composites/", + ); + }); + + it("prepends lexicon base to bare /foo/ (interpreted as site-local)", () => { + expect(run(opts, "/foo/bar/")).toBe("/chant/lexicons/aws/foo/bar/"); + }); +}); + +describe("rehypeBaseUrl — base normalization", () => { + it("is a no-op when base is '/'", () => { + const plugin = rehypeBaseUrl({ base: "/" }); + const root = tree(a("/foo")); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (plugin as any)(root); + expect(root.children[0].properties.href).toBe("/foo"); + }); + + it("handles trailing slashes in base option", () => { + expect(run({ base: "/chant/", projectBase: "/chant/" }, "/foo")).toBe( + "/chant/foo", + ); + }); + + it("handles missing leading slash in base option", () => { + expect(run({ base: "chant", projectBase: "chant" }, "/foo")).toBe("/chant/foo"); + }); +}); + +describe("rehypeBaseUrl — tree traversal", () => { + it("rewrites nested hrefs", () => { + const plugin = rehypeBaseUrl({ base: "/chant", projectBase: "/chant" }); + const root = tree(); + root.children = [ + { + type: "element", + tagName: "div", + properties: {}, + children: [a("/foo"), a("/bar")], + }, + a("/baz"), + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (plugin as any)(root); + const div = root.children[0] as Element; + expect(div.children[0].properties.href).toBe("/chant/foo"); + expect(div.children[1].properties.href).toBe("/chant/bar"); + expect(root.children[1].properties.href).toBe("/chant/baz"); + }); + + it("leaves non- elements alone", () => { + const plugin = rehypeBaseUrl({ base: "/chant" }); + const root: Element = { + type: "element", + tagName: "root", + properties: {}, + children: [ + { + type: "element", + tagName: "img", + properties: { src: "/foo.png" }, + children: [], + }, + ], + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (plugin as any)(root); + expect(root.children[0].properties.src).toBe("/foo.png"); + }); +});