/**
* The inline grammar for `Markdown`: locating code spans, links, images, strong
* and emphasis runs, and resolving them to escaped React nodes. A single
* forward-only cursor keeps the pass linear; a link is admitted only when
* `safeHttpUrl` accepts it, and an image contributes its `alt` text alone.
*/
import type { ReactNode } from 'react';
import { safeHttpUrl } from './primitives';
// Single-character predicates, so the `\S` and `\w` classes decide flanking
// exactly — a non-breaking space is whitespace, an underscore a word character —
// rather than an open-coded space test getting those edges wrong.
const NON_SPACE = /\S/;
const WORD = /\w/;
/** Inline code: a run of backticks and its literal, unparsed content, on one line. */
const CODE_SPAN = /`([^`\n]+)`/g;
// Emphasis runs carry CommonMark flanking: the opening marker must be followed by
// a non-space and the closing marker preceded by a non-space, so spaced prose like
// `w * h * d` is left literal. An `_` marker is additionally barred from touching a
// word character on its outer side, so intraword underscores (`user_id_field`,
// `a__proto__b`) never open or close a run; `*` keeps working everywhere.
/** A resolved emphasis or strong run: where it sits and the text between its markers. */
interface EmphasisSpan {
readonly index: number;
readonly length: number;
readonly content: string;
}
/**
* Whether the marker ending at `j` closes the pending `opener`: at least one body
* character in, preceded by a non-space (and, for `_`, not followed by a word
* character on its outer edge).
*/
function isEmphasisCloser(
text: string,
j: number,
opener: number,
m: number,
underscore: boolean,
): boolean {
return (
opener !== -1 &&
j >= opener + m + 1 &&
NON_SPACE.test(text[j - 1] ?? '') &&
(!underscore || j + m >= text.length || !WORD.test(text[j + m] ?? ''))
);
}
/**
* Whether the marker at `j` opens a run: no opener pending, followed by a
* non-space (and, for `_`, not abutting a word character on its left).
*/
function isEmphasisOpener(
text: string,
j: number,
opener: number,
m: number,
underscore: boolean,
): boolean {
return (
opener === -1 &&
j + m < text.length &&
NON_SPACE.test(text[j + m] ?? '') &&
(!underscore || j === 0 || !WORD.test(text[j - 1] ?? ''))
);
}
/**
* The earliest run delimited by `marker` (`**`, `__`, `*` or `_`) at or after
* `from`. Walks once, holding one pending opener per line and closing it at the
* first marker a non-space away (and, for `_`, not abutting a word character on
* its outer edges). One pending opener per line reproduces the leftmost lazy
* match, quirks and all (`***a***` yields `***a**`, and `**a*` matches none); a
* newline drops the opener. O(1) per character.
*/
function findEmphasisSpan(text: string, from: number, marker: string): EmphasisSpan | null {
const m = marker.length;
const mark = marker[0];
const underscore = mark === '_';
let opener = -1;
for (let j = from; j + m <= text.length; j += 1) {
const ch = text[j];
if (ch === '\n') {
opener = -1;
continue;
}
if (ch !== mark || (m === 2 && text[j + 1] !== mark)) continue;
if (isEmphasisCloser(text, j, opener, m, underscore)) {
return { index: opener, length: j + m - opener, content: text.slice(opener + m, j) };
}
if (isEmphasisOpener(text, j, opener, m, underscore)) opener = j;
}
return null;
}
/** The outcome of a `scanDestination` sweep: the `)` it found, and where it halted. */
interface DestinationScan {
/** The closing `)` index, or `-1` when the run is empty or unterminated. */
readonly end: number;
/**
* Where the walk halted — the `)` on success, else the whitespace or end-of-text
* that ended a failure. On a failure that advanced, `[start, stop)` provably
* holds no `)`, which the caller memoizes as a dead span.
*/
readonly stop: number;
}
/**
* The destination run of a bracketed construct: one-or-more non-`)`, non-space
* characters bounded by `)`. Forward-only cursor. See {@link DestinationScan} for
* `end`/`stop`.
*/
function scanDestination(text: string, start: number): DestinationScan {
let k = start;
while (k < text.length && text[k] !== ')' && NON_SPACE.test(text[k] ?? '')) k += 1;
if (k > start && text[k] === ')') return { end: k, stop: k };
// A failure halts at `k`: the whitespace that stopped the walk, or `text.length`
// when the run reached the end. Either way `[start, k)` holds no `)`.
return { end: -1, stop: k };
}
/** A resolved link or image: its label/alt `content` and its destination `extra`. */
interface BracketedSpan {
readonly index: number;
readonly length: number;
readonly content: string;
readonly extra: string;
}
/**
* The forward-only memo one `findBracketed` sweep threads through its candidates:
* the reusable `]` pointer, the destination cached on a `]`, and the span a scan
* proved to hold no `)`.
*/
interface BracketMemo {
/** First `]` at an index the current candidate can still use. */
close: number;
/** The `]` the memoized destination belongs to. */
destKey: number;
/** Closing `)` for `destKey`, or -1. */
destEnd: number;
/**
* Exclusive index up to which a scan proved no `)` exists; a destination start
* below it skips the scan. A start at or past it must still scan.
*/
deadUntil: number;
}
/**
* The closing `)` for the destination of the label ending at `close`, memoized on
* that `]`. A destination start inside a proven no-`)` span skips the scan.
*/
function resolveDestination(text: string, close: number, memo: BracketMemo): number {
if (memo.destKey === close) return memo.destEnd;
memo.destKey = close;
const destStart = close + 2;
if (memo.deadUntil !== -1 && destStart < memo.deadUntil) {
memo.destEnd = -1;
return -1;
}
const scan = scanDestination(text, destStart);
memo.destEnd = scan.end;
if (scan.end === -1 && scan.stop > destStart) memo.deadUntil = scan.stop;
return memo.destEnd;
}
/**
* The bracketed span opening at `bracket` (label `start` accounts for an image's
* `!`), or `null` when this candidate has no non-empty label followed by a
* `(dest)`. Requires `memo.close` already advanced to a live `]`.
*/
function matchAt(
text: string,
bracket: number,
start: number,
image: boolean,
memo: BracketMemo,
): BracketedSpan | null {
const labelOk = image || memo.close > bracket + 1;
if (!labelOk || text[memo.close + 1] !== '(') return null;
const destEnd = resolveDestination(text, memo.close, memo);
if (destEnd === -1) return null;
return {
index: start,
length: destEnd + 1 - start,
content: text.slice(bracket + 1, memo.close),
extra: text.slice(memo.close + 2, destEnd),
};
}
/**
* The earliest `[label](dest)` (or, when `image`, ``) at or after
* `from`. A label runs from `[` to the first `]`; a link needs a non-empty label,
* an image admits an empty one. Linear: each `[` candidate reuses a forward-only
* `]` pointer and a destination memoized on that `]`, and `deadUntil` records a
* proven no-`)` span so later candidates starting inside it skip the scan.
*/
function findBracketed(text: string, from: number, image: boolean): BracketedSpan | null {
const memo: BracketMemo = { close: -1, destKey: -1, destEnd: -1, deadUntil: -1 };
let bracket = text.indexOf('[', from);
while (bracket !== -1) {
// An image opens at the `!` immediately before its `[`.
const start = image ? bracket - 1 : bracket;
if (!image || (start >= from && text[start] === '!')) {
if (memo.close < bracket + 1) memo.close = text.indexOf(']', bracket + 1);
if (memo.close === -1) return null;
const span = matchAt(text, bracket, start, image, memo);
if (span !== null) return span;
}
bracket = text.indexOf('[', bracket + 1);
}
return null;
}
type InlineKind = 'code' | 'image' | 'link' | 'strong' | 'emphasis';
/** An inline construct located at an absolute `index`, ready to render. */
interface InlineFound {
readonly kind: InlineKind;
readonly index: number;
readonly length: number;
/** Inline code text, image alt, link label, or emphasis/strong body. */
readonly content: string;
/** A link's or image's destination. */
readonly extra?: string;
}
/** Inline code at or after `from`; the shared cursor is set immediately before the scan. */
function findCode(text: string, from: number): InlineFound | null {
CODE_SPAN.lastIndex = from;
const match = CODE_SPAN.exec(text);
if (match === null) return null;
return { kind: 'code', index: match.index, length: match[0].length, content: match[1] ?? '' };
}
interface InlineRule {
readonly kind: InlineKind;
readonly find: (text: string, from: number) => InlineFound | null;
}
/** Tags an emphasis span with its element kind, or passes `null` through. */
function asFound(kind: InlineKind, span: EmphasisSpan | null): InlineFound | null {
return span === null
? null
: { kind, index: span.index, length: span.length, content: span.content };
}
/**
* Inline constructs in tie-break order at a shared position: code, image, link,
* strong, emphasis — so `**` reads as one strong span, not two emphasis markers.
* Strong and emphasis are split per marker (`**`/`__`, `*`/`_`) so each marker
* caches its own exhaustion and the pass stays linear; star precedes underscore.
*/
const INLINE_RULES: readonly InlineRule[] = [
{ kind: 'code', find: findCode },
{
kind: 'image',
find: (text, from) => {
const span = findBracketed(text, from, true);
return span === null ? null : { kind: 'image', ...span };
},
},
{
kind: 'link',
find: (text, from) => {
const span = findBracketed(text, from, false);
return span === null ? null : { kind: 'link', ...span };
},
},
{ kind: 'strong', find: (text, from) => asFound('strong', findEmphasisSpan(text, from, '**')) },
{ kind: 'strong', find: (text, from) => asFound('strong', findEmphasisSpan(text, from, '__')) },
{
kind: 'emphasis',
find: (text, from) => asFound('emphasis', findEmphasisSpan(text, from, '*')),
},
{
kind: 'emphasis',
find: (text, from) => asFound('emphasis', findEmphasisSpan(text, from, '_')),
},
];
/**
* Resolves inline markup to React nodes. A cursor walks the text once; each rule
* caches its earliest match at or after the cursor and is rescanned only when the
* cursor passes that match, so the rules' rescans cover disjoint forward ranges
* and the pass is linear. Plain runs are pushed as text children that React
* escapes; a link is admitted only when `safeHttpUrl` accepts it, and an image
* contributes its `alt` text alone.
*/
export function renderInline(text: string, keyBase: string): ReactNode[] {
const nodes: ReactNode[] = [];
// Per rule: `undefined` not yet scanned, `null` no match through the end
// (final), otherwise the pending match to reuse until the cursor passes it.
const cache: (InlineFound | null | undefined)[] = INLINE_RULES.map(() => undefined);
let pos = 0;
while (pos < text.length) {
let best: InlineFound | null = null;
for (const [r, rule] of INLINE_RULES.entries()) {
let hit = cache[r];
if (hit === undefined || (hit !== null && hit.index < pos)) {
hit = rule.find(text, pos);
cache[r] = hit;
}
if (hit !== null && (best === null || hit.index < best.index)) {
best = hit;
}
}
if (best === null) {
nodes.push(text.slice(pos));
break;
}
if (best.index > pos) {
nodes.push(text.slice(pos, best.index));
}
nodes.push(renderInlineMatch(best, `${keyBase}-${String(best.index)}`));
pos = best.index + best.length;
}
return nodes;
}
/**
* Test-only entry point to the inline scanner. Resolves inline markup to React
* nodes exactly as `renderInline` does, but without mounting or serializing a
* DOM, so a test can time the scan itself in isolation from render cost. Not part
* of the public API.
*/
export function scanInline(text: string): ReactNode[] {
return renderInline(text, 'k');
}
function renderInlineMatch(found: InlineFound, key: string): ReactNode {
switch (found.kind) {
case 'code':
return {found.content};
case 'image':
// Images are not rendered: the alt text is all that survives, and no URL
// is fetched.
return found.content;
case 'link': {
const url = safeHttpUrl(found.extra ?? '');
const children = renderInline(found.content, `${key}-t`);
if (url === undefined) {
// A destination that is not an absolute http(s) URL never becomes a live
// anchor: the display text is rendered as plain text.
return {children};
}
return (
{children}
);
}
case 'strong':
return {renderInline(found.content, `${key}-t`)};
case 'emphasis':
return {renderInline(found.content, `${key}-t`)};
}
}