diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index e5eda0bd..6a8a8c9d 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/justfile b/justfile index 2d3f8b3f..7678b722 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 diff --git a/lexicons/aws/docs/astro.config.mjs b/lexicons/aws/docs/astro.config.mjs index ae9be3bc..67766a0e 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 f2c5f478..f1435630 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 00000000..b92d422a --- /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 5591c83a..8dcc718e 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 a7e11351..43c30c8c 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 00000000..b92d422a --- /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 942c6566..569c283e 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 fd38f6e9..0ed3e1cd 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 00000000..b92d422a --- /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 8910d057..30e1765f 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 bf56cd13..e321b4d0 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 00000000..b92d422a --- /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 33c4018d..20d97271 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 00000000..b92d422a --- /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 d1a01c98..f5daea5b 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 00000000..b92d422a --- /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 ac4e725a..26de30c4 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 00000000..b92d422a --- /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 9ee6c58c..edcbf913 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 9ee4435e..ba68cc24 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 00000000..b92d422a --- /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 1b1f62fc..05a3504c 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 690ff316..efc9b7ec 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 f1ae1d9d..627bbd2d 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 00000000..b92d422a --- /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 79ac7339..c2b46ed8 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 4256e386..e14309ed 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 00000000..36246a52 --- /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 00000000..b92d422a --- /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 00000000..f3861eff --- /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"); + }); +});