{
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
';