| 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,7 +33,8 @@ these rules are configured in `plugin/mod.rs` in `add_all`. |
| 33 | ## `lib/`: javascript package | 33 | ## `lib/`: javascript package |
| 34 | 34 | ||
| 35 | published to jsr and then to npm. this is a thin wrapper on top of wasm-bindgen, | 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 | - `@clo/markodown` - primary import (`lib/mod.ts`) | 39 | - `@clo/markodown` - primary import (`lib/mod.ts`) |
| 39 | - `@clo/markodown/rollup.ts` - rollup or vite plugin (`lib/rollup.ts`) | 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,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 | i love using Marko components because they're extremely concise to write. | 19 | i love using Marko components because they're extremely concise to write. |
| 20 | a lot less brace hell for non-string attributes, and more treats! | 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,6 +28,8 @@ a lot less brace hell for non-string attributes, and more treats! |
| 28 | <@img src="IMG_4838.jpeg" w=2 /> | 28 | <@img src="IMG_4838.jpeg" w=2 /> |
| 29 | <@img src="IMG_4839.jpeg" w=2 align="top" /> | 29 | <@img src="IMG_4839.jpeg" w=2 align="top" /> |
| 30 | <@img src="IMG_4833.jpeg" h=2 /> | 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 | ## in conclusion | 35 | ## in conclusion |
examples/marko-run/src/routes/+layout.marko-3| ... | @@ -21,9 +21,6 @@ | ... | @@ -21,9 +21,6 @@ |
| 21 | color: #fff; | 21 | color: #fff; |
| 22 | background: #15151e; | 22 | background: #15151e; |
| 23 | } | 23 | } |
| 24 | code { | ||
| 25 | color: #fc0; | ||
| 26 | } | ||
| 27 | a { | 24 | a { |
| 28 | color: #09c; | 25 | color: #09c; |
| 29 | } | 26 | } |
examples/marko-run/src/tags/markdown-layout.marko+8-3| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | export interface Header { | 1 | export interface Header { |
| 2 | level: 1 | 2 | 3 | 4 | 5 | 6; | 2 | level: 1 | 2 | 3 | 4 | 5 | 6; |
| 3 | id: string; | 3 | id: string; |
| 4 | html: string; | 4 | content: Marko.Body; |
| 5 | } | 5 | } |
| 6 | 6 | ||
| 7 | export interface Input { | 7 | export interface Input { |
| ... | @@ -34,7 +34,7 @@ export interface Input { | ... | @@ -34,7 +34,7 @@ export interface Input { |
| 34 | </p> | 34 | </p> |
| 35 | </> | 35 | </> |
| 36 | <for|header| of=input.outline> | 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 | </for> | 38 | </for> |
| 39 | </aside> | 39 | </aside> |
| 40 | </div> | 40 | </div> |
| ... | @@ -48,7 +48,7 @@ export interface Input { | ... | @@ -48,7 +48,7 @@ export interface Input { |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | .markdown-layout > h1 { | 50 | .markdown-layout > h1 { |
| 51 | margin: 0 0 2rem 0; | 51 | margin: 0; |
| 52 | font-size: 2.5rem; | 52 | font-size: 2.5rem; |
| 53 | font-weight: 700; | 53 | font-weight: 700; |
| 54 | } | 54 | } |
| ... | @@ -110,4 +110,9 @@ export interface Input { | ... | @@ -110,4 +110,9 @@ export interface Input { |
| 110 | position: static; | 110 | position: static; |
| 111 | } | 111 | } |
| 112 | } | 112 | } |
| 113 | |||
| 114 | .markdown-layout pre { | ||
| 115 | background-color: black; | ||
| 116 | padding: 1rem; | ||
| 117 | } | ||
| 113 | </style> | 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,7 +35,7 @@ export interface Input { |
| 35 | 35 | ||
| 36 | if (x >= cols) { | 36 | if (x >= cols) { |
| 37 | x = 0; | 37 | x = 0; |
| 38 | y += h; | 38 | y += 1; |
| 39 | } | 39 | } |
| 40 | } | 40 | } |
| 41 | return result; | 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,6 +44,7 @@ export default function esbuildPlugin( |
| 44 | const result = markodown.transform({ | 44 | const result = markodown.transform({ |
| 45 | ...options, | 45 | ...options, |
| 46 | source: await fs.readFile(file, "utf-8"), | 46 | source: await fs.readFile(file, "utf-8"), |
| 47 | selfImport: "./" + path.basename(file), | ||
| 47 | }); | 48 | }); |
| 48 | if (!result.success) { | 49 | if (!result.success) { |
| 49 | return { | 50 | return { |
| ... | @@ -68,7 +69,7 @@ export default function esbuildPlugin( | ... | @@ -68,7 +69,7 @@ export default function esbuildPlugin( |
| 68 | 69 | ||
| 69 | // TODO: source maps! | 70 | // TODO: source maps! |
| 70 | // TODO: parse marko errors | 71 | // TODO: parse marko errors |
| 71 | const { code, meta } = await marko.compile( | 72 | const { code, meta } = await compile( |
| 72 | result.text, | 73 | result.text, |
| 73 | file + ".marko", | 74 | file + ".marko", |
| 74 | options?.markoOptions ?? {}, | 75 | options?.markoOptions ?? {}, |
lib/mod.test.ts+4-4| ... | @@ -39,7 +39,7 @@ describe("transform", () => { | ... | @@ -39,7 +39,7 @@ describe("transform", () => { |
| 39 | const error = result.errors[0]; | 39 | const error = result.errors[0]; |
| 40 | expect(error.message).toContain("Unclosed tag"); | 40 | expect(error.message).toContain("Unclosed tag"); |
| 41 | expect(error.line).toBe(1); | 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,9 +117,9 @@ describe("transform", () => { |
| 117 | if (!result.success) { | 117 | if (!result.success) { |
| 118 | const error = result.errors[0]; | 118 | const error = result.errors[0]; |
| 119 | expect(error.labels.length).toBe(2); | 119 | expect(error.labels.length).toBe(2); |
| 120 | // "div" has width 3 | 120 | // "<div" has width 4 (includes < for better error highlighting) |
| 121 | expect(error.labels[0].width).toBe(3); | 121 | expect(error.labels[0].width).toBe(4); |
| 122 | // "span" has width 4 | 122 | // "span" has width 4 (close tags only highlight the name) |
| 123 | expect(error.labels[1].width).toBe(4); | 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,20 +5,6 @@ wasm.initSync({ module: bytes }); |
| 5 | 5 | ||
| 6 | type Transformed = Success | Failure; | 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 | export interface TransformOptions { | 8 | export interface TransformOptions { |
| 23 | source: string; | 9 | source: string; |
| 24 | /** | 10 | /** |
| ... | @@ -30,8 +16,73 @@ export interface TransformOptions { | ... | @@ -30,8 +16,73 @@ export interface TransformOptions { |
| 30 | format?: Array<"marko" | "html">; | 16 | format?: Array<"marko" | "html">; |
| 31 | /** Wraps the component in another component. Enables Table of Contents generation */ | 17 | /** Wraps the component in another component. Enables Table of Contents generation */ |
| 32 | layoutImport?: string; | 18 | layoutImport?: string; |
| 19 | /** Import path for the module itself, passed to the layout as `module` */ | ||
| 20 | selfImport?: string; | ||
| 33 | /** Replace built-in elements with custom components */ | 21 | /** Replace built-in elements with custom components */ |
| 34 | componentImports?: ComponentImports; | 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 | export type OutputFormat = "marko" | "html"; | 88 | export type OutputFormat = "marko" | "html"; |
| ... | @@ -86,5 +137,8 @@ export function transform(options: TransformOptions): Transformed { | ... | @@ -86,5 +137,8 @@ export function transform(options: TransformOptions): Transformed { |
| 86 | forceFormat, | 137 | forceFormat, |
| 87 | options?.layoutImport, | 138 | options?.layoutImport, |
| 88 | options?.componentImports, | 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,6 +1,7 @@ |
| 1 | import * as markodown from "./mod"; | 1 | import * as markodown from "./mod"; |
| 2 | import * as marko from "@marko/compiler"; | 2 | import * as marko from "@marko/compiler"; |
| 3 | import type * as rollup from "rollup"; | 3 | import type * as rollup from "rollup"; |
| 4 | import * as path from "node:path"; | ||
| 4 | 5 | ||
| 5 | export type RollupPluginOptions = | 6 | export type RollupPluginOptions = |
| 6 | & Omit<markodown.TransformOptions, "source" | "format"> | 7 | & Omit<markodown.TransformOptions, "source" | "format"> |
| ... | @@ -15,7 +16,7 @@ export type RollupPluginOptions = | ... | @@ -15,7 +16,7 @@ export type RollupPluginOptions = |
| 15 | export default function rollupPlugin( | 16 | export default function rollupPlugin( |
| 16 | options: RollupPluginOptions = {}, | 17 | options: RollupPluginOptions = {}, |
| 17 | ): rollup.Plugin { | 18 | ): rollup.Plugin { |
| 18 | const { compileSync } = options.marko ?? marko; | 19 | const { compile } = options.marko ?? marko; |
| 19 | return { | 20 | return { |
| 20 | name: "@clo/markodown/rollup.ts", | 21 | name: "@clo/markodown/rollup.ts", |
| 21 | load: { | 22 | load: { |
| ... | @@ -26,6 +27,7 @@ export default function rollupPlugin( | ... | @@ -26,6 +27,7 @@ export default function rollupPlugin( |
| 26 | const result = markodown.transform({ | 27 | const result = markodown.transform({ |
| 27 | ...options, | 28 | ...options, |
| 28 | source, | 29 | source, |
| 30 | selfImport: "./" + path.basename(id), | ||
| 29 | }); | 31 | }); |
| 30 | 32 | ||
| 31 | if (!result.success) { | 33 | if (!result.success) { |
| ... | @@ -45,7 +47,8 @@ export default function rollupPlugin( | ... | @@ -45,7 +47,8 @@ export default function rollupPlugin( |
| 45 | console.log(result); | 47 | console.log(result); |
| 46 | 48 | ||
| 47 | // TODO: source maps! | 49 | // TODO: source maps! |
| 48 | const { code, meta } = compileSync( | 50 | // TODO: parse marko errors |
| 51 | const { code, meta } = await compile( | ||
| 49 | result.text, | 52 | result.text, |
| 50 | id + ".marko", | 53 | id + ".marko", |
| 51 | { | 54 | { |
src/component_transforms.rs+1-1| ... | @@ -129,7 +129,7 @@ impl NodeValue for CodeBlockComponentNode { | ... | @@ -129,7 +129,7 @@ impl NodeValue for CodeBlockComponentNode { |
| 129 | fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang))); | 129 | fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang))); |
| 130 | } | 130 | } |
| 131 | if let Some(meta) = &self.meta { | 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 | fmt.text_raw(">\n"); | 135 | fmt.text_raw(">\n"); |
src/lib.rs+24-4| ... | @@ -32,6 +32,21 @@ pub struct ComponentImports { | ... | @@ -32,6 +32,21 @@ pub struct ComponentImports { |
| 32 | pub blockquote: Option<String>, | 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 | pub struct Output { | 50 | pub struct Output { |
| 36 | pub text: String, | 51 | pub text: String, |
| 37 | pub format: OutputFormat, | 52 | pub format: OutputFormat, |
| ... | @@ -51,6 +66,8 @@ pub fn transform( | ... | @@ -51,6 +66,8 @@ pub fn transform( |
| 51 | layout_import: Option<String>, | 66 | layout_import: Option<String>, |
| 52 | component_imports: Option<ComponentImports>, | 67 | component_imports: Option<ComponentImports>, |
| 53 | self_import: Option<String>, | 68 | self_import: Option<String>, |
| 69 | markdown_only: bool, | ||
| 70 | clover_extensions: Option<CloverExtensions>, | ||
| 54 | ) -> Result<Output, Vec<OxcDiagnostic>> { | 71 | ) -> Result<Output, Vec<OxcDiagnostic>> { |
| 55 | // Pre-process to extract preamble (blank lines, imports) and frontmatter | 72 | // Pre-process to extract preamble (blank lines, imports) and frontmatter |
| 56 | // This is needed because markdown-it skips blank lines before running block rules | 73 | // This is needed because markdown-it skips blank lines before running block rules |
| ... | @@ -66,7 +83,7 @@ pub fn transform( | ... | @@ -66,7 +83,7 @@ pub fn transform( |
| 66 | 83 | ||
| 67 | let md = &mut markdown_it::MarkdownIt::new(); | 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 | markdown_it::plugins::cmark::add(md); | 87 | markdown_it::plugins::cmark::add(md); |
| 71 | markdown_it::plugins::extra::add(md); | 88 | markdown_it::plugins::extra::add(md); |
| 72 | 89 | ||
| ... | @@ -92,8 +109,11 @@ pub fn transform( | ... | @@ -92,8 +109,11 @@ pub fn transform( |
| 92 | 109 | ||
| 93 | // Determine output format | 110 | // Determine output format |
| 94 | // componentImports forces Marko format since we add imports | 111 | // componentImports forces Marko format since we add imports |
| 112 | // markdown_only mode defaults to HTML since no Marko features are parsed | ||
| 95 | let format = force.unwrap_or_else(|| { | 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 | OutputFormat::Marko | 117 | OutputFormat::Marko |
| 98 | } else { | 118 | } else { |
| 99 | OutputFormat::Html | 119 | OutputFormat::Html |
| ... | @@ -156,11 +176,11 @@ pub fn transform( | ... | @@ -156,11 +176,11 @@ pub fn transform( |
| 156 | 176 | ||
| 157 | if let Some(self_path) = self_import { | 177 | if let Some(self_path) = self_import { |
| 158 | text = format!( | 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 | } else { | 181 | } else { |
| 162 | text = format!( | 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 | } else { | 186 | } else { |
src/main.rs+13-1| ... | @@ -48,6 +48,10 @@ struct Cli { | ... | @@ -48,6 +48,10 @@ struct Cli { |
| 48 | /// Replace blockquote elements with a custom component | 48 | /// Replace blockquote elements with a custom component |
| 49 | #[arg(long)] | 49 | #[arg(long)] |
| 50 | blockquote_import: Option<String>, | 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 | fn main() { | 57 | fn main() { |
| ... | @@ -87,7 +91,15 @@ 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 | Err(errors) => { | 103 | Err(errors) => { |
| 92 | let handler = GraphicalReportHandler::new(); | 104 | let handler = GraphicalReportHandler::new(); |
| 93 | for error in &errors { | 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,4 +1,5 @@ |
| 1 | pub mod autolink; | 1 | pub mod autolink; |
| 2 | pub mod clover; | ||
| 2 | pub mod comment; | 3 | pub mod comment; |
| 3 | pub mod frontmatter; | 4 | pub mod frontmatter; |
| 4 | pub mod inline_tags; | 5 | pub mod inline_tags; |
| ... | @@ -8,22 +9,35 @@ pub mod template; | ... | @@ -8,22 +9,35 @@ pub mod template; |
| 8 | 9 | ||
| 9 | pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; | 10 | pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; |
| 10 | 11 | ||
| 12 | use crate::CloverExtensions; | ||
| 11 | use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; | 13 | use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; |
| 12 | use oxc_diagnostics::OxcDiagnostic; | 14 | use oxc_diagnostics::OxcDiagnostic; |
| 13 | 15 | ||
| 14 | /// Register all markodown extensions | 16 | /// Register all markodown extensions. |
| 15 | pub fn add_all(md: &mut MarkdownIt) { | 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 | md.block.add_rule::<frontmatter::Rule>(); | 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 | if !markdown_only { |
| 22 | md.inline.add_rule::<template::Rule>(); | 23 | // Marko-specific block rules |
| 23 | md.inline.add_rule::<tags::Rule>(); | 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 | 28 | // Marko-specific inline rules |
| 26 | inline_tags::add(md); | 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 | /// An AST node representing a JavaScript error | 43 | /// An AST node representing a JavaScript error |
src/wasm.rs+13-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | use crate::{ComponentImports, OutputFormat}; | 1 | use crate::{CloverExtensions, ComponentImports, OutputFormat}; |
| 2 | use oxc_diagnostics::OxcDiagnostic; | 2 | use oxc_diagnostics::OxcDiagnostic; |
| 3 | use serde::Serialize; | 3 | use serde::Serialize; |
| 4 | use wasm_bindgen::prelude::*; | 4 | use wasm_bindgen::prelude::*; |
| ... | @@ -102,6 +102,8 @@ pub fn transform( | ... | @@ -102,6 +102,8 @@ pub fn transform( |
| 102 | layout_import: Option<String>, | 102 | layout_import: Option<String>, |
| 103 | component_imports: JsValue, | 103 | component_imports: JsValue, |
| 104 | self_path: Option<String>, | 104 | self_path: Option<String>, |
| 105 | markdown_only: Option<bool>, | ||
| 106 | clover_extensions: JsValue, | ||
| 105 | ) -> JsValue { | 107 | ) -> JsValue { |
| 106 | // Deserialize component_imports from JsValue (can be undefined/null) | 108 | // Deserialize component_imports from JsValue (can be undefined/null) |
| 107 | let component_imports: Option<ComponentImports> = | 109 | let component_imports: Option<ComponentImports> = |
| ... | @@ -111,12 +113,22 @@ pub fn transform( | ... | @@ -111,12 +113,22 @@ pub fn transform( |
| 111 | serde_wasm_bindgen::from_value(component_imports).ok() | 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 | match crate::transform( | 124 | match crate::transform( |
| 115 | src, | 125 | src, |
| 116 | force_format, | 126 | force_format, |
| 117 | layout_import, | 127 | layout_import, |
| 118 | component_imports, | 128 | component_imports, |
| 119 | self_path, | 129 | self_path, |
| 130 | markdown_only.unwrap_or(false), | ||
| 131 | clover_extensions, | ||
| 120 | ) { | 132 | ) { |
| 121 | Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { | 133 | Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { |
| 122 | text: Some(output.text), | 134 | text: Some(output.text), |
tests/fixtures.rs+4| ... | @@ -36,6 +36,8 @@ fn run_fixture_with_options( | ... | @@ -36,6 +36,8 @@ fn run_fixture_with_options( |
| 36 | layout.map(|s| s.to_string()), | 36 | layout.map(|s| s.to_string()), |
| 37 | component_imports.clone(), | 37 | component_imports.clone(), |
| 38 | self_path.map(|s| s.to_string()), | 38 | self_path.map(|s| s.to_string()), |
| 39 | false, | ||
| 40 | None, | ||
| 39 | ) | 41 | ) |
| 40 | .unwrap(); | 42 | .unwrap(); |
| 41 | 43 | ||
| ... | @@ -63,6 +65,8 @@ fn run_fixture_with_options( | ... | @@ -63,6 +65,8 @@ fn run_fixture_with_options( |
| 63 | layout.map(|s| s.to_string()), | 65 | layout.map(|s| s.to_string()), |
| 64 | component_imports.clone(), | 66 | component_imports.clone(), |
| 65 | self_path.map(|s| s.to_string()), | 67 | self_path.map(|s| s.to_string()), |
| 68 | false, | ||
| 69 | None, | ||
| 66 | ) | 70 | ) |
| 67 | .unwrap(); | 71 | .unwrap(); |
| 68 | 72 |
tests/fixtures/23-outline-extracting.marko+1| ... | @@ -11,6 +11,7 @@ good night | ... | @@ -11,6 +11,7 @@ good night |
| 11 | <define/Heading_4__markodown__> | 11 | <define/Heading_4__markodown__> |
| 12 | rain <strong>time</strong> | 12 | rain <strong>time</strong> |
| 13 | </> | 13 | </> |
| 14 | |||
| 14 | <Layout__markodown__ module=null outline=[ | 15 | <Layout__markodown__ module=null outline=[ |
| 15 | { level: 1, id: 'good-morning', content: Heading_1__markodown__ }, | 16 | { level: 1, id: 'good-morning', content: Heading_1__markodown__ }, |
| 16 | { level: 2, id: 'good-night', content: Heading_2__markodown__ }, | 17 | { level: 2, id: 'good-night', content: Heading_2__markodown__ }, |