192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
/* Impementation of CommonMark specification for markdown with support
|
|
* for custom syntax extensions via the parser options. Instead of
|
|
* returning an AST that has a second conversion pass to JSX, the
|
|
* returned value of 'parse' is 'engine.Node' which can be stringified
|
|
* via clover's SSR engine. This way, generation optimizations, async
|
|
* components, and other features are gained for free here.
|
|
*/
|
|
function parse(src: string, options: Partial<ParseOpts> = {}) {
|
|
}
|
|
|
|
/* Render markdown content. Same function as 'parse', but JSX components
|
|
* only take one argument and must start with a capital letter. */
|
|
export function Markdown(
|
|
{ src, ...options }: { src: string } & Partial<ParseOpts>,
|
|
) {
|
|
return parse(src, options);
|
|
}
|
|
|
|
function parseInline(src: string, options: Partial<InlineOpts> = {}) {
|
|
const { rules = inlineRules, links = new Map() } = options;
|
|
const opts: InlineOpts = { rules, links };
|
|
const parts: engine.Node[] = [];
|
|
const ruleList = Object.values(rules);
|
|
parse: while (true) {
|
|
for (const rule of ruleList) {
|
|
if (!rule.match) continue;
|
|
const match = src.match(rule.match);
|
|
if (!match) continue;
|
|
const index = UNWRAP(match.index);
|
|
const after = src.slice(index + match[0].length);
|
|
const parse = rule.parse({ after, match: match[0], opts });
|
|
if (!parse) continue;
|
|
// parse before
|
|
parts.push(src.slice(0, index), parse.result);
|
|
src = parse.rest ?? after;
|
|
continue parse;
|
|
}
|
|
break;
|
|
}
|
|
parts.push(src);
|
|
return parts;
|
|
}
|
|
|
|
// -- interfaces --
|
|
interface ParseOpts {
|
|
gfm: boolean;
|
|
blockRules: Record<string, BlockRule>;
|
|
inlineRules: Record<string, InlineRule>;
|
|
}
|
|
interface InlineOpts {
|
|
rules: Record<string, InlineRule>;
|
|
links: Map<string, LinkRef>;
|
|
}
|
|
interface InlineRule {
|
|
match: RegExp;
|
|
parse(opts: {
|
|
after: string;
|
|
match: string;
|
|
opts: InlineOpts;
|
|
}): InlineParse | null;
|
|
}
|
|
interface InlineParse {
|
|
result: engine.Node;
|
|
rest?: string;
|
|
}
|
|
interface LinkRef {
|
|
href: string;
|
|
title: string | null;
|
|
}
|
|
interface BlockRule {
|
|
match: RegExp;
|
|
parse(opts: {}): unknown;
|
|
}
|
|
export const inlineRules: Record<string, InlineRule> = {
|
|
code: {
|
|
match: /`+/,
|
|
// 6.1 - code spans
|
|
parse({ after, match }) {
|
|
const end = after.indexOf(match);
|
|
if (end === -1) return null;
|
|
let inner = after.slice(0, end);
|
|
const rest = after.slice(end + match.length);
|
|
// If the resulting string both begins and ends with a space
|
|
// character, but does not consist entirely of space characters,
|
|
// a single space character is removed from the front and back.
|
|
if (inner.match(/^ [^ ]+ $/)) inner = inner.slice(1, -1);
|
|
return { result: <code>{inner}</code>, rest };
|
|
},
|
|
},
|
|
link: {
|
|
match: /(?<!!)\[/,
|
|
// 6.3 - links
|
|
parse({ after, opts }) {
|
|
// Match '[' to let the inner-most link win.
|
|
const splitText = splitFirst(after, /[[\]]/);
|
|
if (!splitText) return null;
|
|
if (splitText.delim !== "]") return null;
|
|
const { first: textSrc, rest: afterText } = splitText;
|
|
let href: string, title: string | null = null, rest: string;
|
|
if (afterText[0] === "(") {
|
|
// Inline link
|
|
const splitTarget = splitFirst(afterText.slice(1), /\)/);
|
|
if (!splitTarget) return null;
|
|
({ rest } = splitTarget);
|
|
const target = parseLinkTarget(splitTarget.first);
|
|
if (!target) return null;
|
|
({ href, title } = target);
|
|
} else if (afterText[0] === "[") {
|
|
const splitTarget = splitFirst(afterText.slice(1), /]/);
|
|
if (!splitTarget) return null;
|
|
const name = splitTarget.first.trim().length === 0
|
|
// Collapsed reference link
|
|
? textSrc.trim()
|
|
// Full Reference Link
|
|
: splitTarget.first.trim();
|
|
const target = opts.links.get(name);
|
|
if (!target) return null;
|
|
({ href, title } = target);
|
|
({ rest } = splitTarget);
|
|
} else {
|
|
// Shortcut reference link
|
|
const target = opts.links.get(textSrc);
|
|
if (!target) return null;
|
|
({ href, title } = target);
|
|
rest = afterText;
|
|
}
|
|
return {
|
|
result: <a {...{ href, title }}>{parseInline(textSrc, opts)}</a>,
|
|
rest,
|
|
};
|
|
},
|
|
},
|
|
image: {
|
|
match: /!\[/,
|
|
// 6.4 - images
|
|
parse({ after, opts }) {
|
|
// Match '[' to let the inner-most link win.
|
|
const splitText = splitFirst(after, /[[\]]/);
|
|
if (!splitText) return null;
|
|
if (splitText.delim !== "]") return null;
|
|
const { first: textSrc, rest: afterText } = splitText;
|
|
},
|
|
},
|
|
emphasis: {
|
|
// detect left-flanking delimiter runs, but this expression does not
|
|
// consider preceding escapes. instead, those are programatically
|
|
// checked inside the parse function.
|
|
match: /(?:\*+|(?<!\p{P})_+)(?!\s|\p{P}|$)/u,
|
|
// 6.2 - emphasis and strong emphasis
|
|
parse({ before, match, after, opts }) {
|
|
// find out how long the delim sequence is
|
|
|
|
// look for 'ends'
|
|
},
|
|
},
|
|
autolink: {},
|
|
html: {},
|
|
br: {
|
|
match: / +\n|\\\n/,
|
|
parse() {
|
|
return { result: <br /> };
|
|
},
|
|
},
|
|
};
|
|
|
|
function parseLinkTarget(src: string) {
|
|
let href: string, title: string | null = null;
|
|
href = src;
|
|
return { href, title };
|
|
}
|
|
|
|
/* Find a delimiter while considering backslash escapes. */
|
|
function splitFirst(text: string, match: RegExp) {
|
|
let first = "", delim: string, escaped: boolean;
|
|
do {
|
|
const find = text.match(match);
|
|
if (!find) return null;
|
|
delim = find[0];
|
|
const index = UNWRAP(find.index);
|
|
let i = index - 1;
|
|
escaped = false;
|
|
while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1;
|
|
first += text.slice(0, index - +escaped);
|
|
text = text.slice(index + find[0].length);
|
|
} while (escaped);
|
|
return { first, delim, rest: text };
|
|
}
|
|
|
|
console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``")));
|
|
|
|
import * as engine from "#ssr";
|
|
import type { ParseOptions } from "node:querystring";
|