From d75fe82a5af4f5e3fdf6ecc3618322cb454dc44d Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 18 May 2026 11:04:37 +0200 Subject: [PATCH 1/3] fix(markdown): skip type-linking inside anchor ancestors When an inline `` element already sits inside an `` (for example, from a markdown link like `[label `Foo`](url)`), the typeLinks pass would wrap the type token in another anchor and produce a nested ``, which is invalid HTML and confuses navigation. Generalises the existing `
` skip-set into a more general
"skippable code" set that also captures `` inside any anchor
ancestor, and leaves those code elements untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/kompendium/markdown-typelinks.ts | 45 ++++++++++++++++++----------
 1 file changed, 30 insertions(+), 15 deletions(-)

diff --git a/src/kompendium/markdown-typelinks.ts b/src/kompendium/markdown-typelinks.ts
index 19eb4bb..7e05e21 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,53 @@ 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 : '';
+
+    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 +76,7 @@ const mapCodeNode =
             return [node];
         }
 
-        if (preCodeElements.has(parent)) {
+        if (skipCodeElements.has(parent)) {
             return [node];
         }
 

From 02595b18ff9fea8ef75700b141b6642bb710f803 Mon Sep 17 00:00:00 2001
From: Kiarokh Moattar 
Date: Mon, 18 May 2026 11:05:10 +0200
Subject: [PATCH 2/3] feat(markdown): render inline {@link Target} references
 as links
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

JSDoc/TSDoc `{@link Target}` references in component descriptions
previously surfaced in the generated docs as literal text. Adds a remark
plugin that rewrites them into mdast link nodes during the markdown
pipeline, supporting the standard syntactic forms:

  {@link Target}
  {@link Target Display text}
  {@link Target | Display text}
  {@link https://example.com | Display text}

Targets are resolved against the project's known types and component
tags (a new component registry mirrors the existing type registry).
Bare-identifier references — those without an explicit display label —
are wrapped in `` so they visually match how type names render
elsewhere in the docs. Unresolved targets still render as inline code
rather than dangling syntax, and absolute URLs link directly.

References inside inline code (``{@link Foo}``) and fenced code blocks
are intentionally left untouched so the syntax itself can be documented.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/components/app/app.tsx                    |   5 +-
 src/components/markdown/markdown-types.ts     |   9 +
 src/components/markdown/markdown.tsx          |   5 +-
 src/kompendium/markdown-inline-links.ts       | 205 ++++++++++++
 src/kompendium/markdown-typelinks.ts          |   8 +
 src/kompendium/markdown.ts                    |  37 ++-
 .../test/markdown-inline-links.spec.ts        | 295 ++++++++++++++++++
 src/kompendium/test/markdown.spec.ts          | 115 +++++++
 8 files changed, 674 insertions(+), 5 deletions(-)
 create mode 100644 src/kompendium/markdown-inline-links.ts
 create mode 100644 src/kompendium/test/markdown-inline-links.spec.ts

diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx
index 09ca52f..d56813d 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/markdown-types.ts b/src/components/markdown/markdown-types.ts
index 4c50275..a120633 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 a459bab..cf4be2f 100644
--- a/src/components/markdown/markdown.tsx
+++ b/src/components/markdown/markdown.tsx
@@ -1,6 +1,6 @@
 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';
 
 /**
@@ -52,7 +52,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 0000000..16f1626
--- /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 7e05e21..86e8683 100644
--- a/src/kompendium/markdown-typelinks.ts
+++ b/src/kompendium/markdown-typelinks.ts
@@ -39,6 +39,14 @@ function collectSkippableCode(
 
     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);
     }
diff --git a/src/kompendium/markdown.ts b/src/kompendium/markdown.ts
index 0e2c4f2..8cfa41d 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-inline-links.spec.ts b/src/kompendium/test/markdown-inline-links.spec.ts
new file mode 100644
index 0000000..347d646
--- /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 53151c5..5cb172c 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
'; From 8d14c394a0378e9c918f09bd062653b3a2bb9fd0 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 18 May 2026 11:05:32 +0200 Subject: [PATCH 3/3] docs(markdown): add example demonstrating inline @link references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated `kompendium-example-inline-links` example component attached to `kompendium-markdown` so the rendered docs page shows each inline `{@link …}` syntactic form alongside its source. The integration test renders the example against the actual built `kompendium.json` registry to keep the showcase honest as the docs build evolves. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../markdown/examples/inline-links-example.ts | 27 +++++++ .../markdown/examples/inline-links.tsx | 19 +++++ src/components/markdown/markdown.tsx | 1 + .../test/markdown-example-integration.spec.ts | 80 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/components/markdown/examples/inline-links-example.ts create mode 100644 src/components/markdown/examples/inline-links.tsx create mode 100644 src/kompendium/test/markdown-example-integration.spec.ts 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 0000000..605756e --- /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 0000000..1929cc3 --- /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.tsx b/src/components/markdown/markdown.tsx index cf4be2f..1533ca4 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -6,6 +6,7 @@ import { scrollToAnchor } from '../anchor-scroll'; /** * This component renders markdown * @exampleComponent kompendium-example-markdown + * @exampleComponent kompendium-example-inline-links */ @Component({ tag: 'kompendium-markdown', 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 0000000..a5f7cdc --- /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');
+    });
+});