Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/app/app.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions src/components/markdown/examples/inline-links-example.ts
Original file line number Diff line number Diff line change
@@ -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.
\`\`\`
`;
19 changes: 19 additions & 0 deletions src/components/markdown/examples/inline-links.tsx
Original file line number Diff line number Diff line change
@@ -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 <kompendium-markdown text={inlineLinksExample} />;
}
}
9 changes: 9 additions & 0 deletions src/components/markdown/markdown-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
let types: string[] = [];
let components: string[] = [];

export function getTypes(): string[] {
return types;
Expand All @@ -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;
}
6 changes: 4 additions & 2 deletions src/components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
205 changes: 205 additions & 0 deletions src/kompendium/markdown-inline-links.ts
Original file line number Diff line number Diff line change
@@ -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],
};
}
Loading
Loading