diff --git a/.ecrc.json b/.ecrc.json
index d12afe614..27b82edee 100644
--- a/.ecrc.json
+++ b/.ecrc.json
@@ -13,7 +13,10 @@
"collections/_docs/contributing.md",
"collections/_docs/security.md",
"collections/_docs/support.md",
- "collections/_pages/vision.md"
+ "collections/_pages/vision.md",
+ "vendor/sdk-api/[^/]+/",
+ "collections/_sdk-api/",
+ "_data/sdkApi.json"
],
"AllowedContentTypes": [],
"PassedFiles": [],
diff --git a/.gitignore b/.gitignore
index f59404f1a..502cc6b08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ node_modules/
# Ignore generated build products.
_site/
/assets/
+/collections/_sdk-api/
+/_data/sdkApi.json
diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc
index 25ac8112a..0358323be 100644
--- a/.markdownlint-cli2.jsonc
+++ b/.markdownlint-cli2.jsonc
@@ -19,7 +19,11 @@
"collections/_docs/contributing.md",
"collections/_docs/security.md",
"collections/_docs/support.md",
- "collections/_pages/vision.md"
+ "collections/_pages/vision.md",
+ // The SDK's API reference: TypeDoc's output, vendored as the release
+ // published it, and the pages the import task derives from that.
+ "vendor/sdk-api/*/**",
+ "collections/_sdk-api/"
],
"markdownItPlugins": [
diff --git a/.prettierignore b/.prettierignore
index 097d10468..4957b3c16 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -16,3 +16,7 @@ collections/_docs/contributing.md
collections/_docs/security.md
collections/_docs/support.md
collections/_pages/vision.md
+
+# Derived from the vendored SDK API artifact by
+# compile.importSdkApiDocs, which rewrites them on every build.
+collections/_sdk-api/
diff --git a/_assets/styles/_sdk-api.scss b/_assets/styles/_sdk-api.scss
new file mode 100644
index 000000000..f0f722849
--- /dev/null
+++ b/_assets/styles/_sdk-api.scss
@@ -0,0 +1,143 @@
+@use 'sass:color';
+
+// Generated API reference pages use the same reading typography as the
+// community documentation. Their one additional element is the navigation
+// beside them: ten package roots, with the symbols of whichever package the
+// page belongs to opened underneath it, so that a function is one click from
+// the ones declared beside it.
+
+$sdk-api-rule: color.mix($tertiary, $body-bg, 35%);
+
+.sdk-api .sdk-api-kicker {
+ margin: 1rem 0 0;
+ font-family: $font-family-monospace;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: rgb(231 231 214 / 72%);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.sdk-api .sdk-api-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 65ch) minmax(11rem, 15rem);
+ gap: clamp(2rem, 5vw, 5rem);
+ justify-content: center;
+}
+
+.sdk-api .doc-prose {
+ width: 100%;
+ margin-inline: 0;
+}
+
+.sdk-api .sdk-api-nav {
+ align-self: start;
+ padding-inline-start: 1.25rem;
+ border-inline-start: 1px solid $sdk-api-rule;
+}
+
+.sdk-api .sdk-api-nav-title {
+ margin: 0;
+ font-family: $font-family-monospace;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.sdk-api .sdk-api-nav-list {
+ padding: 0;
+ margin: 0.875rem 0 0;
+ list-style: none;
+}
+
+.sdk-api .sdk-api-nav-package + .sdk-api-nav-package {
+ margin-top: 0.5rem;
+}
+
+// The package a page belongs to is the one carrying a list, so it reads as a
+// heading over that list rather than as another item in the same column.
+.sdk-api .sdk-api-nav-package.is-open {
+ margin-top: 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+.sdk-api .sdk-api-nav-package.is-open > .sdk-api-nav-package-name {
+ font-weight: 700;
+}
+
+.sdk-api .sdk-api-nav-list a {
+ color: $doc-link;
+ text-decoration: none;
+}
+
+.sdk-api .sdk-api-nav-list a:hover,
+.sdk-api .sdk-api-nav-list a[aria-current='page'] {
+ text-decoration: underline;
+}
+
+.sdk-api .sdk-api-nav-list a[aria-current='page'] {
+ font-weight: 700;
+}
+
+// How many symbols a closed package holds, which its name does not say.
+.sdk-api .sdk-api-nav-count {
+ margin-inline-start: 0.5ch;
+ font-family: $font-family-monospace;
+ font-size: 0.75rem;
+ color: $doc-muted;
+}
+
+// What the open package's symbols are grouped by -- Functions, Classes, and
+// so on -- set quietly, since the names beneath are what is being scanned.
+.sdk-api .sdk-api-nav-group {
+ margin: 0.75rem 0 0;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ color: $doc-muted;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+// Indented past the package names, against a rule, so that the column reads
+// as one package opened rather than as packages interrupted by a list.
+.sdk-api .sdk-api-nav-group,
+.sdk-api .sdk-api-nav-symbols {
+ padding-inline-start: 0.75rem;
+ border-inline-start: 1px solid $doc-hairline;
+}
+
+.sdk-api .sdk-api-nav-symbols {
+ margin: 0.25rem 0 0;
+ font-family: $font-family-monospace;
+ font-size: 0.8125rem;
+ list-style: none;
+}
+
+.sdk-api .sdk-api-nav-symbols li + li {
+ margin-top: 0.25rem;
+}
+
+// A package with eighty-odd symbols makes a column far taller than the prose
+// beside it, and navigation that scrolls away is navigation you have to go
+// back for. Only where there are two columns to begin with: stacked, it is
+// the end of the page and scrolls with it.
+@media (width >= 960px) {
+ .sdk-api .sdk-api-nav {
+ position: sticky;
+ top: calc(#{$navbar-height} + 1.5rem);
+ max-height: calc(100vh - #{$navbar-height} - 3rem);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ }
+}
+
+@media (width < 960px) {
+ .sdk-api .sdk-api-grid {
+ display: block;
+ }
+
+ .sdk-api .sdk-api-nav {
+ margin-top: 3rem;
+ }
+}
diff --git a/_assets/styles/main.scss b/_assets/styles/main.scss
index 8876f0899..a11687425 100644
--- a/_assets/styles/main.scss
+++ b/_assets/styles/main.scss
@@ -27,6 +27,7 @@
@import 'nav';
@import 'news';
@import 'news-item';
+@import 'sdk-api';
// Optional Bootstrap components (core styles).
@import '../../node_modules/bootstrap/scss/utilities';
diff --git a/_layouts/sdk-api.liquid b/_layouts/sdk-api.liquid
new file mode 100644
index 000000000..30432028d
--- /dev/null
+++ b/_layouts/sdk-api.liquid
@@ -0,0 +1,111 @@
+---
+layout: default
+body_classes:
+ - docs
+ - doc-page
+ - sdk-api
+editable: false
+---
+{%- assign release = sdkApi.versions | where: 'version', api_version | first -%}
+
+
+
+
+
+
API reference · {{ api_version }}
+
{{ title | escape }}
+
+
+
+
+
+
{{ content | sanitizeSdkHtml }}
+
+ {%- comment -%}
+ Every package, with the one this page belongs to opened beneath it to
+ show the symbols declared alongside it. TypeDoc's own theme keeps
+ that tree in a sidebar and expands it with script; a page here knows
+ its own package from its front matter, so the same reach costs none.
+ A closed package says how many symbols it holds, which its name does
+ not.
+ {%- endcomment -%}
+ {%- if release -%}
+
+ {%- endif -%}
+
+
+
+
diff --git a/build/shared/sdk-docs.mts b/build/shared/sdk-docs.mts
new file mode 100644
index 000000000..254b3a04c
--- /dev/null
+++ b/build/shared/sdk-docs.mts
@@ -0,0 +1,399 @@
+/**
+ * @file Turns TypeDoc Markdown paths into stable OpenINF portal URLs.
+ * @author The OpenINF Authors & Friends
+ * @license MIT OR Apache-2.0 OR BlueOak-1.0.0
+ * @module {type ES6Module} build/shared/sdk-docs
+ */
+
+import { realpath } from 'node:fs/promises';
+import { isAbsolute, relative, resolve } from 'node:path';
+import {
+ dirname as pathDirname,
+ extname as pathExtname,
+ normalize as pathNormalize,
+ relative as pathRelative,
+} from 'node:path/posix';
+import sanitizeHtml from 'sanitize-html';
+
+const normalizePath = (input: string) =>
+ pathNormalize(input).replace(/^\.\//, '');
+
+/**
+ * Maps one TypeDoc Markdown filename to its portal-relative source filename.
+ * `README.md` is a directory index in TypeDoc's tree; the portal gives it an
+ * actual `index.md` source, so Eleventy can make the corresponding clean URL.
+ * @param {string} sourcePath A slash-separated path beneath the Markdown root.
+ * @returns The matching generated source path beneath `collections/_sdk-api`.
+ */
+export function portalPath(sourcePath: string) {
+ const normalized = normalizePath(sourcePath);
+
+ if (normalized === 'README.md') return 'index.md';
+ if (normalized === 'packages.md') return 'packages/index.md';
+
+ const packageMatch = normalized.match(/^@openinf\/([^/]+)\/(.*)$/);
+ if (packageMatch === null) {
+ throw new Error(`Unexpected TypeDoc path: ${sourcePath}`);
+ }
+
+ const [, packageName, remainder] = packageMatch;
+ if (remainder === 'README.md') return `packages/${packageName}/index.md`;
+
+ return `packages/${packageName}/${remainder}`;
+}
+
+/**
+ * The package a generated page belongs to, named as the navigation names it,
+ * or an empty string for the two pages that belong to no package. The layout
+ * reads this to find that package in the navigation tree, so that a symbol
+ * page can list the symbols beside it rather than only the package roots.
+ * @param {string} sourcePath A slash-separated path beneath the Markdown root.
+ * @returns The package's navigation title, or an empty string.
+ */
+export function packageOf(sourcePath: string) {
+ return normalizePath(sourcePath).match(/^(@openinf\/[^/]+)\//)?.[1] ?? '';
+}
+
+/**
+ * Returns the public URL for one TypeDoc Markdown source path.
+ * @param {string} version The SDK release version that generated the page.
+ * @param {string} sourcePath A slash-separated path beneath the Markdown root.
+ * @returns The portal URL at which Eleventy publishes the page.
+ */
+export function portalUrl(version: string, sourcePath: string) {
+ const file = portalPath(sourcePath).replace(/\.md$/, '');
+ const path = file.endsWith('/index') ? file.slice(0, -'/index'.length) : file;
+ const suffix = path === 'index' ? '' : `${path}/`;
+
+ return `/docs/sdk/${version}/api/${suffix}`;
+}
+
+/**
+ * Reads TypeDoc's first heading after removing the product link and generated
+ * breadcrumb it puts above every page. The portal supplies those itself.
+ * @param {string} markdown A generated TypeDoc Markdown page.
+ * @returns The page title and the Markdown that belongs in the portal body.
+ */
+export function stripTypeDocChrome(markdown: string) {
+ let content = markdown.replace(/\r\n/g, '\n');
+
+ // The root has bold text; child pages link that same text home. Both are
+ // followed by a thematic break.
+ content = content.replace(
+ /^(?:\[\*{1,2}OpenINF\*{1,2}\]\([^\n)]*\)|\*{1,2}OpenINF\*{1,2})\n\n\*\*\*\n\n/,
+ ''
+ );
+ // A child page's generated breadcrumb would duplicate the portal's trail.
+ content = content.replace(/^\[OpenINF\]\([^\n)]*\)(?:\s*\/[^\n]*)?\n\n/, '');
+
+ const heading = content.match(/^# ([^\n]+)\n+/);
+ const htmlHeading = content.match(/^
]*)?>([^<]+)<\/h1>\n+/);
+
+ if (heading !== null) {
+ return {
+ title: heading[1],
+ content: content.slice(heading[0].length),
+ };
+ }
+
+ if (htmlHeading !== null) {
+ return {
+ title: htmlHeading[1],
+ content: content.slice(htmlHeading[0].length),
+ };
+ }
+
+ throw new Error('Generated TypeDoc page has no h1');
+}
+
+/**
+ * Rewrites relative TypeDoc Markdown links to their portal URLs. TypeDoc's
+ * links name `.md` files, whereas the portal publishes clean directory URLs.
+ * @param {string} markdown Generated TypeDoc Markdown.
+ * @param {string} sourcePath The current page's source path beneath the Markdown root.
+ * @param {Map} urls Public URLs keyed by TypeDoc source path.
+ * @returns Markdown whose internal document links point at the portal.
+ */
+export function rewriteTypeDocLinks(
+ markdown: string,
+ sourcePath: string,
+ urls: Map
+) {
+ // A destination cannot consume another link opener. Otherwise an
+ // unterminated run of `](` makes the matcher retry the entire suffix at
+ // every opener, producing quadratic work on malformed artifact text.
+ const rewrite = (prose: string) =>
+ prose.replace(
+ /(\]\()(?[^()[\]\s<>]+>?)(\))/g,
+ (whole, before: string, target: string, after: string) => {
+ const wrapped = target.startsWith('<') && target.endsWith('>');
+ const destination = wrapped ? target.slice(1, -1) : target;
+ const hash = destination.indexOf('#');
+ const file = hash === -1 ? destination : destination.slice(0, hash);
+ const fragment = hash === -1 ? '' : destination.slice(hash);
+
+ if (
+ !file.endsWith('.md') ||
+ /^[a-z][a-z\d+.-]*:/i.test(file) ||
+ file.startsWith('/')
+ )
+ return whole;
+
+ const resolved = normalizePath(
+ pathNormalize(`${pathDirname(sourcePath)}/${file}`)
+ );
+ const url = urls.get(resolved);
+ if (url === undefined) {
+ throw new Error(
+ `${sourcePath} links to generated Markdown that is not imported: ${file}`
+ );
+ }
+
+ return `${before}${url}${fragment}${after}`;
+ }
+ );
+
+ // Generated signatures and examples are fenced; inline examples use one
+ // backtick. A link-shaped string in either is code, not navigation.
+ return markdown
+ .split(/(```[\s\S]*?```|`[^`\n]*`)/)
+ .map((part, index) => (index % 2 === 0 ? rewrite(part) : part))
+ .join('');
+}
+
+/**
+ * Sanitizes the rendered SDK body before the portal layout embeds it.
+ * Running after Markdown rendering also covers Markdown-generated URLs;
+ * code examples have already been escaped and remain readable text.
+ * @param {string} html Rendered API documentation, without the portal shell.
+ * @returns HTML restricted to documentation elements and safe attributes.
+ */
+export function sanitizeSdkHtml(html: string) {
+ return sanitizeHtml(html, {
+ allowedTags: [
+ 'a',
+ 'abbr',
+ 'b',
+ 'blockquote',
+ 'br',
+ 'code',
+ 'dd',
+ 'del',
+ 'details',
+ 'div',
+ 'dl',
+ 'dt',
+ 'em',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'hr',
+ 'i',
+ 'img',
+ 'li',
+ 'ol',
+ 'p',
+ 'pre',
+ 's',
+ 'span',
+ 'strong',
+ 'sub',
+ 'summary',
+ 'sup',
+ 'table',
+ 'tbody',
+ 'td',
+ 'th',
+ 'thead',
+ 'tr',
+ 'ul',
+ ],
+ allowedAttributes: {
+ '*': ['id', 'class', 'title', 'aria-label', 'aria-describedby'],
+ a: ['href', 'name', 'rel'],
+ img: ['src', 'alt', 'width', 'height'],
+ ol: ['start'],
+ th: ['colspan', 'rowspan', 'scope', 'align'],
+ td: ['colspan', 'rowspan', 'align'],
+ details: ['open'],
+ },
+ allowedSchemes: ['https', 'http', 'mailto'],
+ allowedSchemesByTag: { img: ['https', 'http'] },
+ allowProtocolRelative: false,
+ });
+}
+
+/**
+ * Resolves an artifact member and checks physical as well as lexical bounds.
+ * @param {string} root Directory containing the trusted artifact boundary.
+ * @param {string} value Relative member path, possibly containing symlinks.
+ * @returns The contained real path to use for subsequent reads.
+ */
+export async function artifactFile(root: string, value: string) {
+ if (isAbsolute(value)) throw new Error(`${value} must be relative`);
+ const directory = await realpath(root);
+ const candidate = resolve(directory, value);
+ const check = (file: string) => {
+ const fromRoot = relative(directory, file);
+ if (
+ fromRoot === '..' ||
+ fromRoot.startsWith('../') ||
+ fromRoot.startsWith('..\\') ||
+ isAbsolute(fromRoot)
+ ) {
+ throw new Error(`${value} escapes ${directory}`);
+ }
+ };
+ check(candidate);
+ const file = await realpath(candidate);
+ check(file);
+ return file;
+}
+
+/**
+ * Keeps generated subsection headings valid below the portal-provided h1.
+ * A few hand-authored comments begin at h3 before TypeDoc emits an h2.
+ * @param {string} markdown Generated Markdown body.
+ * @returns Markdown with leading h3 sections promoted to h2.
+ */
+export function normalizeHeadingHierarchy(markdown: string) {
+ let hasH2 = false;
+
+ const promote = (prose: string) =>
+ prose.replace(/^#{2,3} .+$/gm, (heading) => {
+ if (heading.startsWith('## ')) {
+ hasH2 = true;
+ return heading;
+ }
+
+ return hasH2 ? heading : `## ${heading.slice(4)}`;
+ });
+
+ // A run of hashes inside a fence is part of an example -- a shell comment,
+ // a Markdown sample -- and promoting it would rewrite what the example
+ // says. Split the same way the link rewriter does, and read only the prose.
+ return markdown
+ .split(/(```[\s\S]*?```|`[^`\n]*`)/)
+ .map((part, index) => (index % 2 === 0 ? promote(part) : part))
+ .join('');
+}
+
+/**
+ * Rejects an artifact path which would leave its declared documentation root.
+ * @param {string} root The artifact's documentation root.
+ * @param {string} candidate A path that claims to sit under it.
+ * @returns A normalized path beneath `root`.
+ */
+export function pathInside(root: string, candidate: string) {
+ const relative = pathRelative(root, candidate);
+ if (relative === '' || (!relative.startsWith('../') && relative !== '..')) {
+ return normalizePath(candidate);
+ }
+
+ throw new Error(`${candidate} is outside ${root}`);
+}
+
+/**
+ * Compares two runs of digits as numbers, without making them into any. What
+ * the artifact schema accepts is `\\d+`, which is not bounded, and past 2^53
+ * two distinct versions round to one Number and compare as equal. Leading
+ * zeros go first, since they lengthen a string without changing its value.
+ * @param {string} a One run of digits.
+ * @param {string} b Another run of digits.
+ * @returns A negative number when `a` is the smaller.
+ */
+function compareDigits(a: string, b: string) {
+ const left = a.replace(/^0+(?=\d)/, '');
+ const right = b.replace(/^0+(?=\d)/, '');
+
+ if (left.length !== right.length) return left.length - right.length;
+
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
+/**
+ * Splits a release version into the parts that decide its precedence.
+ * Build metadata takes no part in it, so it is dropped before anything else
+ * is read: left in, `3.1.1+build.1` would have `1+build` for its patch, and
+ * comparing that to a number answers neither larger nor smaller.
+ * @param {string} version A release version the artifact schema accepts.
+ * @returns Its numeric release parts and its prerelease identifiers.
+ */
+function releaseParts(version: string) {
+ const precedence = version.split('+', 1)[0] ?? version;
+ const at = precedence.indexOf('-');
+
+ return {
+ release: (at === -1 ? precedence : precedence.slice(0, at)).split('.'),
+ prerelease: at === -1 ? [] : precedence.slice(at + 1).split('.'),
+ };
+}
+
+/**
+ * Orders release versions newest first, by precedence rather than by name.
+ * The portal lists releases in the order it imports them, and importing them
+ * in the order their directory names sort would put 10.0.0 before 3.0.0.
+ * @param {string} a One release version.
+ * @param {string} b Another release version.
+ * @returns A negative number when `a` is the newer release.
+ */
+export function compareVersions(a: string, b: string) {
+ const left = releaseParts(a);
+ const right = releaseParts(b);
+
+ for (const [index, part] of left.release.entries()) {
+ const difference = compareDigits(right.release[index] ?? '0', part);
+ if (difference !== 0) return difference;
+ }
+
+ // A release outranks every prerelease of the same version.
+ if (left.prerelease.length === 0) {
+ return right.prerelease.length === 0 ? 0 : -1;
+ }
+ if (right.prerelease.length === 0) return 1;
+
+ const numeric = /^\d+$/;
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
+
+ for (let index = 0; index < length; index += 1) {
+ const ours = left.prerelease[index];
+ const theirs = right.prerelease[index];
+
+ // A prerelease that runs out of identifiers first is the older one.
+ if (ours === undefined) return 1;
+ if (theirs === undefined) return -1;
+ if (ours === theirs) continue;
+
+ if (numeric.test(ours) && numeric.test(theirs)) {
+ return compareDigits(theirs, ours);
+ }
+
+ // Numeric identifiers rank below alphanumeric ones.
+ if (numeric.test(ours)) return 1;
+ if (numeric.test(theirs)) return -1;
+
+ return ours < theirs ? 1 : -1;
+ }
+
+ return 0;
+}
+
+/**
+ * The title the portal gives one generated page. TypeDoc heads its package
+ * index with the project name, which on the portal reads as the name of the
+ * site rather than as what the page is; every other page names itself.
+ * @param {string} sourcePath A slash-separated path beneath the Markdown root.
+ * @param {string} heading The page's own first heading.
+ * @returns The title to publish the page under.
+ */
+export function portalTitle(sourcePath: string, heading: string) {
+ return normalizePath(sourcePath) === 'packages.md' ? 'Packages' : heading;
+}
+
+/** Whether a file is one TypeDoc Markdown page the portal should import. */
+export function isApiMarkdown(sourcePath: string) {
+ return pathExtname(sourcePath) === '.md' && !sourcePath.startsWith('_media/');
+}
diff --git a/build/shared/sdk-docs.test.mts b/build/shared/sdk-docs.test.mts
new file mode 100644
index 000000000..2e2904b0c
--- /dev/null
+++ b/build/shared/sdk-docs.test.mts
@@ -0,0 +1,336 @@
+/**
+ * @file Tests for TypeDoc Markdown portal helpers.
+ * @author The OpenINF Authors & Friends
+ * @license MIT OR Apache-2.0 OR BlueOak-1.0.0
+ * @module {type ES6Module} build/shared/sdk-docs.test
+ */
+
+import {
+ deepStrictEqual,
+ doesNotMatch,
+ match,
+ throws,
+} from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { describe, test } from 'node:test';
+import {
+ compareVersions,
+ isApiMarkdown,
+ normalizeHeadingHierarchy,
+ packageOf,
+ pathInside,
+ portalPath,
+ portalTitle,
+ portalUrl,
+ rewriteTypeDocLinks,
+ sanitizeSdkHtml,
+ stripTypeDocChrome,
+} from '@openinf/portal/build/sdk-docs';
+
+describe('portalPath', () => {
+ test('turns TypeDoc indexes into portal indexes', () => {
+ deepStrictEqual(portalPath('README.md'), 'index.md');
+ deepStrictEqual(portalPath('packages.md'), 'packages/index.md');
+ deepStrictEqual(
+ portalPath('@openinf/util/README.md'),
+ 'packages/util/index.md'
+ );
+ });
+
+ test('keeps a symbol below its package', () => {
+ deepStrictEqual(
+ portalPath('@openinf/util/functions/isString.md'),
+ 'packages/util/functions/isString.md'
+ );
+ });
+});
+
+describe('portalUrl', () => {
+ test('gives indexes clean trailing-slash URLs', () => {
+ deepStrictEqual(portalUrl('3.0.0', 'README.md'), '/docs/sdk/3.0.0/api/');
+ deepStrictEqual(
+ portalUrl('3.0.0', '@openinf/util/README.md'),
+ '/docs/sdk/3.0.0/api/packages/util/'
+ );
+ });
+
+ test('gives a symbol its own URL', () => {
+ deepStrictEqual(
+ portalUrl('3.0.0', '@openinf/util/functions/isString.md'),
+ '/docs/sdk/3.0.0/api/packages/util/functions/isString/'
+ );
+ });
+});
+
+describe('stripTypeDocChrome', () => {
+ test('keeps only a child page’s consumer content', () => {
+ deepStrictEqual(
+ stripTypeDocChrome(
+ '[**OpenINF**](../../README.md)\n\n***\n\n[OpenINF](../../packages.md) / @openinf/util\n\n# @openinf/util\n\nUseful things.\n'
+ ),
+ { title: '@openinf/util', content: 'Useful things.\n' }
+ );
+ });
+
+ test('also handles the root page', () => {
+ deepStrictEqual(
+ stripTypeDocChrome('**OpenINF**\n\n***\n\n# OpenINF\n\nWelcome.\n'),
+ { title: 'OpenINF', content: 'Welcome.\n' }
+ );
+ });
+
+ test('handles a package readme with an HTML h1', () => {
+ deepStrictEqual(
+ stripTypeDocChrome(
+ '[**OpenINF**](../../README.md)\n\n***\n\n[OpenINF](../../packages.md) / @openinf/util-errors\n\n
@openinf/util-errors
\n\nIntro.\n'
+ ),
+ { title: '@openinf/util-errors', content: 'Intro.\n' }
+ );
+ });
+});
+
+describe('rewriteTypeDocLinks', () => {
+ test('maps a relative Markdown link and keeps its fragment', () => {
+ const urls = new Map([
+ ['@openinf/util/README.md', '/docs/sdk/3.0.0/api/packages/util/'],
+ [
+ '@openinf/util/functions/isString.md',
+ '/docs/sdk/3.0.0/api/packages/util/functions/isString/',
+ ],
+ ]);
+
+ deepStrictEqual(
+ rewriteTypeDocLinks(
+ 'See [isString](functions/isString.md#examples).',
+ '@openinf/util/README.md',
+ urls
+ ),
+ 'See [isString](/docs/sdk/3.0.0/api/packages/util/functions/isString/#examples).'
+ );
+ });
+
+ test('leaves external links and code alone', () => {
+ const urls = new Map();
+ deepStrictEqual(
+ rewriteTypeDocLinks(
+ 'See [TypeDoc](https://typedoc.org) and `](README.md)`.',
+ 'README.md',
+ urls
+ ),
+ 'See [TypeDoc](https://typedoc.org) and `](README.md)`.'
+ );
+ });
+
+ test('refuses a link to an omitted generated page', () => {
+ throws(
+ () => rewriteTypeDocLinks('[no](missing.md)', 'README.md', new Map()),
+ /not imported/
+ );
+ });
+
+ test('leaves absolute Markdown URLs untouched', () => {
+ const input = '[source](https://example.com/README.md#intro)';
+ deepStrictEqual(rewriteTypeDocLinks(input, 'README.md', new Map()), input);
+ });
+
+ test('finishes the CodeQL repeated-link input within a bounded process', () => {
+ // A child process timeout can interrupt a synchronous regexp. A test
+ // timeout in the same process cannot. The old expression takes minutes.
+ execFileSync(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ `
+ import { rewriteTypeDocLinks } from ${JSON.stringify(new URL('./sdk-docs.mts', import.meta.url).href)};
+ const input = '](' + '](!'.repeat(300_000);
+ if (rewriteTypeDocLinks(input, 'README.md', new Map()) !== input) {
+ throw new Error('Malformed link text changed');
+ }
+ `,
+ ],
+ { timeout: 10_000 }
+ );
+ });
+});
+
+describe('artifact safety', () => {
+ test('keeps an artifact path inside its root', () => {
+ deepStrictEqual(
+ pathInside(
+ 'vendor/sdk-api/3.0.0/docs',
+ 'vendor/sdk-api/3.0.0/docs/README.md'
+ ),
+ 'vendor/sdk-api/3.0.0/docs/README.md'
+ );
+ throws(() =>
+ pathInside(
+ 'vendor/sdk-api/3.0.0/docs',
+ 'vendor/sdk-api/3.0.0/manifest.json'
+ )
+ );
+ });
+
+ test('does not make generated maintainer documents API pages', () => {
+ deepStrictEqual(isApiMarkdown('@openinf/util/README.md'), true);
+ deepStrictEqual(isApiMarkdown('_media/RELEASING.md'), false);
+ });
+
+ test('leaves a run of hashes inside an example alone', () => {
+ // A shell comment in a fence is what the example says, not a section of
+ // the page, and promoting it would rewrite the example.
+ const markdown = [
+ 'Prose before any heading.',
+ '',
+ '```sh',
+ '### output',
+ '```',
+ '',
+ '## Parameters',
+ ].join('\n');
+ match(normalizeHeadingHierarchy(markdown), /### output/);
+ });
+
+ test('promotes an orphaned h3 below the portal page title', () => {
+ deepStrictEqual(
+ normalizeHeadingHierarchy(
+ '### Explanation\n\nBody.\n\n## Parameters\n\n### Value'
+ ),
+ '## Explanation\n\nBody.\n\n## Parameters\n\n### Value'
+ );
+ });
+});
+
+describe('rendered SDK HTML', () => {
+ test('removes executable markup and unsafe URL schemes', () => {
+ const result = sanitizeSdkHtml(
+ [
+ '',
+ '',
+ '',
+ '