diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 09ca52fe..d56813da 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -1,6 +1,6 @@ import { Component, h, State, Prop, Watch } from '@stencil/core'; import { KompendiumData, KompendiumDocument } from '../../types'; -import { setTypes } from '../markdown/markdown-types'; +import { setComponents, setTypes } from '../markdown/markdown-types'; import Fuse from 'fuse.js'; import { PropsFactory } from '../playground/playground.types'; @@ -83,6 +83,9 @@ export class App { this.data = await data.json(); const typeNames = this.data.types.map((type) => type.name); setTypes(typeNames); + const componentTags = + this.data.docs.components?.map((component) => component.tag) ?? []; + setComponents(componentTags); } protected render(): HTMLElement { diff --git a/src/components/markdown/examples/inline-links-example.ts b/src/components/markdown/examples/inline-links-example.ts new file mode 100644 index 00000000..605756e8 --- /dev/null +++ b/src/components/markdown/examples/inline-links-example.ts @@ -0,0 +1,27 @@ +export const inlineLinksExample = ` +JSDoc/TSDoc \`{@link Target}\` references inside component descriptions are +turned into clickable links — the same syntax IDEs use for cmd-click +navigation. The plugin recognises: + +- A known component tag: {@link kompendium-markdown} +- A known type: {@link MenuItem} +- A pipe-delimited display label: {@link MenuItem | the menu item interface} +- A space-delimited display label: {@link MenuItem the menu item interface} +- An absolute URL: {@link https://example.com | external resource} + +Identifiers the resolver does not recognise still get rendered as inline +code, so they remain visually distinct from prose even when there is +nothing to navigate to: + +- Unknown identifier: {@link DoesNotExist} +- Unknown identifier with a free-form label: {@link DoesNotExist a missing type} + +References inside inline code such as \`{@link MenuItem}\` are intentionally +left untouched, so the literal syntax can be shown in documentation about +the feature itself. + +\`\`\`ts +// References inside fenced code blocks are left alone too: +// {@link MenuItem} stays exactly like this. +\`\`\` +`; diff --git a/src/components/markdown/examples/inline-links.tsx b/src/components/markdown/examples/inline-links.tsx new file mode 100644 index 00000000..1929cc34 --- /dev/null +++ b/src/components/markdown/examples/inline-links.tsx @@ -0,0 +1,19 @@ +import { Component, h } from '@stencil/core'; +import { inlineLinksExample } from './inline-links-example'; + +/** + * Inline `@link` references + * + * Demonstrates how inline `{@link Target}` references in markdown are + * turned into clickable links. + * @sourceFile inline-links-example.ts + */ +@Component({ + tag: 'kompendium-example-inline-links', + shadow: true, +}) +export class InlineLinksExample { + public render(): HTMLElement { + return ; + } +} diff --git a/src/components/markdown/markdown-types.ts b/src/components/markdown/markdown-types.ts index 4c50275a..a120633b 100644 --- a/src/components/markdown/markdown-types.ts +++ b/src/components/markdown/markdown-types.ts @@ -1,4 +1,5 @@ let types: string[] = []; +let components: string[] = []; export function getTypes(): string[] { return types; @@ -7,3 +8,11 @@ export function getTypes(): string[] { export function setTypes(newTypes: string[]): void { types = newTypes; } + +export function getComponents(): string[] { + return components; +} + +export function setComponents(newComponents: string[]): void { + components = newComponents; +} diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index a459baba..1533ca4e 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -1,11 +1,12 @@ import { Component, h, Prop, Element } from '@stencil/core'; import { markdownToHtml } from '../../kompendium/markdown'; -import { getTypes } from './markdown-types'; +import { getComponents, getTypes } from './markdown-types'; import { scrollToAnchor } from '../anchor-scroll'; /** * This component renders markdown * @exampleComponent kompendium-example-markdown + * @exampleComponent kompendium-example-inline-links */ @Component({ tag: 'kompendium-markdown', @@ -52,7 +53,8 @@ export class Markdown { const renderSeq = ++this.renderSeq; const currentText = this.text; const types = getTypes(); - const file = await markdownToHtml(currentText, types); + const components = getComponents(); + const file = await markdownToHtml(currentText, types, components); // Abort if a newer render has started or text has changed if (renderSeq !== this.renderSeq || currentText !== this.text) { diff --git a/src/kompendium/markdown-inline-links.ts b/src/kompendium/markdown-inline-links.ts new file mode 100644 index 00000000..16f16260 --- /dev/null +++ b/src/kompendium/markdown-inline-links.ts @@ -0,0 +1,205 @@ +import type { Node, Parent } from 'unist'; +import { visit, SKIP } from 'unist-util-visit'; + +export type LinkResolver = (target: string) => string | null; + +// The target group `([^\s}|]+)` and the trailing tail `([^}\n]{0,1000})` are +// separated by a zero-width lookahead `(?=[\s}|])`. This is the crux of the +// ReDoS resistance: the two greedy classes overlap (`[^\s}|]` is a subset of +// `[^}\n]`), so without the lookahead the engine would try every partition of +// a long unbroken run between them when the mandatory closing `}` is missing — +// O(n²) catastrophic backtracking. The lookahead pins the target's right edge +// to a delimiter (whitespace, `|`, or `}`), so the target can't grow into the +// tail's territory and the match fails promptly on an unterminated `{@link X…`. +// +// The tail class is additionally bounded — it excludes newlines and is capped +// at 1000 chars — as belt-and-suspenders against a second, milder super-linear +// shape: a terminator-free span packed with many `{@link` near-misses. Without +// a bound each `exec` start would scan its tail to end-of-string, making a +// multi-start scan O(n²) even though the per-start lookahead is O(1). Stopping +// the tail at a newline (an inline `{@link}` never spans lines) and capping its +// length keeps every start's scan bounded, so the whole pass stays linear. No +// real reference has a label anywhere near 1000 chars or crosses a line. +// +// The raw tail is split into separator/label by `splitLabel` below, keeping +// both patterns in lockstep through one shared parser. +const URL_LINK_PATTERN = + /\{@link\s+(https?:\/\/[^\s}|]+)(?=[\s}|])([^}\n]{0,1000})\}/g; + +const INLINE_LINK_PATTERN = /\{@link\s+([^\s}|]+)(?=[\s}|])([^}\n]{0,1000})\}/g; + +interface SplitLabel { + // The explicit display label, if one was given; null for a bare reference. + label: string | null; + // True when the label came from a `| label` form (vs. ` label`). Callers + // render space-form bare identifiers as code and free-form labels as prose. + explicit: boolean; +} + +// Parses the raw tail captured after the target (everything up to `}`) into an +// optional display label. Handles `| label`, ` label`, and the bare (empty) +// form. Doing this in code rather than in the regex avoids the backtracking +// that an in-pattern label alternation would reintroduce. +function splitLabel(rest: string): SplitLabel { + const pipeIndex = rest.indexOf('|'); + if (pipeIndex !== -1) { + const pipeLabel = rest.slice(pipeIndex + 1).trim(); + if (pipeLabel.length > 0) { + return { label: pipeLabel, explicit: true }; + } + + return { label: null, explicit: false }; + } + + const trimmed = rest.trim(); + if (trimmed.length > 0) { + return { label: trimmed, explicit: true }; + } + + return { label: null, explicit: false }; +} + +// URL-targeted references are pre-converted to normal markdown links because +// remark-gfm's autolink extension would otherwise tear them apart at parse +// time, before the mdast plugin can see them. +export function normalizeInlineLinkUrls(text: string): string { + return text.replace(URL_LINK_PATTERN, (_match, url, rest) => { + const { label } = splitLabel(rest); + + return `[${(label ?? url).trim()}](${url})`; + }); +} + +interface MdastText extends Node { + type: 'text'; + value: string; +} + +interface MdastInlineCode extends Node { + type: 'inlineCode'; + value: string; +} + +interface MdastLink extends Parent { + type: 'link'; + url: string; + title: null; +} + +type ReplacementNode = MdastText | MdastInlineCode | MdastLink; + +// Turns inline TSDoc/JSDoc link references in prose into mdast link nodes. +// The resolver maps a target identifier to a URL; targets it returns null +// for are rendered as plain text so unresolved references never leave +// dangling links in the output. +export function inlineLinks( + options: { resolve?: LinkResolver } = {}, +): (tree: Node) => void { + const resolve = options.resolve ?? (() => null); + + return (tree: Node) => { + visit( + tree, + 'text', + (node: MdastText, index: number | null, parent: Parent | null) => { + if (!parent || index === null) { + return; + } + + if (parent.type === 'link') { + return; + } + + if (!node.value.includes('{@link')) { + return; + } + + const replacements = transformTextNode(node.value, resolve); + if (!replacements) { + return; + } + + parent.children.splice(index, 1, ...replacements); + + return [SKIP, index + replacements.length]; + }, + ); + }; +} + +function transformTextNode( + value: string, + resolve: LinkResolver, +): ReplacementNode[] | null { + INLINE_LINK_PATTERN.lastIndex = 0; + const result: ReplacementNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + let matched = false; + + while ((match = INLINE_LINK_PATTERN.exec(value)) !== null) { + matched = true; + const [whole, target, rest] = match; + const start = match.index; + + if (start > cursor) { + result.push(textNode(value.slice(cursor, start))); + } + + const { label, explicit } = splitLabel(rest); + const display = (label ?? target).trim(); + const url = resolveTarget(target, resolve); + // A bare identifier reference (no explicit label) is rendered as + // inline code so it visually matches how Kompendium styles type + // names elsewhere. Free-form labels stay plain prose. + let labelNode: MdastText | MdastInlineCode; + if (!explicit) { + labelNode = inlineCodeNode(display); + } else { + labelNode = textNode(display); + } + + if (url) { + result.push(linkNode(url, labelNode)); + } else { + result.push(labelNode); + } + + cursor = start + whole.length; + } + + if (!matched) { + return null; + } + + if (cursor < value.length) { + result.push(textNode(value.slice(cursor))); + } + + return result; +} + +function resolveTarget(target: string, resolve: LinkResolver): string | null { + if (/^https?:\/\//i.test(target) || target.startsWith('#/')) { + return target; + } + + return resolve(target); +} + +function textNode(value: string): MdastText { + return { type: 'text', value: value }; +} + +function inlineCodeNode(value: string): MdastInlineCode { + return { type: 'inlineCode', value: value }; +} + +function linkNode(url: string, child: MdastText | MdastInlineCode): MdastLink { + return { + type: 'link', + url: url, + title: null, + children: [child], + }; +} diff --git a/src/kompendium/markdown-typelinks.ts b/src/kompendium/markdown-typelinks.ts index 19eb4bbc..86e86833 100644 --- a/src/kompendium/markdown-typelinks.ts +++ b/src/kompendium/markdown-typelinks.ts @@ -1,4 +1,4 @@ -import type { Node } from 'unist'; +import type { Node, Parent } from 'unist'; import { isElement, isParent, isTextNode } from './markdown-nodes'; type MapFn = (node: Node, index: number, parent: Node | null) => Node[]; @@ -16,38 +16,61 @@ const transformer = return tree; } - const preCodeElements = collectPreCodeElements(tree); + const skipCodeElements = collectSkippableCodeElements(tree); - return flatMap(tree, mapCodeNode(types, preCodeElements)); + return flatMap(tree, mapCodeNode(types, skipCodeElements)); }; -function collectPreCodeElements(node: Node): Set { +function collectSkippableCodeElements(node: Node): Set { const set = new Set(); - collectPreCode(node, set); + collectSkippableCode(node, set, false); return set; } -function collectPreCode(node: Node, set: Set) { +function collectSkippableCode( + node: Node, + set: Set, + insideAnchor: boolean, +) { if (!isParent(node)) { return; } - if (isElement(node) && node.tagName === 'pre') { - for (const child of node.children) { - if (isElement(child) && child.tagName === 'code') { - set.add(child); - } - } + const tagName = isElement(node) ? node.tagName : ''; + + // Skip `` nested inside an ``: the inline-link pass (`inlineLinks` + // in markdown-inline-links.ts) emits exactly this `link > inlineCode` shape + // for a resolved bare `{@link}` reference. Re-linking that code here would + // wrap an anchor in another anchor, so this pass yields to the earlier one. + // This guard is intentionally broader than that contract: it suppresses + // re-linking for *any* `` inside *any* ``, regardless of origin, + // since a nested anchor is never wanted no matter how the `` got + // there. + if (tagName === 'code' && insideAnchor) { + set.add(node); } + if (tagName === 'pre') { + addPreCodeChildren(node, set); + } + + const nextInsideAnchor = insideAnchor || tagName === 'a'; for (const child of node.children) { - collectPreCode(child, set); + collectSkippableCode(child, set, nextInsideAnchor); + } +} + +function addPreCodeChildren(node: Parent, set: Set) { + for (const child of node.children) { + if (isElement(child) && child.tagName === 'code') { + set.add(child); + } } } const mapCodeNode = - (types: string[] = [], preCodeElements: Set) => + (types: string[] = [], skipCodeElements: Set) => (node: Node, _: number, parent: Node | null) => { if (!isTextNode(node)) { return [node]; @@ -61,7 +84,7 @@ const mapCodeNode = return [node]; } - if (preCodeElements.has(parent)) { + if (skipCodeElements.has(parent)) { return [node]; } diff --git a/src/kompendium/markdown.ts b/src/kompendium/markdown.ts index 0e2c4f27..8cfa41dc 100644 --- a/src/kompendium/markdown.ts +++ b/src/kompendium/markdown.ts @@ -14,6 +14,11 @@ import { } from './markdown-admonitions'; import { kompendiumCode } from './markdown-code'; import { typeLinks } from './markdown-typelinks'; +import { + inlineLinks, + LinkResolver, + normalizeInlineLinkUrls, +} from './markdown-inline-links'; export interface File { data: { @@ -24,8 +29,15 @@ export interface File { toString(): string; } -export async function markdownToHtml(text: string, types = []): Promise { - const normalized = normalizeLegacyAdmonitions(text); +export async function markdownToHtml( + text: string, + types: string[] = [], + components: string[] = [], +): Promise { + const normalized = normalizeInlineLinkUrls( + normalizeLegacyAdmonitions(text), + ); + const resolve = createLinkResolver(types, components); const file = await unified() .use(remarkParse) @@ -34,6 +46,7 @@ export async function markdownToHtml(text: string, types = []): Promise { .use(saveFrontmatter) .use(remarkDirective) .use(admonitions) + .use(inlineLinks, { resolve: resolve }) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(rehypeSlug) @@ -47,3 +60,23 @@ export async function markdownToHtml(text: string, types = []): Promise { toString: () => file.toString(), }; } + +function createLinkResolver( + types: string[], + components: string[], +): LinkResolver { + const typeSet = new Set(types); + const componentSet = new Set(components); + + return (target: string) => { + if (typeSet.has(target)) { + return `#/type/${target}`; + } + + if (componentSet.has(target)) { + return `#/component/${target}/`; + } + + return null; + }; +} diff --git a/src/kompendium/test/markdown-example-integration.spec.ts b/src/kompendium/test/markdown-example-integration.spec.ts new file mode 100644 index 00000000..a5f7cdce --- /dev/null +++ b/src/kompendium/test/markdown-example-integration.spec.ts @@ -0,0 +1,80 @@ +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { markdownToHtml } from '../markdown'; +import { inlineLinksExample } from '../../components/markdown/examples/inline-links-example'; + +const KOMPENDIUM_JSON = join( + __dirname, + '..', + '..', + '..', + 'www', + 'kompendium.json', +); + +const isBuilt = existsSync(KOMPENDIUM_JSON); +const describeIfBuilt = isBuilt ? describe : describe.skip; + +// Make the skip visible in CI output: a bare `describe.skip` is invisible, so +// a missing `www/kompendium.json` would silently drop this real-registry +// coverage with no signal. This always-run guard surfaces why it was skipped. +if (!isBuilt) { + describe('inline @link integration coverage', () => { + it('is skipped because www/kompendium.json is not built (run the build first to enable)', () => { + console.warn( + 'Skipping inline-links integration test: www/kompendium.json not found. ' + + 'Build the docs (so the real type/component registry exists) to run it.', + ); + expect(isBuilt).toBe(false); + }); + }); +} + +describeIfBuilt('inline @link in the inline-links example', () => { + it('renders every reference correctly under the real Kompendium type registry', async () => { + const data = JSON.parse(readFileSync(KOMPENDIUM_JSON, 'utf8')); + const types: string[] = data.types.map((t: any) => t.name); + const components: string[] = data.docs.components.map( + (c: any) => c.tag, + ); + + const result = await markdownToHtml( + inlineLinksExample, + types, + components, + ); + const html = result.toString(); + + // Bare reference to a known component → linked with code wrapping + expect(html).toContain( + 'kompendium-markdown', + ); + + // Bare reference to a known type → linked with code wrapping + expect(html).toContain( + 'MenuItem', + ); + + // Free-form label (pipe + space variants) → linked but plain prose label + expect(html).toContain( + 'the menu item interface', + ); + + // Absolute URL → linked, plain label + expect(html).toContain( + 'external resource', + ); + + // Unknown identifier → inline code, no link + expect(html).toContain('DoesNotExist'); + expect(html).not.toContain('href="#/type/DoesNotExist"'); + + // Raw `{@link …}` syntax must never leak into the rendered prose. + // It is deliberately preserved inside fenced and inline code (the + // example demonstrates both), so strip those before asserting. + const proseOnly = html + .replace(/
[\s\S]*?<\/pre>/g, '')
+            .replace(/[\s\S]*?<\/code>/g, '');
+        expect(proseOnly).not.toContain('{@link');
+    });
+});
diff --git a/src/kompendium/test/markdown-inline-links.spec.ts b/src/kompendium/test/markdown-inline-links.spec.ts
new file mode 100644
index 00000000..347d6466
--- /dev/null
+++ b/src/kompendium/test/markdown-inline-links.spec.ts
@@ -0,0 +1,295 @@
+import type { Node, Parent } from 'unist';
+import {
+    inlineLinks,
+    LinkResolver,
+    normalizeInlineLinkUrls,
+} from '../markdown-inline-links';
+
+function paragraph(...children: Node[]): Parent {
+    return { type: 'paragraph', children: children };
+}
+
+function text(value: string): Node {
+    return { type: 'text', value: value };
+}
+
+function inlineCode(value: string): Node {
+    return { type: 'inlineCode', value: value };
+}
+
+function tree(...children: Parent[]): Parent {
+    return { type: 'root', children: children };
+}
+
+function run(input: Parent, resolve: LinkResolver = () => null): Parent {
+    inlineLinks({ resolve: resolve })(input);
+
+    return input;
+}
+
+describe('inlineLinks()', () => {
+    describe('when there are no {@link} references', () => {
+        it('leaves the tree alone', () => {
+            const result = run(tree(paragraph(text('Just prose.'))));
+            expect(result.children[0]).toEqual(paragraph(text('Just prose.')));
+        });
+    });
+
+    describe('when the resolver recognises the target', () => {
+        const resolve: LinkResolver = (target) =>
+            target === 'Rule' ? '#/type/Rule' : null;
+
+        it('rewrites a bare {@link Target} and wraps the identifier in code', () => {
+            const result = run(
+                tree(paragraph(text('See {@link Rule} now.'))),
+                resolve,
+            );
+            expect(result.children[0]).toEqual(
+                paragraph(
+                    text('See '),
+                    {
+                        type: 'link',
+                        url: '#/type/Rule',
+                        title: null,
+                        children: [inlineCode('Rule')],
+                    },
+                    text(' now.'),
+                ),
+            );
+        });
+
+        it('uses the space-separated display text', () => {
+            const result = run(
+                tree(paragraph(text('See {@link Rule the rule type}.'))),
+                resolve,
+            );
+            const para = result.children[0] as Parent;
+            const link = para.children[1] as Parent & { url: string };
+            expect(link.type).toEqual('link');
+            expect(link.url).toEqual('#/type/Rule');
+            expect(link.children).toEqual([text('the rule type')]);
+        });
+
+        it('uses the pipe-separated display text', () => {
+            const result = run(
+                tree(paragraph(text('See {@link Rule | the rule type}.'))),
+                resolve,
+            );
+            const para = result.children[0] as Parent;
+            const link = para.children[1] as Parent & { url: string };
+            expect(link.type).toEqual('link');
+            expect(link.url).toEqual('#/type/Rule');
+            expect(link.children).toEqual([text('the rule type')]);
+        });
+
+        it('rewrites multiple references in the same text node', () => {
+            const result = run(
+                tree(
+                    paragraph(text('{@link Rule} and {@link Rule | another}.')),
+                ),
+                resolve,
+            );
+            const para = result.children[0] as Parent;
+            expect(para.children).toHaveLength(4);
+            expect((para.children[0] as any).url).toEqual('#/type/Rule');
+            expect((para.children[0] as any).children).toEqual([
+                inlineCode('Rule'),
+            ]);
+            expect((para.children[1] as any).value).toEqual(' and ');
+            expect((para.children[2] as any).url).toEqual('#/type/Rule');
+            expect((para.children[2] as any).children).toEqual([
+                text('another'),
+            ]);
+        });
+    });
+
+    describe('when the resolver returns null', () => {
+        it('renders the bare identifier as inline code, not the raw syntax', () => {
+            const result = run(tree(paragraph(text('See {@link Unknown}.'))));
+            expect(result.children[0]).toEqual(
+                paragraph(text('See '), inlineCode('Unknown'), text('.')),
+            );
+        });
+
+        it('renders a free-form label as plain prose, no code wrapping', () => {
+            const result = run(
+                tree(paragraph(text('See {@link Unknown the missing one}.'))),
+            );
+            expect(result.children[0]).toEqual(
+                paragraph(text('See '), text('the missing one'), text('.')),
+            );
+        });
+    });
+
+    describe('when the target is an absolute URL', () => {
+        it('links to it directly without consulting the resolver', () => {
+            const resolve = jest.fn();
+            const result = run(
+                tree(
+                    paragraph(
+                        text('See {@link https://example.com | the docs}.'),
+                    ),
+                ),
+                resolve as unknown as LinkResolver,
+            );
+            const para = result.children[0] as Parent;
+            const link = para.children[1] as any;
+            expect(link.type).toEqual('link');
+            expect(link.url).toEqual('https://example.com');
+            expect(link.children).toEqual([text('the docs')]);
+            expect(resolve).not.toHaveBeenCalled();
+        });
+    });
+
+    describe('when given a malformed reference (no closing brace)', () => {
+        // Regression guard for catastrophic regex backtracking (ReDoS): an
+        // unterminated `{@link X` followed by a long whitespace run used to
+        // make the matcher run super-linearly on the render thread (thousands
+        // of spaces froze the page for seconds). It must now fail to match
+        // promptly and leave the text untouched.
+        const unterminated = `{@link X${' '.repeat(50000)}`;
+
+        it('returns promptly without rewriting (mdast plugin)', () => {
+            const resolve: LinkResolver = () => '#/type/X';
+            const start = Date.now();
+            const result = run(tree(paragraph(text(unterminated))), resolve);
+            expect(Date.now() - start).toBeLessThan(100);
+            // No `}`, so nothing matches: the text node is left as-is.
+            expect(result.children[0]).toEqual(paragraph(text(unterminated)));
+        });
+
+        it('returns promptly without rewriting (URL pre-pass)', () => {
+            const start = Date.now();
+            const result = normalizeInlineLinkUrls(
+                `{@link https://example.com${' '.repeat(50000)}`,
+            );
+            expect(Date.now() - start).toBeLessThan(100);
+            expect(result).toEqual(
+                `{@link https://example.com${' '.repeat(50000)}`,
+            );
+        });
+
+        // The whitespace cases above are terminated by the first space (which
+        // ends the target class), so they never exercise a long *unbroken*
+        // target. A single long run with no whitespace/`|`/`}` is the input
+        // that triggers the overlapping-quantifier backtracking the lookahead
+        // in the patterns guards against — these cover that path directly.
+        const longUnbrokenTarget = `{@link ${'a'.repeat(50000)}`;
+
+        it('returns promptly on a long unbroken target (mdast plugin)', () => {
+            const resolve: LinkResolver = () => '#/type/X';
+            const start = Date.now();
+            const result = run(
+                tree(paragraph(text(longUnbrokenTarget))),
+                resolve,
+            );
+            expect(Date.now() - start).toBeLessThan(100);
+            expect(result.children[0]).toEqual(
+                paragraph(text(longUnbrokenTarget)),
+            );
+        });
+
+        it('returns promptly on a long unbroken target (URL pre-pass)', () => {
+            const unterminatedUrl = `{@link http://${'a'.repeat(50000)}`;
+            const start = Date.now();
+            const result = normalizeInlineLinkUrls(unterminatedUrl);
+            expect(Date.now() - start).toBeLessThan(100);
+            expect(result).toEqual(unterminatedUrl);
+        });
+
+        // A distinct super-linear shape from the single long token above: a
+        // terminator-free span packed with many `{@link` near-misses. Each
+        // `exec` start would scan its tail to end-of-string, making a
+        // multi-start scan quadratic. The bounded tail (no newlines, capped
+        // length) keeps every start's scan bounded so the pass stays linear.
+        // Both a single line and a newline-separated run are exercised.
+        //
+        // The input is sized so a quadratic regression takes several seconds
+        // (~5.7s locally for this many tokens) while the linear pass stays in
+        // the tens of milliseconds. The bound is deliberately generous: it must
+        // tolerate a heavily-loaded CI runner (where even the linear pass can
+        // take a few hundred ms) yet still fail loudly on an O(n^2) regression.
+        const NEAR_MISS_COUNT = 40000;
+        const LINEAR_BUDGET_MS = 3000;
+        const manyNearMisses = '{@link a '.repeat(NEAR_MISS_COUNT);
+        const manyNearMissesMultiline = '{@link a \n'.repeat(NEAR_MISS_COUNT);
+
+        it('returns promptly on many single-line near-misses (mdast plugin)', () => {
+            const resolve: LinkResolver = () => '#/type/X';
+            const start = Date.now();
+            const result = run(tree(paragraph(text(manyNearMisses))), resolve);
+            expect(Date.now() - start).toBeLessThan(LINEAR_BUDGET_MS);
+            expect(result.children[0]).toEqual(paragraph(text(manyNearMisses)));
+        });
+
+        it('returns promptly on many multi-line near-misses (mdast plugin)', () => {
+            const resolve: LinkResolver = () => '#/type/X';
+            const start = Date.now();
+            const result = run(
+                tree(paragraph(text(manyNearMissesMultiline))),
+                resolve,
+            );
+            expect(Date.now() - start).toBeLessThan(LINEAR_BUDGET_MS);
+            expect(result.children[0]).toEqual(
+                paragraph(text(manyNearMissesMultiline)),
+            );
+        });
+
+        it('returns promptly on many near-misses (URL pre-pass)', () => {
+            const manyUrlNearMisses = '{@link http://a '.repeat(
+                NEAR_MISS_COUNT,
+            );
+            const start = Date.now();
+            const result = normalizeInlineLinkUrls(manyUrlNearMisses);
+            expect(Date.now() - start).toBeLessThan(LINEAR_BUDGET_MS);
+            expect(result).toEqual(manyUrlNearMisses);
+        });
+    });
+
+    describe('normalizeInlineLinkUrls()', () => {
+        it('rewrites a bare URL reference to a markdown link', () => {
+            expect(
+                normalizeInlineLinkUrls('See {@link https://example.com}.'),
+            ).toEqual('See [https://example.com](https://example.com).');
+        });
+
+        it('uses a pipe-separated label', () => {
+            expect(
+                normalizeInlineLinkUrls(
+                    'See {@link https://example.com | the docs}.',
+                ),
+            ).toEqual('See [the docs](https://example.com).');
+        });
+
+        it('uses a space-separated label', () => {
+            expect(
+                normalizeInlineLinkUrls(
+                    'See {@link https://example.com the docs}.',
+                ),
+            ).toEqual('See [the docs](https://example.com).');
+        });
+
+        it('leaves non-URL references untouched', () => {
+            expect(normalizeInlineLinkUrls('See {@link Rule}.')).toEqual(
+                'See {@link Rule}.',
+            );
+        });
+    });
+
+    describe('when the text node already lives inside a link', () => {
+        it('does not introduce a nested link', () => {
+            const resolve: LinkResolver = () => '#/type/Rule';
+            const link: Parent = {
+                type: 'link',
+                children: [text('A {@link Rule} inside a link')],
+            };
+            const result = run(tree(paragraph(link)), resolve);
+            const para = result.children[0] as Parent;
+            const outerLink = para.children[0] as Parent;
+            expect(outerLink.type).toEqual('link');
+            expect(outerLink.children).toEqual([
+                text('A {@link Rule} inside a link'),
+            ]);
+        });
+    });
+});
diff --git a/src/kompendium/test/markdown.spec.ts b/src/kompendium/test/markdown.spec.ts
index 53151c5b..5cb172cc 100644
--- a/src/kompendium/test/markdown.spec.ts
+++ b/src/kompendium/test/markdown.spec.ts
@@ -471,6 +471,121 @@ describe('type links', () => {
     });
 });
 
+describe('inline @link references', () => {
+    it('links {@link Target} to known types and wraps the label in code', async () => {
+        const md = 'See {@link Rule} for details.';
+        const result = await markdownToHtml(md, ['Rule']);
+        expect(result.toString()).toContain(
+            'Rule',
+        );
+    });
+
+    it('links {@link Target} to known components and wraps the label in code', async () => {
+        const md = 'Use {@link limebb-rule-editor} for editing.';
+        const result = await markdownToHtml(md, [], ['limebb-rule-editor']);
+        expect(result.toString()).toContain(
+            'limebb-rule-editor',
+        );
+    });
+
+    it('supports space-separated display text', async () => {
+        const md = 'See {@link Rule the rule type}.';
+        const result = await markdownToHtml(md, ['Rule']);
+        expect(result.toString()).toContain(
+            'the rule type',
+        );
+    });
+
+    it('supports pipe-separated display text', async () => {
+        const md = 'See {@link Rule | the rule type}.';
+        const result = await markdownToHtml(md, ['Rule']);
+        expect(result.toString()).toContain(
+            'the rule type',
+        );
+    });
+
+    it('falls back to inline code for unresolved bare identifiers', async () => {
+        const md = 'See {@link Unknown} please.';
+        const result = await markdownToHtml(md);
+        const html = result.toString();
+        expect(html).not.toContain('{@link');
+        expect(html).not.toContain('Unknown');
+    });
+
+    it('falls back to plain text when the target is unknown and a label is given', async () => {
+        const md = 'See {@link Unknown the missing one}.';
+        const result = await markdownToHtml(md);
+        const html = result.toString();
+        expect(html).not.toContain('{@link');
+        expect(html).not.toContain('');
+        expect(html).toContain('the missing one');
+    });
+
+    it('does not transform references inside inline code', async () => {
+        const md = 'Use `{@link Rule}` literally.';
+        const result = await markdownToHtml(md, ['Rule']);
+        const html = result.toString();
+        // The `{@link Rule}` syntax stays intact inside the  element —
+        // i.e. the inline-link plugin doesn't fire. The existing typeLinks
+        // pass may still link the bare `Rule` token inside the code element;
+        // that is pre-existing behaviour and not under test here.
+        expect(html).toContain('{@link ');
+        expect(html).toContain('}');
+    });
+
+    it('does not transform references inside fenced code blocks', async () => {
+        const md = '```ts\n// {@link Rule}\n```';
+        const result = await markdownToHtml(md, ['Rule']);
+        const html = result.toString();
+        expect(html).toContain('{@link Rule}');
+        expect(html).not.toContain(' {
+        const md = 'See {@link https://example.com | the docs}.';
+        const result = await markdownToHtml(md);
+        expect(result.toString()).toContain(
+            'the docs',
+        );
+    });
+
+    it('treats an empty pipe label as a bare reference', async () => {
+        // `{@link X |}` has a pipe but no label after it. The label is empty,
+        // so the reference is treated as bare: the identifier is wrapped in
+        // code (and linked when known), never rendered as the literal `|`.
+        const md = 'See {@link Rule |} for details.';
+        const result = await markdownToHtml(md, ['Rule']);
+        const html = result.toString();
+        expect(html).toContain('Rule');
+        expect(html).not.toContain('|');
+    });
+
+    // Known limitation: the URL pre-pass (`normalizeInlineLinkUrls`) runs over
+    // raw text before parsing, so unlike the non-URL mdast path it cannot tell
+    // a code span from prose. A URL `{@link}` inside inline or fenced code is
+    // therefore rewritten to markdown link source rather than passed through
+    // verbatim. Documented here as intended (if imperfect) behaviour; non-URL
+    // references in code are correctly passed through (see the tests above).
+    it('rewrites a URL reference even inside inline code (known gap)', async () => {
+        const md = 'Use `{@link https://example.com}` literally.';
+        const result = await markdownToHtml(md);
+        const html = result.toString();
+        expect(html).toContain('');
+        expect(html).toContain('[https://example.com](https://example.com)');
+        expect(html).not.toContain('{@link');
+    });
+
+    it('rewrites a URL reference even inside fenced code (known gap)', async () => {
+        const md = '```\n{@link https://example.com}\n```';
+        const result = await markdownToHtml(md);
+        const html = result.toString();
+        expect(html).toContain('[https://example.com](https://example.com)');
+        expect(html).not.toContain('{@link');
+    });
+});
+
 describe('raw HTML passthrough', () => {
     it('allows raw HTML in markdown', async () => {
         const md = '
Custom content
';