authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-07-08 01:09:55-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
logee1ec05cc2a55234d1cc51bb285ea247d9144bf8
treeab20aad973d218670e95eabd9fe807593658a186
parent84fe2100063fd3f75c749270095c416ef5c69804
signature Commit is signed but in an unrecognized format.

start the markdown parser


2 files changed, 168 insertions(+), 0 deletions(-)

framework/lib/markdown.tsx created+167
...@@ -0,0 +1,167 @@
1/* Impementation of CommonMark specification for markdown with support
2 * for custom syntax extensions via the parser options. Instead of
3 * returning an AST that has a second conversion pass to JSX, the
4 * returned value of 'parse' is 'engine.Node' which can be stringified
5 * via clover's SSR engine. This way, generation optimizations, async
6 * components, and other features are gained for free here.
7 */
8function parse(src: string, options: Partial<ParseOpts> = {}) {
9
10}
11
12/* Render markdown content. Same function as 'parse', but JSX components
13 * only take one argument and must start with a capital letter. */
14export function Markdown({ src, ...options }: { src: string } & Partial<ParseOpts>) {
15 return parse(src, options)
16}
17
18function parseInline(src: string, options: Partial<InlineOpts> = {}) {
19 const { rules = inlineRules, links = new Map() } = options;
20 const opts: InlineOpts = { rules, links };
21 const parts: engine.Node[] = [];
22 const ruleList = Object.values(rules);
23 parse: while(true) {
24 for (const rule of ruleList) {
25 if (!rule.match) continue;
26 const match = src.match(rule.match);
27 if (!match) continue;
28 const index = UNWRAP(match.index);
29 const after = src.slice(index + match[0].length);
30 const parse = rule.parse({ after, match: match[0], opts });
31 if (!parse) continue;
32 parts.push(src.slice(0, index), parse.result);
33 src = parse.rest ?? after;
34 continue parse;
35 }
36 break;
37 }
38 parts.push(src);
39 return parts;
40}
41
42// -- interfaces --
43interface ParseOpts {
44 blockRules: Record<string, BlockRule>;
45 inlineRules: Record<string, InlineRule>;
46}
47interface InlineOpts {
48 rules: Record<string, InlineRule>;
49 links: Map<string, LinkRef>;
50}
51interface InlineRule {
52 match: RegExp;
53 parse(opts: {
54 after: string;
55 match: string;
56 opts: InlineOpts;
57 }): InlineParse | null;
58}
59interface InlineParse {
60 result: engine.Node;
61 rest?: string;
62}
63interface LinkRef {
64 href: string;
65 title: string | null;
66}
67interface BlockRule {
68 match: RegExp;
69 parse(opts: {}): unknown;
70}
71export const inlineRules: Record<string, InlineRule> = {
72 code: {
73 match: /`+/,
74 // 6.1 - code spans
75 parse({ after, match }) {
76 const end = after.indexOf(match);
77 if (end === -1) return null;
78 let inner = after.slice(0, end);
79 const rest = after.slice(end + match.length);
80 // If the resulting string both begins and ends with a space
81 // character, but does not consist entirely of space characters,
82 // a single space character is removed from the front and back.
83 if (inner.match(/^ [^ ]+ $/)) inner = inner.slice(1, -1);
84 return { result: <code>{inner}</code>, rest };
85 },
86 },
87 emphasis: {},
88 link: {
89 match: /(?<!!)\[/,
90 parse({ after, opts }) {
91 // Match '[' to let the inner-most link win.
92 const splitText = splitFirst(after, /[[\]]/);
93 if (!splitText) return null;
94 if (splitText.delim !== "]") return null;
95 const { first: textSrc, rest: afterText } = splitText;
96 let href: string, title: string | null = null, rest: string;
97 if (afterText[0] === "(") {
98 // Inline link
99 const splitTarget = splitFirst(afterText.slice(1), /\)/);
100 if (!splitTarget) return null;
101 ({ rest } = splitTarget);
102 const target = parseLinkTarget(splitTarget.first);
103 if (!target) return null;
104 ({ href, title } = target);
105 } else if (afterText[0] === "[") {
106 const splitTarget = splitFirst(afterText.slice(1), /]/);
107 if (!splitTarget) return null;
108 const name = splitTarget.first.trim().length === 0
109 // Collapsed reference link
110 ? textSrc.trim()
111 // Full Reference Link
112 : splitTarget.first.trim();
113 const target = opts.links.get(name);
114 if (!target) return null;
115 ({ href, title } = target);
116 ({ rest } = splitTarget);
117 } else {
118 // Shortcut reference link
119 const target = opts.links.get(textSrc);
120 if (!target) return null;
121 ({ href, title } = target);
122 rest = afterText;
123 }
124 return {
125 result: <a {...{ href, title }}>{parseInline(textSrc, opts)}</a>,
126 rest,
127 };
128 },
129 },
130 image: {},
131 autolink: {},
132 html: {},
133 br: {
134 match: / +\n|\\\n/,
135 parse() {
136 return { result: <br /> };
137 },
138 },
139};
140
141function parseLinkTarget(src: string) {
142 let href: string, title: string | null = null;
143 href = src;
144 return { href, title };
145}
146
147/* Find a delimiter while considering backslash escapes. */
148function splitFirst(text: string, match: RegExp) {
149 let first = "", delim: string, escaped: boolean;
150 do {
151 const find = text.match(match);
152 if (!find) return null;
153 delim = find[0];
154 const index = UNWRAP(find.index);
155 let i = index - 1;
156 escaped = false;
157 while (i >= 0 && text[i] === "\\") escaped = !escaped, i -= 1;
158 first += text.slice(0, index - +escaped);
159 text = text.slice(index + find[0].length);
160 } while (escaped);
161 return { first, delim, rest: text };
162}
163
164console.log(engine.ssrSync(parseInline("meow `bwaa` `` ` `` `` `z``")));
165
166import * as engine from "#ssr";import type { ParseOptions } from "node:querystring";
167
src/file-viewer/transcode-rules.ts+1
...@@ -120,6 +120,7 @@ export const imagePresets = [...@@ -120,6 +120,7 @@ export const imagePresets = [
120 "-effort",120 "-effort",
121 "9",121 "9",
122 "-update",122 "-update",
123 "1",
123 "-frames:v",124 "-frames:v",
124 "1",125 "1",
125 ],126 ],