1import * as wasm from "./bindgen/markodown.js";
2import bytes from "./bindgen/wasm_bytes.js";
3
4wasm.initSync({ module: bytes });
5
6/** The result of a Markodown transform */
7export type Transformed = Success | Failure;
8
9/** Convert Markodown (`.mdo`) source code into Marko source code (`.marko`) */
10export function transform(options: TransformOptions): Transformed {
11 const formats = options.format ?? ["marko"];
12 let forceFormat = null;
13 if (formats.includes("html") && !formats.includes("marko")) {
14 forceFormat = wasm.OutputFormat.Html;
15 } else if (formats.includes("marko") && !formats.includes("html")) {
16 forceFormat = wasm.OutputFormat.Marko;
17 } else if (!formats.includes("html") && !formats.includes("marko")) {
18 throw new Error("No supported formats in " + JSON.stringify(formats));
19 }
20
21 return wasm.transform(
22 options.source,
23 forceFormat,
24 options?.layoutImport,
25 options?.componentImports,
26 options?.selfImport,
27 options?.markdownOnly,
28 options?.cloverExtensions,
29 );
30}
31
32/**
33 * Converts a flat document outline into a nested tree. You can consume this
34 * tree with a recursive Marko component:
35
36 * ```marko
37 * <define/Recurse|input: HeadingTree|>
38 * <a href=`#${input.id}`><${input.content} /></a>
39
40 * <if=input.children.length>
41 * <ul><for|item| of=input.children>
42 * <li><Recurse ...item /></li>
43 * </></ul>
44 * </>
45 * </>
46 *
47 * <div#toc>
48 * <h2>Contents:</h2>
49 * <for|item| of=groups>
50 * <li><Recurse ...item /></li>
51 * </>
52 * </div>
53 * ```
54 */
55export function outlineToTree(outline: Heading[]): HeadingTree[] {
56 const root: HeadingTree[] = [];
57 const stack: HeadingTree[] = [];
58
59 for (const heading of outline) {
60 if (heading.level === 1) continue;
61
62 const node: HeadingTree = { ...heading, children: [] };
63
64 while (
65 stack.length && stack[stack.length - 1].level >= heading.level
66 ) {
67 stack.pop();
68 }
69
70 if (stack.length) {
71 stack[stack.length - 1].children.push(node);
72 } else {
73 root.push(node);
74 }
75
76 stack.push(node);
77 }
78
79 return root;
80}
81
82/** Options for {@linkcode transform}. */
83export interface TransformOptions {
84 /** The Markodown source code to be transformed */
85 source: string;
86 /**
87 * Specify the allowed output formats. For simplicity, pass `marko`. By
88 * including `html`, you can opt into a faster codepath where plain HTML is
89 * returned to you as such, when no Marko features are used.
90 * @default ["marko"]
91 */
92 format?: Array<"marko" | "html">;
93 /** Wraps the component in another component. Enables Table of Contents generation */
94 layoutImport?: string;
95 /** Import path for the module itself, passed to the layout as `module` */
96 selfImport?: string;
97 /** Replace built-in elements with custom components */
98 componentImports?: ComponentImports;
99 /**
100 * When true, disables all Marko extensions, turning this into a pure
101 * Markdown parser. Marko tags, template expressions, statements, and
102 * comments will be treated as plain text. Defaults to HTML output.
103 * @default false
104 */
105 markdownOnly?: boolean;
106 /**
107 * These extensions are special-cased so that Clover can re-use this on
108 * her website without maintaining a second markdown parser. I promise
109 * we are not wasting your bundle size on my features.
110 */
111 cloverExtensions?: CloverQuestionExtensions;
112}
113
114/** Replace Built In Elements */
115export interface ComponentImports {
116 /** Replace (h1-h6) with this import. Receives attribute `level: number`. */
117 heading?: string;
118 /** Replace `pre > code` with this import. */
119 codeBlock?: string;
120 /** Replace links with this import. */
121 link?: string;
122 /** Replace images with this import. */
123 image?: string;
124 /** Replace blockquotes with this import. */
125 blockquote?: string;
126}
127
128/**
129 * Clover's question extensions are syntax features used on the years of backlog
130 * from https://paperclover.net/q+a. It was easier to re-implement these than
131 * convert everything into Marko. Besides, there are some extra things that
132 * make it so these must emit HTML and not Marko, so these components all
133 * emit custom HTML elements instead of imported components.
134 *
135 * Also includes `@html <raw>` block syntax for raw HTML passthrough.
136 *
137 * @internal
138 */
139export interface CloverQuestionExtensions {
140 /**
141 * Element name for question blocks. Not an import path.
142 * `q: ...inline...` -> `<question>...</question>`
143 *
144 * `q: ...inline...\nq: ...inline...`
145 * ^ this inserts a `<br />` between them since theyre stuck together.
146 */
147 question: string;
148 /**
149 * Element name for artifact ref. Not an import path.
150 * `@its-snowing` -> `<artifactRef>its-snowing</artifactRef>`
151 */
152 artifactRef: string;
153 /**
154 * Element name for question ref. Not an import path.
155 * `#2602142011` -> `<questionRef>2602142011</questionRef>`
156 *
157 * Question refs are 10 or 12 numbers in a row.
158 */
159 questionRef: string;
160 /**
161 * Element name for Labelled redactions.
162 * `#name#` -> `<labelledRedaction>name</labelledRedaction>`
163 */
164 labelledRedaction: string;
165}
166
167/** The transformer currently supports two output formats. */
168export type OutputFormat = "marko" | "html";
169
170/** The transform is a success when `success: true` or there are no errors. */
171export interface Success {
172 /** Easy boolean to discriminate {@linkcode TransformResult} */
173 success: true;
174 /** The transformed text. Format is determined by `format` */
175 text: string;
176 /** The resolved output format of `text` */
177 format: OutputFormat;
178 /** List of errors, if any */
179 errors: [];
180}
181
182/** The transform is a success when `success: false` or there is at least one error. */
183export interface Failure {
184 /** Easy boolean to discriminate {@linkcode TransformResult} */
185 success: false;
186 /** The transformed text. Format is determined by `format` */
187 text: null;
188 /** The resolved output format of `text` */
189 format: null;
190 /** List of errors, if any */
191 errors: [TransformError, ...TransformError[]];
192}
193
194/** This is passed to Markodown layouts */
195export interface LayoutInput {
196 /**
197 * Scanned from heading tags, the document outline is provided flat here. You
198 * can convert it into a nested tree with {@linkcode outlineToTree}
199 */
200 outline: Heading[];
201 /**
202 * A copy of the Module Namespace object of the page. Use this to reflect
203 * frontmatter or other customizable exports. Don't render `module.default` as
204 * a component, since that will recursively call this layout.
205 */
206 module: Record<string, unknown>;
207 /** The rendered document */
208 // @ts-ignore fails if marko types not chilling
209 content: Marko.Body;
210}
211
212/**
213 * Scanned from heading tags, this represents one heading in the document. You
214 * can convert it into a nested tree with {@linkcode outlineToTree}
215 */
216export interface Heading {
217 /** Which header element this corresponds to. */
218 level: 1 | 2 | 3 | 4 | 5 | 6;
219 /** The link ID. Derived from the `id` attribute of the heading, or generated for you otherwise. */
220 id: string;
221 /** The rendered heading name */
222 // @ts-ignore fails if marko types not chilling
223 content: Marko.Body;
224}
225
226/** Generated by {@linkcode outlineToTree} */
227export interface HeadingTree extends Heading {
228 /** Sub-headings */
229 children: HeadingTree[];
230}
231
232export interface TransformError {
233 /** What went wrong? */
234 message: string;
235 /** Additional notes for the failure */
236 notes: TransformNote[];
237 /** One-based line */
238 line: number;
239 /** One-based column, byte offset */
240 column: number;
241}
242
243export interface TransformNote {
244 /** What this span is communicating */
245 message?: string | null;
246 /** One-based line */
247 line: number;
248 /** One-based column, byte offset */
249 column: number;
250 /** Byte length */
251 width: number;
252}