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");
+ });
+});