authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 00:51:41-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 01:27:02-08:00
logf6677db2d5545e8d8b2ebc12ee2bdef114192798
tree2e82d7bf9bfe56615c6e9dc03bb96b83dbed5807
parentc0f040a1b3c5f865374e3027102048483ec0c0b2
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: clover extensions

closes #1

18 files changed, 801 insertions(+), 47 deletions(-)

ARCHITECTURE.md+2-1
......@@ -33,7 +33,8 @@ these rules are configured in `plugin/mod.rs` in `add_all`.
3333## `lib/`: javascript package
3434
3535published to jsr and then to npm. this is a thin wrapper on top of wasm-bindgen,
36with plugins for the web ecosystem!
36with plugins for the web ecosystem! you can build the wasm code with
37`sh wasm.sh`.
3738
3839- `@clo/markodown` - primary import (`lib/mod.ts`)
3940- `@clo/markodown/rollup.ts` - rollup or vite plugin (`lib/rollup.ts`)
examples/marko-run/src/md/example.mdo+3-1
......@@ -14,7 +14,7 @@ function doMyFavoriteThing() {
1414}
1515```
1616
17## photos of my favorite people
17## photos of my *favorite people*
1818
1919i love using Marko components because they're extremely concise to write.
2020a lot less brace hell for non-string attributes, and more treats!
......@@ -28,6 +28,8 @@ a lot less brace hell for non-string attributes, and more treats!
2828 <@img src="IMG_4838.jpeg" w=2 />
2929 <@img src="IMG_4839.jpeg" w=2 align="top" />
3030 <@img src="IMG_4833.jpeg" h=2 />
31 <@img src="IMG_4832.jpeg" />
32 <@img src="IMG_4833.jpeg" />
3133</>
3234
3335## in conclusion
examples/marko-run/src/routes/+layout.marko-3
......@@ -21,9 +21,6 @@
2121 color: #fff;
2222 background: #15151e;
2323 }
24 code {
25 color: #fc0;
26 }
2724 a {
2825 color: #09c;
2926 }
examples/marko-run/src/tags/markdown-layout.marko+8-3
......@@ -1,7 +1,7 @@
11export interface Header {
22 level: 1 | 2 | 3 | 4 | 5 | 6;
33 id: string;
4 html: string;
4 content: Marko.Body;
55}
66
77export interface Input {
......@@ -34,7 +34,7 @@ export interface Input {
3434 </p>
3535 </>
3636 <for|header| of=input.outline>
37 <li><a href="#" + header.id>${JSON.stringify(header)}</a></li>
37 <li><a href="#" + header.id><${header.content}/></a></li>
3838 </for>
3939 </aside>
4040 </div>
......@@ -48,7 +48,7 @@ export interface Input {
4848 }
4949
5050 .markdown-layout > h1 {
51 margin: 0 0 2rem 0;
51 margin: 0;
5252 font-size: 2.5rem;
5353 font-weight: 700;
5454 }
......@@ -110,4 +110,9 @@ export interface Input {
110110 position: static;
111111 }
112112 }
113
114 .markdown-layout pre {
115 background-color: black;
116 padding: 1rem;
117 }
113118</style>
\ No newline at end of file
examples/marko-run/src/tags/photo-grid.marko+1-1
......@@ -35,7 +35,7 @@ export interface Input {
3535
3636 if (x >= cols) {
3737 x = 0;
38 y += h;
38 y += 1;
3939 }
4040 }
4141 return result;
lib/clover.test.ts created+322
......@@ -0,0 +1,322 @@
1import { describe, it, expect } from "vitest";
2import { transform, CloverQuestionExtensions } from "./mod.ts";
3
4const cloverExtensions: CloverQuestionExtensions = {
5 question: "q-block",
6 artifactRef: "artifact-ref",
7 questionRef: "question-ref",
8 labelledRedaction: "redacted",
9};
10
11describe("clover extensions", () => {
12 describe("question blocks", () => {
13 it("transforms q: prefix to question element", () => {
14 const result = transform({
15 source: "q: what is your favorite color?",
16 markdownOnly: true,
17 cloverExtensions,
18 });
19 expect(result.success).toBe(true);
20 if (result.success) {
21 expect(result.text).toContain("<q-block>");
22 expect(result.text).toContain("what is your favorite color?");
23 expect(result.text).toContain("</q-block>");
24 }
25 });
26
27 it("adds br between consecutive question lines", () => {
28 const result = transform({
29 source: "q: first question\nq: second question",
30 markdownOnly: true,
31 cloverExtensions,
32 });
33 expect(result.success).toBe(true);
34 if (result.success) {
35 expect(result.text).toContain("<br />");
36 expect(result.text).toContain("<q-block>first question</q-block>");
37 expect(result.text).toContain("<q-block>second question</q-block>");
38 }
39 });
40
41 it("does not add br for non-consecutive questions", () => {
42 const result = transform({
43 source: "q: first question\n\nsome text\n\nq: second question",
44 markdownOnly: true,
45 cloverExtensions,
46 });
47 expect(result.success).toBe(true);
48 if (result.success) {
49 expect(result.text).not.toContain("<br />");
50 }
51 });
52
53 it("parses inline markdown in questions", () => {
54 const result = transform({
55 source: "q: what about **bold** text?",
56 markdownOnly: true,
57 cloverExtensions,
58 });
59 expect(result.success).toBe(true);
60 if (result.success) {
61 expect(result.text).toContain("<strong>bold</strong>");
62 }
63 });
64 });
65
66 describe("@html blocks", () => {
67 it("passes through raw HTML", () => {
68 const result = transform({
69 source: '@html <custom-element attr="value">content</custom-element>',
70 markdownOnly: true,
71 cloverExtensions,
72 });
73 expect(result.success).toBe(true);
74 if (result.success) {
75 expect(result.text).toContain(
76 '<custom-element attr="value">content</custom-element>'
77 );
78 }
79 });
80
81 it("does not wrap @html in paragraph", () => {
82 const result = transform({
83 source: "@html <div>raw</div>",
84 markdownOnly: true,
85 cloverExtensions,
86 });
87 expect(result.success).toBe(true);
88 if (result.success) {
89 expect(result.text).not.toContain("<p>");
90 expect(result.text).toContain("<div>raw</div>");
91 }
92 });
93 });
94
95 describe("artifact refs", () => {
96 it("transforms @slug to artifact ref element", () => {
97 const result = transform({
98 source: "check out @its-snowing for details",
99 markdownOnly: true,
100 cloverExtensions,
101 });
102 expect(result.success).toBe(true);
103 if (result.success) {
104 expect(result.text).toContain(
105 "<artifact-ref>its-snowing</artifact-ref>"
106 );
107 }
108 });
109
110 it("handles multiple artifact refs", () => {
111 const result = transform({
112 source: "see @first-one and @second-one",
113 markdownOnly: true,
114 cloverExtensions,
115 });
116 expect(result.success).toBe(true);
117 if (result.success) {
118 expect(result.text).toContain("<artifact-ref>first-one</artifact-ref>");
119 expect(result.text).toContain(
120 "<artifact-ref>second-one</artifact-ref>"
121 );
122 }
123 });
124
125 it("only matches lowercase letters, digits, and hyphens", () => {
126 const result = transform({
127 source: "@valid-slug123 but @INVALID stays",
128 markdownOnly: true,
129 cloverExtensions,
130 });
131 expect(result.success).toBe(true);
132 if (result.success) {
133 expect(result.text).toContain(
134 "<artifact-ref>valid-slug123</artifact-ref>"
135 );
136 expect(result.text).toContain("@INVALID");
137 expect(result.text).not.toContain("<artifact-ref>INVALID");
138 }
139 });
140
141 it("does not match @html as artifact ref", () => {
142 const result = transform({
143 source: "@html <div>test</div>",
144 markdownOnly: true,
145 cloverExtensions,
146 });
147 expect(result.success).toBe(true);
148 if (result.success) {
149 expect(result.text).not.toContain("<artifact-ref>html");
150 }
151 });
152 });
153
154 describe("question refs", () => {
155 it("transforms 10-digit refs", () => {
156 const result = transform({
157 source: "see #2602142011 for more",
158 markdownOnly: true,
159 cloverExtensions,
160 });
161 expect(result.success).toBe(true);
162 if (result.success) {
163 expect(result.text).toContain(
164 "<question-ref>2602142011</question-ref>"
165 );
166 }
167 });
168
169 it("transforms 12-digit refs", () => {
170 const result = transform({
171 source: "see #260214201112 for more",
172 markdownOnly: true,
173 cloverExtensions,
174 });
175 expect(result.success).toBe(true);
176 if (result.success) {
177 expect(result.text).toContain(
178 "<question-ref>260214201112</question-ref>"
179 );
180 }
181 });
182
183 it("does not match other digit counts", () => {
184 const result = transform({
185 source: "#123456789 and #1234567890123",
186 markdownOnly: true,
187 cloverExtensions,
188 });
189 expect(result.success).toBe(true);
190 if (result.success) {
191 // 9 digits - not matched
192 expect(result.text).toContain("#123456789");
193 expect(result.text).not.toContain("<question-ref>123456789</");
194 // 13 digits - not matched
195 expect(result.text).toContain("#1234567890123");
196 }
197 });
198
199 it("handles multiple question refs", () => {
200 const result = transform({
201 source: "compare #2602142011 with #2602142012",
202 markdownOnly: true,
203 cloverExtensions,
204 });
205 expect(result.success).toBe(true);
206 if (result.success) {
207 expect(result.text).toContain(
208 "<question-ref>2602142011</question-ref>"
209 );
210 expect(result.text).toContain(
211 "<question-ref>2602142012</question-ref>"
212 );
213 }
214 });
215 });
216
217 describe("labelled redactions", () => {
218 it("transforms ##label## to redaction element", () => {
219 const result = transform({
220 source: "the ##secret name## is hidden",
221 markdownOnly: true,
222 cloverExtensions,
223 });
224 expect(result.success).toBe(true);
225 if (result.success) {
226 expect(result.text).toContain("<redacted>secret name</redacted>");
227 }
228 });
229
230 it("handles hyphens and underscores in labels", () => {
231 const result = transform({
232 source: "##my-secret_label##",
233 markdownOnly: true,
234 cloverExtensions,
235 });
236 expect(result.success).toBe(true);
237 if (result.success) {
238 expect(result.text).toContain("<redacted>my-secret_label</redacted>");
239 }
240 });
241
242 it("does not match empty labels", () => {
243 const result = transform({
244 source: "text #### more",
245 markdownOnly: true,
246 cloverExtensions,
247 });
248 expect(result.success).toBe(true);
249 if (result.success) {
250 expect(result.text).not.toContain("<redacted></redacted>");
251 // #### in the middle of text stays as-is
252 expect(result.text).toContain("####");
253 }
254 });
255
256 it("handles multiple redactions", () => {
257 const result = transform({
258 source: "##first## and ##second##",
259 markdownOnly: true,
260 cloverExtensions,
261 });
262 expect(result.success).toBe(true);
263 if (result.success) {
264 expect(result.text).toContain("<redacted>first</redacted>");
265 expect(result.text).toContain("<redacted>second</redacted>");
266 }
267 });
268 });
269
270 describe("combined usage", () => {
271 it("handles all extensions in one document", () => {
272 const result = transform({
273 source: `q: what is @some-artifact about?
274q: also see #2602142011
275
276The ##secret## is revealed.
277
278@html <hr class="divider" />`,
279 markdownOnly: true,
280 cloverExtensions,
281 });
282 expect(result.success).toBe(true);
283 if (result.success) {
284 expect(result.text).toContain("<q-block>");
285 expect(result.text).toContain("<artifact-ref>some-artifact</artifact-ref>");
286 expect(result.text).toContain("<question-ref>2602142011</question-ref>");
287 expect(result.text).toContain("<redacted>secret</redacted>");
288 expect(result.text).toContain('<hr class="divider" />');
289 expect(result.text).toContain("<br />"); // between consecutive q: lines
290 }
291 });
292
293 it("works with markdownOnly: false (marko mode)", () => {
294 const result = transform({
295 source: "q: question with @artifact",
296 cloverExtensions,
297 });
298 expect(result.success).toBe(true);
299 if (result.success) {
300 expect(result.text).toContain("<q-block>");
301 expect(result.text).toContain("<artifact-ref>artifact</artifact-ref>");
302 }
303 });
304 });
305
306 describe("without cloverExtensions", () => {
307 it("does not parse clover syntax when extensions not provided", () => {
308 const result = transform({
309 source: "q: not a question\n@not-artifact\n#2602142011\n##not-redacted##",
310 markdownOnly: true,
311 });
312 expect(result.success).toBe(true);
313 if (result.success) {
314 expect(result.text).toContain("q: not a question");
315 expect(result.text).not.toContain("<q-block>");
316 expect(result.text).not.toContain("<artifact-ref>");
317 expect(result.text).not.toContain("<question-ref>");
318 expect(result.text).not.toContain("<redacted>");
319 }
320 });
321 });
322});
lib/esbuild.ts+2-1
......@@ -44,6 +44,7 @@ export default function esbuildPlugin(
4444 const result = markodown.transform({
4545 ...options,
4646 source: await fs.readFile(file, "utf-8"),
47 selfImport: "./" + path.basename(file),
4748 });
4849 if (!result.success) {
4950 return {
......@@ -68,7 +69,7 @@ export default function esbuildPlugin(
6869
6970 // TODO: source maps!
7071 // TODO: parse marko errors
71 const { code, meta } = await marko.compile(
72 const { code, meta } = await compile(
7273 result.text,
7374 file + ".marko",
7475 options?.markoOptions ?? {},
lib/mod.test.ts+4-4
......@@ -39,7 +39,7 @@ describe("transform", () => {
3939 const error = result.errors[0];
4040 expect(error.message).toContain("Unclosed tag");
4141 expect(error.line).toBe(1);
42 expect(error.column).toBe(2); // after '<'
42 expect(error.column).toBe(1); // at '<'
4343 }
4444 });
4545
......@@ -117,9 +117,9 @@ describe("transform", () => {
117117 if (!result.success) {
118118 const error = result.errors[0];
119119 expect(error.labels.length).toBe(2);
120 // "div" has width 3
121 expect(error.labels[0].width).toBe(3);
122 // "span" has width 4
120 // "<div" has width 4 (includes < for better error highlighting)
121 expect(error.labels[0].width).toBe(4);
122 // "span" has width 4 (close tags only highlight the name)
123123 expect(error.labels[1].width).toBe(4);
124124 }
125125 });
lib/mod.ts+68-14
......@@ -5,20 +5,6 @@ wasm.initSync({ module: bytes });
55
66type Transformed = Success | Failure;
77
8/** Configuration for replacing built-in elements with custom components. */
9export interface ComponentImports {
10 /** Replace heading elements (h1-h6) with a custom component. Receives `level=1-6`. */
11 heading?: string;
12 /** Replace code blocks with a custom component. Receives `language` and `meta`. */
13 codeBlock?: string;
14 /** Replace link elements with a custom component. */
15 link?: string;
16 /** Replace image elements with a custom component. */
17 image?: string;
18 /** Replace blockquote elements with a custom component. */
19 blockquote?: string;
20}
21
228export interface TransformOptions {
239 source: string;
2410 /**
......@@ -30,8 +16,73 @@ export interface TransformOptions {
3016 format?: Array<"marko" | "html">;
3117 /** Wraps the component in another component. Enables Table of Contents generation */
3218 layoutImport?: string;
19 /** Import path for the module itself, passed to the layout as `module` */
20 selfImport?: string;
3321 /** Replace built-in elements with custom components */
3422 componentImports?: ComponentImports;
23 /**
24 * When true, disables all Marko extensions, turning this into a pure
25 * Markdown parser. Marko tags, template expressions, statements, and
26 * comments will be treated as plain text. Defaults to HTML output.
27 * @default false
28 */
29 markdownOnly?: boolean;
30 /**
31 * These extensions are special-cased so that Clover can re-use this on
32 * her website without
33 */
34 cloverExtensions?: CloverQuestionExtensions;
35}
36
37/** Replace Built In Elements */
38export interface ComponentImports {
39 /** Replace (h1-h6) with this import. Receives attribute `level: number`. */
40 heading?: string;
41 /** Replace `pre > code` with this import. */
42 codeBlock?: string;
43 /** Replace markdown links with this import. */
44 link?: string;
45 /** Replace images with this import. */
46 image?: string;
47 /** Replace blockquotes with this import. */
48 blockquote?: string;
49}
50
51/**
52 * Clover's question extensions are syntax features used on the years of backlog
53 * from https://paperclover.net/q+a. It was easier to re-implement these than
54 * convert everything into Marko. Besides, there are some extra things that
55 * make it so these must emit HTML and not Marko, so these components all
56 * emit custom HTML elements instead of imported components.
57 *
58 * Also includes `@html <raw>` block syntax for raw HTML passthrough.
59 */
60export interface CloverQuestionExtensions {
61 /**
62 * Element name for question blocks. Not an import path.
63 * `q: ...inline...` -> `<question>...</question>`
64 *
65 * `q: ...inline...\nq: ...inline...`
66 * ^ this inserts a `<br />` between them since theyre stuck together.
67 */
68 question: string;
69 /**
70 * Element name for artifact ref. Not an import path.
71 * `@its-snowing` -> `<artifactRef>its-snowing</artifactRef>`
72 */
73 artifactRef: string;
74 /**
75 * Element name for question ref. Not an import path.
76 * `#2602142011` -> `<questionRef>2602142011</questionRef>`
77 *
78 * Question refs are 10 or 12 numbers in a row.
79 */
80 questionRef: string;
81 /**
82 * Element name for Labelled redactions.
83 * `##name##` -> `<labelledRedaction>name</labelledRedaction>`
84 */
85 labelledRedaction: string;
3586}
3687
3788export type OutputFormat = "marko" | "html";
......@@ -86,5 +137,8 @@ export function transform(options: TransformOptions): Transformed {
86137 forceFormat,
87138 options?.layoutImport,
88139 options?.componentImports,
140 options?.selfImport,
141 options?.markdownOnly,
142 options?.cloverExtensions,
89143 );
90144}
lib/rollup.ts+5-2
......@@ -1,6 +1,7 @@
11import * as markodown from "./mod";
22import * as marko from "@marko/compiler";
33import type * as rollup from "rollup";
4import * as path from "node:path";
45
56export type RollupPluginOptions =
67 & Omit<markodown.TransformOptions, "source" | "format">
......@@ -15,7 +16,7 @@ export type RollupPluginOptions =
1516export default function rollupPlugin(
1617 options: RollupPluginOptions = {},
1718): rollup.Plugin {
18 const { compileSync } = options.marko ?? marko;
19 const { compile } = options.marko ?? marko;
1920 return {
2021 name: "@clo/markodown/rollup.ts",
2122 load: {
......@@ -26,6 +27,7 @@ export default function rollupPlugin(
2627 const result = markodown.transform({
2728 ...options,
2829 source,
30 selfImport: "./" + path.basename(id),
2931 });
3032
3133 if (!result.success) {
......@@ -45,7 +47,8 @@ export default function rollupPlugin(
4547 console.log(result);
4648
4749 // TODO: source maps!
48 const { code, meta } = compileSync(
50 // TODO: parse marko errors
51 const { code, meta } = await compile(
4952 result.text,
5053 id + ".marko",
5154 {
src/component_transforms.rs+1-1
......@@ -129,7 +129,7 @@ impl NodeValue for CodeBlockComponentNode {
129129 fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang)));
130130 }
131131 if let Some(meta) = &self.meta {
132 fmt.text_raw(&format!(" meta=\"{}\"", escape_attr(meta)));
132 fmt.text_raw(&format!(" {}", meta));
133133 }
134134
135135 fmt.text_raw(">\n");
src/lib.rs+24-4
......@@ -32,6 +32,21 @@ pub struct ComponentImports {
3232 pub blockquote: Option<String>,
3333}
3434
35/// Clover's question extensions for paperclover.net/q+a backlog.
36/// These emit custom HTML elements (not Marko components).
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct CloverExtensions {
40 /// Element name for question blocks. `q: text` -> `<element>text</element>`
41 pub question: String,
42 /// Element name for artifact refs. `@slug` -> `<element>slug</element>`
43 pub artifact_ref: String,
44 /// Element name for question refs. `#2602142011` -> `<element>2602142011</element>`
45 pub question_ref: String,
46 /// Element name for labelled redactions. `##name##` -> `<element>name</element>`
47 pub labelled_redaction: String,
48}
49
3550pub struct Output {
3651 pub text: String,
3752 pub format: OutputFormat,
......@@ -51,6 +66,8 @@ pub fn transform(
5166 layout_import: Option<String>,
5267 component_imports: Option<ComponentImports>,
5368 self_import: Option<String>,
69 markdown_only: bool,
70 clover_extensions: Option<CloverExtensions>,
5471) -> Result<Output, Vec<OxcDiagnostic>> {
5572 // Pre-process to extract preamble (blank lines, imports) and frontmatter
5673 // This is needed because markdown-it skips blank lines before running block rules
......@@ -66,7 +83,7 @@ pub fn transform(
6683
6784 let md = &mut markdown_it::MarkdownIt::new();
6885
69 plugin::add_all(md);
86 plugin::add_all(md, markdown_only, &clover_extensions);
7087 markdown_it::plugins::cmark::add(md);
7188 markdown_it::plugins::extra::add(md);
7289
......@@ -92,8 +109,11 @@ pub fn transform(
92109
93110 // Determine output format
94111 // componentImports forces Marko format since we add imports
112 // markdown_only mode defaults to HTML since no Marko features are parsed
95113 let format = force.unwrap_or_else(|| {
96 if component_imports.is_some() || has_marko_features(&ast) {
114 if markdown_only {
115 OutputFormat::Html
116 } else if component_imports.is_some() || has_marko_features(&ast) {
97117 OutputFormat::Marko
98118 } else {
99119 OutputFormat::Html
......@@ -156,11 +176,11 @@ pub fn transform(
156176
157177 if let Some(self_path) = self_import {
158178 text = format!(
159 "import Layout__markodown__ from \"{layout_path}\";import * as self__markodown__ from \"{self_path}\";\n{hoisted}<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{text}</>"
179 "import Layout__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{hoisted}\n<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{text}</>"
160180 );
161181 } else {
162182 text = format!(
163 "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}<Layout__markodown__ module=null outline={outline_array}>\n{text}</>"
183 "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}\n<Layout__markodown__ module=null outline={outline_array}>\n{text}</>"
164184 );
165185 }
166186 } else {
src/main.rs+13-1
......@@ -48,6 +48,10 @@ struct Cli {
4848 /// Replace blockquote elements with a custom component
4949 #[arg(long)]
5050 blockquote_import: Option<String>,
51
52 /// Disable all Marko extensions, treating input as pure Markdown
53 #[arg(long)]
54 markdown_only: bool,
5155}
5256
5357fn main() {
......@@ -87,7 +91,15 @@ fn main() {
8791 }
8892 };
8993
90 match transform(&source, force, layout_import, component_imports, None) {
94 match transform(
95 &source,
96 force,
97 layout_import,
98 component_imports,
99 None,
100 cli.markdown_only,
101 None, // clover_extensions not exposed via CLI
102 ) {
91103 Err(errors) => {
92104 let handler = GraphicalReportHandler::new();
93105 for error in &errors {
src/plugin/clover.rs created+306
......@@ -0,0 +1,306 @@
1//! Clover's Q+A extensions for paperclover.net
2//!
3//! Block rules:
4//! - `q: text` -> `<question>text</question>` (consecutive q: lines get <br /> between)
5//! - `@html <raw>` -> raw HTML passthrough
6//!
7//! Inline rules:
8//! - `@slug` -> `<artifactRef>slug</artifactRef>`
9//! - `#2602142011` -> `<questionRef>2602142011</questionRef>`
10//! - `##label##` -> `<labelledRedaction>label</labelledRedaction>`
11
12use crate::CloverExtensions;
13use markdown_it::parser::block::{BlockRule, BlockState};
14use markdown_it::parser::extset::MarkdownItExt;
15use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState};
16use markdown_it::{MarkdownIt, Node, NodeValue, Renderer};
17
18/// Stored in MarkdownIt.ext for runtime access
19#[derive(Debug, Clone)]
20pub struct CloverConfig(pub CloverExtensions);
21
22impl MarkdownItExt for CloverConfig {}
23
24/// Register clover extension rules
25pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
26 md.ext.insert(CloverConfig(config));
27 md.block.add_rule::<QuestionBlockRule>();
28 md.block.add_rule::<HtmlBlockRule>();
29 md.inline.add_rule::<ArtifactRefRule>();
30 md.inline.add_rule::<QuestionRefRule>();
31 md.inline.add_rule::<LabelledRedactionRule>();
32}
33
34// =============================================================================
35// Question Block: `q: inline text`
36// =============================================================================
37
38#[derive(Debug)]
39pub struct QuestionBlock {
40 pub element: String,
41 /// Whether this question follows another question (needs <br /> prefix)
42 pub needs_break: bool,
43}
44
45impl NodeValue for QuestionBlock {
46 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
47 if self.needs_break {
48 fmt.text_raw("<br />");
49 }
50 fmt.text_raw(&format!("<{}>", self.element));
51 fmt.contents(&node.children);
52 fmt.text_raw(&format!("</{}>", self.element));
53 fmt.text_raw("\n");
54 }
55}
56
57pub struct QuestionBlockRule;
58
59impl BlockRule for QuestionBlockRule {
60 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
61 let config = state.md.ext.get::<CloverConfig>()?;
62
63 if state.line >= state.line_max {
64 return None;
65 }
66
67 let line = state.get_line(state.line);
68 if !line.starts_with("q: ") {
69 return None;
70 }
71
72 // Check if previous line was also a question block
73 let needs_break = if state.line > 0 {
74 let prev_line = state.get_line(state.line - 1);
75 prev_line.starts_with("q: ")
76 } else {
77 false
78 };
79
80 let mut node = Node::new(QuestionBlock {
81 element: config.0.question.clone(),
82 needs_break,
83 });
84
85 // Parse inline content (skip "q: " prefix = 3 chars)
86 let content = line[3..].to_owned();
87 let mapping = vec![(0, state.line_offsets[state.line].first_nonspace + 3)];
88 node.children
89 .push(Node::new(InlineRoot::new(content, mapping)));
90
91 Some((node, 1))
92 }
93}
94
95// =============================================================================
96// HTML Block: `@html <raw content>`
97// =============================================================================
98
99#[derive(Debug)]
100pub struct RawHtmlBlock {
101 pub content: String,
102}
103
104impl NodeValue for RawHtmlBlock {
105 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
106 fmt.text_raw(&self.content);
107 fmt.text_raw("\n");
108 }
109}
110
111pub struct HtmlBlockRule;
112
113impl BlockRule for HtmlBlockRule {
114 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
115 // This rule is enabled when any clover extensions are present
116 state.md.ext.get::<CloverConfig>()?;
117
118 if state.line >= state.line_max {
119 return None;
120 }
121
122 let line = state.get_line(state.line);
123 if !line.starts_with("@html ") {
124 return None;
125 }
126
127 let content = &line[6..];
128 let node = Node::new(RawHtmlBlock {
129 content: content.to_string(),
130 });
131
132 Some((node, 1))
133 }
134}
135
136// =============================================================================
137// Artifact Ref: `@slug-name`
138// =============================================================================
139
140#[derive(Debug)]
141pub struct ArtifactRef {
142 pub element: String,
143 pub slug: String,
144}
145
146impl NodeValue for ArtifactRef {
147 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
148 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.slug));
149 }
150}
151
152pub struct ArtifactRefRule;
153
154impl InlineRule for ArtifactRefRule {
155 const MARKER: char = '@';
156
157 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
158 let config = state.md.ext.get::<CloverConfig>()?;
159
160 let input = &state.src[state.pos..state.pos_max];
161 if !input.starts_with('@') {
162 return None;
163 }
164
165 // Match @[a-z0-9-]+ but not @html (that's a block rule)
166 let rest = &input[1..];
167 if rest.starts_with("html ") {
168 return None;
169 }
170
171 let slug_len = rest
172 .chars()
173 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
174 .count();
175
176 if slug_len == 0 {
177 return None;
178 }
179
180 let slug = &rest[..slug_len];
181 let node = Node::new(ArtifactRef {
182 element: config.0.artifact_ref.clone(),
183 slug: slug.to_string(),
184 });
185
186 Some((node, 1 + slug_len))
187 }
188}
189
190// =============================================================================
191// Question Ref: `#2602142011` (10 or 12 digits)
192// =============================================================================
193
194#[derive(Debug)]
195pub struct QuestionRef {
196 pub element: String,
197 pub id: String,
198}
199
200impl NodeValue for QuestionRef {
201 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
202 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.id));
203 }
204}
205
206pub struct QuestionRefRule;
207
208impl InlineRule for QuestionRefRule {
209 const MARKER: char = '#';
210
211 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
212 let config = state.md.ext.get::<CloverConfig>()?;
213
214 let input = &state.src[state.pos..state.pos_max];
215 if !input.starts_with('#') {
216 return None;
217 }
218
219 // Check it's not ## (that's labelled redaction)
220 if input.starts_with("##") {
221 return None;
222 }
223
224 let rest = &input[1..];
225 let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count();
226
227 // Must be exactly 10 or 12 digits
228 if digit_count != 10 && digit_count != 12 {
229 return None;
230 }
231
232 // Make sure the next char after digits is not also a digit
233 if rest
234 .chars()
235 .nth(digit_count)
236 .is_some_and(|c| c.is_ascii_digit())
237 {
238 return None;
239 }
240
241 let id = &rest[..digit_count];
242 let node = Node::new(QuestionRef {
243 element: config.0.question_ref.clone(),
244 id: id.to_string(),
245 });
246
247 Some((node, 1 + digit_count))
248 }
249}
250
251// =============================================================================
252// Labelled Redaction: `##label##`
253// =============================================================================
254
255#[derive(Debug)]
256pub struct LabelledRedaction {
257 pub element: String,
258 pub label: String,
259}
260
261impl NodeValue for LabelledRedaction {
262 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
263 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.label));
264 }
265}
266
267pub struct LabelledRedactionRule;
268
269impl InlineRule for LabelledRedactionRule {
270 const MARKER: char = '#';
271
272 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
273 let config = state.md.ext.get::<CloverConfig>()?;
274
275 let input = &state.src[state.pos..state.pos_max];
276 if !input.starts_with("##") {
277 return None;
278 }
279
280 let rest = &input[2..];
281
282 // Find closing ##
283 let label_end = rest.find("##")?;
284 if label_end == 0 {
285 return None; // Empty label
286 }
287
288 let label = &rest[..label_end];
289
290 // Label should be simple identifier-like (alphanumeric, hyphens, underscores, spaces)
291 if !label
292 .chars()
293 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ')
294 {
295 return None;
296 }
297
298 let node = Node::new(LabelledRedaction {
299 element: config.0.labelled_redaction.clone(),
300 label: label.to_string(),
301 });
302
303 // ## + label + ##
304 Some((node, 2 + label_end + 2))
305 }
306}
src/plugin/mod.rs+24-10
......@@ -1,4 +1,5 @@
11pub mod autolink;
2pub mod clover;
23pub mod comment;
34pub mod frontmatter;
45pub mod inline_tags;
......@@ -8,22 +9,35 @@ pub mod template;
89
910pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
1011
12use crate::CloverExtensions;
1113use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer};
1214use oxc_diagnostics::OxcDiagnostic;
1315
14/// Register all markodown extensions
15pub fn add_all(md: &mut MarkdownIt) {
16/// Register all markodown extensions.
17/// If `markdown_only` is true, skip Marko-specific rules (tags, templates, statements).
18pub fn add_all(md: &mut MarkdownIt, markdown_only: bool, clover_extensions: &Option<CloverExtensions>) {
19 // Always add frontmatter parsing (YAML metadata is standard markdown extension)
1620 md.block.add_rule::<frontmatter::Rule>();
17 md.block.add_rule::<statement::Rule>();
18 md.block.add_rule::<comment::Rule>();
19 md.block.add_rule::<tags::Rule>();
2021
21 md.inline.add_rule::<autolink::Rule>();
22 md.inline.add_rule::<template::Rule>();
23 md.inline.add_rule::<tags::Rule>();
22 if !markdown_only {
23 // Marko-specific block rules
24 md.block.add_rule::<statement::Rule>();
25 md.block.add_rule::<comment::Rule>();
26 md.block.add_rule::<tags::Rule>();
2427
25 // Post-processor for matching inline tag markers
26 inline_tags::add(md);
28 // Marko-specific inline rules
29 md.inline.add_rule::<autolink::Rule>();
30 md.inline.add_rule::<template::Rule>();
31 md.inline.add_rule::<tags::Rule>();
32
33 // Post-processor for matching inline tag markers
34 inline_tags::add(md);
35 }
36
37 // Clover's Q+A extensions (can be used with or without Marko features)
38 if let Some(config) = clover_extensions {
39 clover::add(md, config.clone());
40 }
2741}
2842
2943/// An AST node representing a JavaScript error
src/wasm.rs+13-1
......@@ -1,4 +1,4 @@
1use crate::{ComponentImports, OutputFormat};
1use crate::{CloverExtensions, ComponentImports, OutputFormat};
22use oxc_diagnostics::OxcDiagnostic;
33use serde::Serialize;
44use wasm_bindgen::prelude::*;
......@@ -102,6 +102,8 @@ pub fn transform(
102102 layout_import: Option<String>,
103103 component_imports: JsValue,
104104 self_path: Option<String>,
105 markdown_only: Option<bool>,
106 clover_extensions: JsValue,
105107) -> JsValue {
106108 // Deserialize component_imports from JsValue (can be undefined/null)
107109 let component_imports: Option<ComponentImports> =
......@@ -111,12 +113,22 @@ pub fn transform(
111113 serde_wasm_bindgen::from_value(component_imports).ok()
112114 };
113115
116 // Deserialize clover_extensions from JsValue (can be undefined/null)
117 let clover_extensions: Option<CloverExtensions> =
118 if clover_extensions.is_undefined() || clover_extensions.is_null() {
119 None
120 } else {
121 serde_wasm_bindgen::from_value(clover_extensions).ok()
122 };
123
114124 match crate::transform(
115125 src,
116126 force_format,
117127 layout_import,
118128 component_imports,
119129 self_path,
130 markdown_only.unwrap_or(false),
131 clover_extensions,
120132 ) {
121133 Ok(output) => serde_wasm_bindgen::to_value(&TransformResult {
122134 text: Some(output.text),
tests/fixtures.rs+4
......@@ -36,6 +36,8 @@ fn run_fixture_with_options(
3636 layout.map(|s| s.to_string()),
3737 component_imports.clone(),
3838 self_path.map(|s| s.to_string()),
39 false,
40 None,
3941 )
4042 .unwrap();
4143
......@@ -63,6 +65,8 @@ fn run_fixture_with_options(
6365 layout.map(|s| s.to_string()),
6466 component_imports.clone(),
6567 self_path.map(|s| s.to_string()),
68 false,
69 None,
6670 )
6771 .unwrap();
6872
tests/fixtures/23-outline-extracting.marko+1
......@@ -11,6 +11,7 @@ good night
1111<define/Heading_4__markodown__>
1212rain <strong>time</strong>
1313</>
14
1415<Layout__markodown__ module=null outline=[
1516 { level: 1, id: 'good-morning', content: Heading_1__markodown__ },
1617 { level: 2, id: 'good-night', content: Heading_2__markodown__ },