| author | |
| committer | |
| log | f6677db2d5545e8d8b2ebc12ee2bdef114192798 |
| tree | 2e82d7bf9bfe56615c6e9dc03bb96b83dbed5807 |
| parent | c0f040a1b3c5f865374e3027102048483ec0c0b2 |
| signature |
closes #118 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`. |
| 33 | 33 | ## `lib/`: javascript package |
| 34 | 34 | |
| 35 | 35 | published to jsr and then to npm. this is a thin wrapper on top of wasm-bindgen, |
| 36 | with plugins for the web ecosystem! | |
| 36 | with plugins for the web ecosystem! you can build the wasm code with | |
| 37 | `sh wasm.sh`. | |
| 37 | 38 | |
| 38 | 39 | - `@clo/markodown` - primary import (`lib/mod.ts`) |
| 39 | 40 | - `@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() { |
| 14 | 14 | } |
| 15 | 15 | ``` |
| 16 | 16 | |
| 17 | ## photos of my favorite people | |
| 17 | ## photos of my *favorite people* | |
| 18 | 18 | |
| 19 | 19 | i love using Marko components because they're extremely concise to write. |
| 20 | 20 | a 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! |
| 28 | 28 | <@img src="IMG_4838.jpeg" w=2 /> |
| 29 | 29 | <@img src="IMG_4839.jpeg" w=2 align="top" /> |
| 30 | 30 | <@img src="IMG_4833.jpeg" h=2 /> |
| 31 | <@img src="IMG_4832.jpeg" /> | |
| 32 | <@img src="IMG_4833.jpeg" /> | |
| 31 | 33 | </> |
| 32 | 34 | |
| 33 | 35 | ## in conclusion |
examples/marko-run/src/routes/+layout.marko-3| ... | ... | @@ -21,9 +21,6 @@ |
| 21 | 21 | color: #fff; |
| 22 | 22 | background: #15151e; |
| 23 | 23 | } |
| 24 | code { | |
| 25 | color: #fc0; | |
| 26 | } | |
| 27 | 24 | a { |
| 28 | 25 | color: #09c; |
| 29 | 26 | } |
examples/marko-run/src/tags/markdown-layout.marko+8-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | export interface Header { |
| 2 | 2 | level: 1 | 2 | 3 | 4 | 5 | 6; |
| 3 | 3 | id: string; |
| 4 | html: string; | |
| 4 | content: Marko.Body; | |
| 5 | 5 | } |
| 6 | 6 | |
| 7 | 7 | export interface Input { |
| ... | ... | @@ -34,7 +34,7 @@ export interface Input { |
| 34 | 34 | </p> |
| 35 | 35 | </> |
| 36 | 36 | <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> | |
| 38 | 38 | </for> |
| 39 | 39 | </aside> |
| 40 | 40 | </div> |
| ... | ... | @@ -48,7 +48,7 @@ export interface Input { |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | .markdown-layout > h1 { |
| 51 | margin: 0 0 2rem 0; | |
| 51 | margin: 0; | |
| 52 | 52 | font-size: 2.5rem; |
| 53 | 53 | font-weight: 700; |
| 54 | 54 | } |
| ... | ... | @@ -110,4 +110,9 @@ export interface Input { |
| 110 | 110 | position: static; |
| 111 | 111 | } |
| 112 | 112 | } |
| 113 | ||
| 114 | .markdown-layout pre { | |
| 115 | background-color: black; | |
| 116 | padding: 1rem; | |
| 117 | } | |
| 113 | 118 | </style> |
| \ No newline at end of file |
examples/marko-run/src/tags/photo-grid.marko+1-1| ... | ... | @@ -35,7 +35,7 @@ export interface Input { |
| 35 | 35 | |
| 36 | 36 | if (x >= cols) { |
| 37 | 37 | x = 0; |
| 38 | y += h; | |
| 38 | y += 1; | |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | return result; |
lib/clover.test.ts created+322| ... | ... | @@ -0,0 +1,322 @@ |
| 1 | import { describe, it, expect } from "vitest"; | |
| 2 | import { transform, CloverQuestionExtensions } from "./mod.ts"; | |
| 3 | ||
| 4 | const cloverExtensions: CloverQuestionExtensions = { | |
| 5 | question: "q-block", | |
| 6 | artifactRef: "artifact-ref", | |
| 7 | questionRef: "question-ref", | |
| 8 | labelledRedaction: "redacted", | |
| 9 | }; | |
| 10 | ||
| 11 | describe("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? | |
| 274 | q: also see #2602142011 | |
| 275 | ||
| 276 | The ##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( |
| 44 | 44 | const result = markodown.transform({ |
| 45 | 45 | ...options, |
| 46 | 46 | source: await fs.readFile(file, "utf-8"), |
| 47 | selfImport: "./" + path.basename(file), | |
| 47 | 48 | }); |
| 48 | 49 | if (!result.success) { |
| 49 | 50 | return { |
| ... | ... | @@ -68,7 +69,7 @@ export default function esbuildPlugin( |
| 68 | 69 | |
| 69 | 70 | // TODO: source maps! |
| 70 | 71 | // TODO: parse marko errors |
| 71 | const { code, meta } = await marko.compile( | |
| 72 | const { code, meta } = await compile( | |
| 72 | 73 | result.text, |
| 73 | 74 | file + ".marko", |
| 74 | 75 | options?.markoOptions ?? {}, |
lib/mod.test.ts+4-4| ... | ... | @@ -39,7 +39,7 @@ describe("transform", () => { |
| 39 | 39 | const error = result.errors[0]; |
| 40 | 40 | expect(error.message).toContain("Unclosed tag"); |
| 41 | 41 | expect(error.line).toBe(1); |
| 42 | expect(error.column).toBe(2); // after '<' | |
| 42 | expect(error.column).toBe(1); // at '<' | |
| 43 | 43 | } |
| 44 | 44 | }); |
| 45 | 45 | |
| ... | ... | @@ -117,9 +117,9 @@ describe("transform", () => { |
| 117 | 117 | if (!result.success) { |
| 118 | 118 | const error = result.errors[0]; |
| 119 | 119 | 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) | |
| 123 | 123 | expect(error.labels[1].width).toBe(4); |
| 124 | 124 | } |
| 125 | 125 | }); |
lib/mod.ts+68-14| ... | ... | @@ -5,20 +5,6 @@ wasm.initSync({ module: bytes }); |
| 5 | 5 | |
| 6 | 6 | type Transformed = Success | Failure; |
| 7 | 7 | |
| 8 | /** Configuration for replacing built-in elements with custom components. */ | |
| 9 | export 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 | ||
| 22 | 8 | export interface TransformOptions { |
| 23 | 9 | source: string; |
| 24 | 10 | /** |
| ... | ... | @@ -30,8 +16,73 @@ export interface TransformOptions { |
| 30 | 16 | format?: Array<"marko" | "html">; |
| 31 | 17 | /** Wraps the component in another component. Enables Table of Contents generation */ |
| 32 | 18 | layoutImport?: string; |
| 19 | /** Import path for the module itself, passed to the layout as `module` */ | |
| 20 | selfImport?: string; | |
| 33 | 21 | /** Replace built-in elements with custom components */ |
| 34 | 22 | 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 */ | |
| 38 | export 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 | */ | |
| 60 | export 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; | |
| 35 | 86 | } |
| 36 | 87 | |
| 37 | 88 | export type OutputFormat = "marko" | "html"; |
| ... | ... | @@ -86,5 +137,8 @@ export function transform(options: TransformOptions): Transformed { |
| 86 | 137 | forceFormat, |
| 87 | 138 | options?.layoutImport, |
| 88 | 139 | options?.componentImports, |
| 140 | options?.selfImport, | |
| 141 | options?.markdownOnly, | |
| 142 | options?.cloverExtensions, | |
| 89 | 143 | ); |
| 90 | 144 | } |
lib/rollup.ts+5-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as markodown from "./mod"; |
| 2 | 2 | import * as marko from "@marko/compiler"; |
| 3 | 3 | import type * as rollup from "rollup"; |
| 4 | import * as path from "node:path"; | |
| 4 | 5 | |
| 5 | 6 | export type RollupPluginOptions = |
| 6 | 7 | & Omit<markodown.TransformOptions, "source" | "format"> |
| ... | ... | @@ -15,7 +16,7 @@ export type RollupPluginOptions = |
| 15 | 16 | export default function rollupPlugin( |
| 16 | 17 | options: RollupPluginOptions = {}, |
| 17 | 18 | ): rollup.Plugin { |
| 18 | const { compileSync } = options.marko ?? marko; | |
| 19 | const { compile } = options.marko ?? marko; | |
| 19 | 20 | return { |
| 20 | 21 | name: "@clo/markodown/rollup.ts", |
| 21 | 22 | load: { |
| ... | ... | @@ -26,6 +27,7 @@ export default function rollupPlugin( |
| 26 | 27 | const result = markodown.transform({ |
| 27 | 28 | ...options, |
| 28 | 29 | source, |
| 30 | selfImport: "./" + path.basename(id), | |
| 29 | 31 | }); |
| 30 | 32 | |
| 31 | 33 | if (!result.success) { |
| ... | ... | @@ -45,7 +47,8 @@ export default function rollupPlugin( |
| 45 | 47 | console.log(result); |
| 46 | 48 | |
| 47 | 49 | // TODO: source maps! |
| 48 | const { code, meta } = compileSync( | |
| 50 | // TODO: parse marko errors | |
| 51 | const { code, meta } = await compile( | |
| 49 | 52 | result.text, |
| 50 | 53 | id + ".marko", |
| 51 | 54 | { |
src/component_transforms.rs+1-1| ... | ... | @@ -129,7 +129,7 @@ impl NodeValue for CodeBlockComponentNode { |
| 129 | 129 | fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang))); |
| 130 | 130 | } |
| 131 | 131 | if let Some(meta) = &self.meta { |
| 132 | fmt.text_raw(&format!(" meta=\"{}\"", escape_attr(meta))); | |
| 132 | fmt.text_raw(&format!(" {}", meta)); | |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | 135 | fmt.text_raw(">\n"); |
src/lib.rs+24-4| ... | ... | @@ -32,6 +32,21 @@ pub struct ComponentImports { |
| 32 | 32 | pub blockquote: Option<String>, |
| 33 | 33 | } |
| 34 | 34 | |
| 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")] | |
| 39 | pub 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 | ||
| 35 | 50 | pub struct Output { |
| 36 | 51 | pub text: String, |
| 37 | 52 | pub format: OutputFormat, |
| ... | ... | @@ -51,6 +66,8 @@ pub fn transform( |
| 51 | 66 | layout_import: Option<String>, |
| 52 | 67 | component_imports: Option<ComponentImports>, |
| 53 | 68 | self_import: Option<String>, |
| 69 | markdown_only: bool, | |
| 70 | clover_extensions: Option<CloverExtensions>, | |
| 54 | 71 | ) -> Result<Output, Vec<OxcDiagnostic>> { |
| 55 | 72 | // Pre-process to extract preamble (blank lines, imports) and frontmatter |
| 56 | 73 | // This is needed because markdown-it skips blank lines before running block rules |
| ... | ... | @@ -66,7 +83,7 @@ pub fn transform( |
| 66 | 83 | |
| 67 | 84 | let md = &mut markdown_it::MarkdownIt::new(); |
| 68 | 85 | |
| 69 | plugin::add_all(md); | |
| 86 | plugin::add_all(md, markdown_only, &clover_extensions); | |
| 70 | 87 | markdown_it::plugins::cmark::add(md); |
| 71 | 88 | markdown_it::plugins::extra::add(md); |
| 72 | 89 | |
| ... | ... | @@ -92,8 +109,11 @@ pub fn transform( |
| 92 | 109 | |
| 93 | 110 | // Determine output format |
| 94 | 111 | // componentImports forces Marko format since we add imports |
| 112 | // markdown_only mode defaults to HTML since no Marko features are parsed | |
| 95 | 113 | 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) { | |
| 97 | 117 | OutputFormat::Marko |
| 98 | 118 | } else { |
| 99 | 119 | OutputFormat::Html |
| ... | ... | @@ -156,11 +176,11 @@ pub fn transform( |
| 156 | 176 | |
| 157 | 177 | if let Some(self_path) = self_import { |
| 158 | 178 | 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}</>" | |
| 160 | 180 | ); |
| 161 | 181 | } else { |
| 162 | 182 | 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}</>" | |
| 164 | 184 | ); |
| 165 | 185 | } |
| 166 | 186 | } else { |
src/main.rs+13-1| ... | ... | @@ -48,6 +48,10 @@ struct Cli { |
| 48 | 48 | /// Replace blockquote elements with a custom component |
| 49 | 49 | #[arg(long)] |
| 50 | 50 | blockquote_import: Option<String>, |
| 51 | ||
| 52 | /// Disable all Marko extensions, treating input as pure Markdown | |
| 53 | #[arg(long)] | |
| 54 | markdown_only: bool, | |
| 51 | 55 | } |
| 52 | 56 | |
| 53 | 57 | fn main() { |
| ... | ... | @@ -87,7 +91,15 @@ fn main() { |
| 87 | 91 | } |
| 88 | 92 | }; |
| 89 | 93 | |
| 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 | ) { | |
| 91 | 103 | Err(errors) => { |
| 92 | 104 | let handler = GraphicalReportHandler::new(); |
| 93 | 105 | 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 | ||
| 12 | use crate::CloverExtensions; | |
| 13 | use markdown_it::parser::block::{BlockRule, BlockState}; | |
| 14 | use markdown_it::parser::extset::MarkdownItExt; | |
| 15 | use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState}; | |
| 16 | use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; | |
| 17 | ||
| 18 | /// Stored in MarkdownIt.ext for runtime access | |
| 19 | #[derive(Debug, Clone)] | |
| 20 | pub struct CloverConfig(pub CloverExtensions); | |
| 21 | ||
| 22 | impl MarkdownItExt for CloverConfig {} | |
| 23 | ||
| 24 | /// Register clover extension rules | |
| 25 | pub 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)] | |
| 39 | pub struct QuestionBlock { | |
| 40 | pub element: String, | |
| 41 | /// Whether this question follows another question (needs <br /> prefix) | |
| 42 | pub needs_break: bool, | |
| 43 | } | |
| 44 | ||
| 45 | impl 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 | ||
| 57 | pub struct QuestionBlockRule; | |
| 58 | ||
| 59 | impl 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)] | |
| 100 | pub struct RawHtmlBlock { | |
| 101 | pub content: String, | |
| 102 | } | |
| 103 | ||
| 104 | impl 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 | ||
| 111 | pub struct HtmlBlockRule; | |
| 112 | ||
| 113 | impl 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)] | |
| 141 | pub struct ArtifactRef { | |
| 142 | pub element: String, | |
| 143 | pub slug: String, | |
| 144 | } | |
| 145 | ||
| 146 | impl 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 | ||
| 152 | pub struct ArtifactRefRule; | |
| 153 | ||
| 154 | impl 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)] | |
| 195 | pub struct QuestionRef { | |
| 196 | pub element: String, | |
| 197 | pub id: String, | |
| 198 | } | |
| 199 | ||
| 200 | impl 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 | ||
| 206 | pub struct QuestionRefRule; | |
| 207 | ||
| 208 | impl 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)] | |
| 256 | pub struct LabelledRedaction { | |
| 257 | pub element: String, | |
| 258 | pub label: String, | |
| 259 | } | |
| 260 | ||
| 261 | impl 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 | ||
| 267 | pub struct LabelledRedactionRule; | |
| 268 | ||
| 269 | impl 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 @@ |
| 1 | 1 | pub mod autolink; |
| 2 | pub mod clover; | |
| 2 | 3 | pub mod comment; |
| 3 | 4 | pub mod frontmatter; |
| 4 | 5 | pub mod inline_tags; |
| ... | ... | @@ -8,22 +9,35 @@ pub mod template; |
| 8 | 9 | |
| 9 | 10 | pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; |
| 10 | 11 | |
| 12 | use crate::CloverExtensions; | |
| 11 | 13 | use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; |
| 12 | 14 | use oxc_diagnostics::OxcDiagnostic; |
| 13 | 15 | |
| 14 | /// Register all markodown extensions | |
| 15 | pub fn add_all(md: &mut MarkdownIt) { | |
| 16 | /// Register all markodown extensions. | |
| 17 | /// If `markdown_only` is true, skip Marko-specific rules (tags, templates, statements). | |
| 18 | pub fn add_all(md: &mut MarkdownIt, markdown_only: bool, clover_extensions: &Option<CloverExtensions>) { | |
| 19 | // Always add frontmatter parsing (YAML metadata is standard markdown extension) | |
| 16 | 20 | 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>(); | |
| 20 | 21 | |
| 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>(); | |
| 24 | 27 | |
| 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 | } | |
| 27 | 41 | } |
| 28 | 42 | |
| 29 | 43 | /// An AST node representing a JavaScript error |
src/wasm.rs+13-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use crate::{ComponentImports, OutputFormat}; | |
| 1 | use crate::{CloverExtensions, ComponentImports, OutputFormat}; | |
| 2 | 2 | use oxc_diagnostics::OxcDiagnostic; |
| 3 | 3 | use serde::Serialize; |
| 4 | 4 | use wasm_bindgen::prelude::*; |
| ... | ... | @@ -102,6 +102,8 @@ pub fn transform( |
| 102 | 102 | layout_import: Option<String>, |
| 103 | 103 | component_imports: JsValue, |
| 104 | 104 | self_path: Option<String>, |
| 105 | markdown_only: Option<bool>, | |
| 106 | clover_extensions: JsValue, | |
| 105 | 107 | ) -> JsValue { |
| 106 | 108 | // Deserialize component_imports from JsValue (can be undefined/null) |
| 107 | 109 | let component_imports: Option<ComponentImports> = |
| ... | ... | @@ -111,12 +113,22 @@ pub fn transform( |
| 111 | 113 | serde_wasm_bindgen::from_value(component_imports).ok() |
| 112 | 114 | }; |
| 113 | 115 | |
| 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 | ||
| 114 | 124 | match crate::transform( |
| 115 | 125 | src, |
| 116 | 126 | force_format, |
| 117 | 127 | layout_import, |
| 118 | 128 | component_imports, |
| 119 | 129 | self_path, |
| 130 | markdown_only.unwrap_or(false), | |
| 131 | clover_extensions, | |
| 120 | 132 | ) { |
| 121 | 133 | Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { |
| 122 | 134 | text: Some(output.text), |
tests/fixtures.rs+4| ... | ... | @@ -36,6 +36,8 @@ fn run_fixture_with_options( |
| 36 | 36 | layout.map(|s| s.to_string()), |
| 37 | 37 | component_imports.clone(), |
| 38 | 38 | self_path.map(|s| s.to_string()), |
| 39 | false, | |
| 40 | None, | |
| 39 | 41 | ) |
| 40 | 42 | .unwrap(); |
| 41 | 43 | |
| ... | ... | @@ -63,6 +65,8 @@ fn run_fixture_with_options( |
| 63 | 65 | layout.map(|s| s.to_string()), |
| 64 | 66 | component_imports.clone(), |
| 65 | 67 | self_path.map(|s| s.to_string()), |
| 68 | false, | |
| 69 | None, | |
| 66 | 70 | ) |
| 67 | 71 | .unwrap(); |
| 68 | 72 |
tests/fixtures/23-outline-extracting.marko+1| ... | ... | @@ -11,6 +11,7 @@ good night |
| 11 | 11 | <define/Heading_4__markodown__> |
| 12 | 12 | rain <strong>time</strong> |
| 13 | 13 | </> |
| 14 | ||
| 14 | 15 | <Layout__markodown__ module=null outline=[ |
| 15 | 16 | { level: 1, id: 'good-morning', content: Heading_1__markodown__ }, |
| 16 | 17 | { level: 2, id: 'good-night', content: Heading_2__markodown__ }, |