authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 01:47:44-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 01:47:52-08:00
log1028bc4c2ba5bf5656b06c29ea8ba019f548df05
treedec48b70f43410173f84468ead06af8e4c68d3b5
parentf6677db2d5545e8d8b2ebc12ee2bdef114192798
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

some more meowing


4 files changed, 117 insertions(+), 71 deletions(-)

README.md+5-5
...@@ -17,7 +17,7 @@ leveraging the existing ecosystem....@@ -17,7 +17,7 @@ leveraging the existing ecosystem.
17> - [Install](#install)17> - [Install](#install)
18> - [Components](#components)18> - [Components](#components)
19> - [Outline / Table of Contents](#outline-table-of-contents)19> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#outline-table-of-contents)20> - [Frontmatter](#frontmatter)
21> - [Comments](#comments)21> - [Comments](#comments)
22> - [Paragraph Detection](#paragraph-detection)22> - [Paragraph Detection](#paragraph-detection)
23> - [Static Statements](#static-statements)23> - [Static Statements](#static-statements)
...@@ -73,7 +73,7 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}....@@ -73,7 +73,7 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
7373
74## Install74## Install
7575
76Markdown is distributed on [NPM](http://npmjs.com/markodown) and76Markodown is distributed on [NPM](http://npmjs.com/markodown) and
77[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.77[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7878
79```sh79```sh
...@@ -82,8 +82,8 @@ npx jsr add @clo/markodown...@@ -82,8 +82,8 @@ npx jsr add @clo/markodown
82```82```
8383
84The compiler can be directly used from `transform`, and there are also plugins84The compiler can be directly used from `transform`, and there are also plugins
85for Rollup/Rolldown/Vite and esbuild. For example, configure Markdown with Marko85for Rollup/Rolldown/Vite and esbuild. For example, configure Markodown with
86Run:86Marko Run:
8787
88```ts88```ts
89import marko from "@marko/run/vite";89import marko from "@marko/run/vite";
...@@ -117,7 +117,7 @@ add complex content to your pages....@@ -117,7 +117,7 @@ add complex content to your pages.
117117
118<footer.copyright-info>118<footer.copyright-info>
119 made with love... (c) ${new Date().getFullYear()}119 made with love... (c) ${new Date().getFullYear()}
120</div>120</footer>
121```121```
122122
123[tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags123[tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags
lib/clover.test.ts+51-30
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, it, expect } from "vitest";1import { describe, expect, it } from "vitest";
2import { transform, CloverQuestionExtensions } from "./mod.ts";2import { CloverQuestionExtensions, transform } from "./mod.ts";
33
4const cloverExtensions: CloverQuestionExtensions = {4const cloverExtensions: CloverQuestionExtensions = {
5 question: "q-block",5 question: "q-block",
...@@ -32,9 +32,9 @@ describe("clover extensions", () => {...@@ -32,9 +32,9 @@ describe("clover extensions", () => {
32 });32 });
33 expect(result.success).toBe(true);33 expect(result.success).toBe(true);
34 if (result.success) {34 if (result.success) {
35 expect(result.text).toContain("<br />");35 expect(result.text).toContain(
36 expect(result.text).toContain("<q-block>first question</q-block>");36 "<q-block>first question<br>second question</q-block>",
37 expect(result.text).toContain("<q-block>second question</q-block>");37 );
38 }38 }
39 });39 });
4040
...@@ -73,7 +73,7 @@ describe("clover extensions", () => {...@@ -73,7 +73,7 @@ describe("clover extensions", () => {
73 expect(result.success).toBe(true);73 expect(result.success).toBe(true);
74 if (result.success) {74 if (result.success) {
75 expect(result.text).toContain(75 expect(result.text).toContain(
76 '<custom-element attr="value">content</custom-element>'76 '<custom-element attr="value">content</custom-element>',
77 );77 );
78 }78 }
79 });79 });
...@@ -102,7 +102,7 @@ describe("clover extensions", () => {...@@ -102,7 +102,7 @@ describe("clover extensions", () => {
102 expect(result.success).toBe(true);102 expect(result.success).toBe(true);
103 if (result.success) {103 if (result.success) {
104 expect(result.text).toContain(104 expect(result.text).toContain(
105 "<artifact-ref>its-snowing</artifact-ref>"105 "<artifact-ref>its-snowing</artifact-ref>",
106 );106 );
107 }107 }
108 });108 });
...@@ -117,7 +117,7 @@ describe("clover extensions", () => {...@@ -117,7 +117,7 @@ describe("clover extensions", () => {
117 if (result.success) {117 if (result.success) {
118 expect(result.text).toContain("<artifact-ref>first-one</artifact-ref>");118 expect(result.text).toContain("<artifact-ref>first-one</artifact-ref>");
119 expect(result.text).toContain(119 expect(result.text).toContain(
120 "<artifact-ref>second-one</artifact-ref>"120 "<artifact-ref>second-one</artifact-ref>",
121 );121 );
122 }122 }
123 });123 });
...@@ -131,7 +131,7 @@ describe("clover extensions", () => {...@@ -131,7 +131,7 @@ describe("clover extensions", () => {
131 expect(result.success).toBe(true);131 expect(result.success).toBe(true);
132 if (result.success) {132 if (result.success) {
133 expect(result.text).toContain(133 expect(result.text).toContain(
134 "<artifact-ref>valid-slug123</artifact-ref>"134 "<artifact-ref>valid-slug123</artifact-ref>",
135 );135 );
136 expect(result.text).toContain("@INVALID");136 expect(result.text).toContain("@INVALID");
137 expect(result.text).not.toContain("<artifact-ref>INVALID");137 expect(result.text).not.toContain("<artifact-ref>INVALID");
...@@ -161,7 +161,7 @@ describe("clover extensions", () => {...@@ -161,7 +161,7 @@ describe("clover extensions", () => {
161 expect(result.success).toBe(true);161 expect(result.success).toBe(true);
162 if (result.success) {162 if (result.success) {
163 expect(result.text).toContain(163 expect(result.text).toContain(
164 "<question-ref>2602142011</question-ref>"164 "<question-ref>2602142011</question-ref>",
165 );165 );
166 }166 }
167 });167 });
...@@ -175,7 +175,7 @@ describe("clover extensions", () => {...@@ -175,7 +175,7 @@ describe("clover extensions", () => {
175 expect(result.success).toBe(true);175 expect(result.success).toBe(true);
176 if (result.success) {176 if (result.success) {
177 expect(result.text).toContain(177 expect(result.text).toContain(
178 "<question-ref>260214201112</question-ref>"178 "<question-ref>260214201112</question-ref>",
179 );179 );
180 }180 }
181 });181 });
...@@ -205,31 +205,31 @@ describe("clover extensions", () => {...@@ -205,31 +205,31 @@ describe("clover extensions", () => {
205 expect(result.success).toBe(true);205 expect(result.success).toBe(true);
206 if (result.success) {206 if (result.success) {
207 expect(result.text).toContain(207 expect(result.text).toContain(
208 "<question-ref>2602142011</question-ref>"208 "<question-ref>2602142011</question-ref>",
209 );209 );
210 expect(result.text).toContain(210 expect(result.text).toContain(
211 "<question-ref>2602142012</question-ref>"211 "<question-ref>2602142012</question-ref>",
212 );212 );
213 }213 }
214 });214 });
215 });215 });
216216
217 describe("labelled redactions", () => {217 describe("labelled redactions", () => {
218 it("transforms ##label## to redaction element", () => {218 it("transforms #label# to redaction element", () => {
219 const result = transform({219 const result = transform({
220 source: "the ##secret name## is hidden",220 source: "the #name# is hidden",
221 markdownOnly: true,221 markdownOnly: true,
222 cloverExtensions,222 cloverExtensions,
223 });223 });
224 expect(result.success).toBe(true);224 expect(result.success).toBe(true);
225 if (result.success) {225 if (result.success) {
226 expect(result.text).toContain("<redacted>secret name</redacted>");226 expect(result.text).toContain("<redacted>name</redacted>");
227 }227 }
228 });228 });
229229
230 it("handles hyphens and underscores in labels", () => {230 it("handles hyphens and underscores in labels", () => {
231 const result = transform({231 const result = transform({
232 source: "##my-secret_label##",232 source: "#my-secret_label#",
233 markdownOnly: true,233 markdownOnly: true,
234 cloverExtensions,234 cloverExtensions,
235 });235 });
...@@ -241,21 +241,21 @@ describe("clover extensions", () => {...@@ -241,21 +241,21 @@ describe("clover extensions", () => {
241241
242 it("does not match empty labels", () => {242 it("does not match empty labels", () => {
243 const result = transform({243 const result = transform({
244 source: "text #### more",244 source: "text ## ### #### more",
245 markdownOnly: true,245 markdownOnly: true,
246 cloverExtensions,246 cloverExtensions,
247 });247 });
248 expect(result.success).toBe(true);248 expect(result.success).toBe(true);
249 if (result.success) {249 if (result.success) {
250 expect(result.text).not.toContain("<redacted></redacted>");250 expect(result.text).not.toContain("<redacted>");
251 // #### in the middle of text stays as-is251 // ## in the middle of text stays as-is
252 expect(result.text).toContain("####");252 expect(result.text).toContain("##");
253 }253 }
254 });254 });
255255
256 it("handles multiple redactions", () => {256 it("handles multiple redactions", () => {
257 const result = transform({257 const result = transform({
258 source: "##first## and ##second##",258 source: "#first# and #second#",
259 markdownOnly: true,259 markdownOnly: true,
260 cloverExtensions,260 cloverExtensions,
261 });261 });
...@@ -273,7 +273,7 @@ describe("clover extensions", () => {...@@ -273,7 +273,7 @@ describe("clover extensions", () => {
273 source: `q: what is @some-artifact about?273 source: `q: what is @some-artifact about?
274q: also see #2602142011274q: also see #2602142011
275275
276The ##secret## is revealed.276The #secret# is revealed.
277277
278@html <hr class="divider" />`,278@html <hr class="divider" />`,
279 markdownOnly: true,279 markdownOnly: true,
...@@ -281,12 +281,12 @@ The ##secret## is revealed....@@ -281,12 +281,12 @@ The ##secret## is revealed.
281 });281 });
282 expect(result.success).toBe(true);282 expect(result.success).toBe(true);
283 if (result.success) {283 if (result.success) {
284 expect(result.text).toContain("<q-block>");284 expect(result.text).toBe(
285 expect(result.text).toContain("<artifact-ref>some-artifact</artifact-ref>");285 `<q-block>what is <artifact-ref>some-artifact</artifact-ref> about?<br>also see <question-ref>2602142011</question-ref></q-block>
286 expect(result.text).toContain("<question-ref>2602142011</question-ref>");286<p>The <redacted>secret</redacted> is revealed.</p>
287 expect(result.text).toContain("<redacted>secret</redacted>");287<hr class="divider" />
288 expect(result.text).toContain('<hr class="divider" />');288`,
289 expect(result.text).toContain("<br />"); // between consecutive q: lines289 );
290 }290 }
291 });291 });
292292
...@@ -306,7 +306,8 @@ The ##secret## is revealed....@@ -306,7 +306,8 @@ The ##secret## is revealed.
306 describe("without cloverExtensions", () => {306 describe("without cloverExtensions", () => {
307 it("does not parse clover syntax when extensions not provided", () => {307 it("does not parse clover syntax when extensions not provided", () => {
308 const result = transform({308 const result = transform({
309 source: "q: not a question\n@not-artifact\n#2602142011\n##not-redacted##",309 source:
310 "q: not a question\n@not-artifact\n#2602142011\n##not-redacted##",
310 markdownOnly: true,311 markdownOnly: true,
311 });312 });
312 expect(result.success).toBe(true);313 expect(result.success).toBe(true);
...@@ -320,3 +321,23 @@ The ##secret## is revealed....@@ -320,3 +321,23 @@ The ##secret## is revealed.
320 });321 });
321 });322 });
322});323});
324
325it("sample cases", () => {
326 expect(
327 transform({
328 source: `q: how is/was your valentine's day?
329
330it was overall good but i don't understand why i'm loved as much as i am. but i appreciate her in every way. also every time i look away, my [frieren collection](/file/q+a/frieren.jpeg) is slowly growing.
331
332@html <p style="color:#81D8D0;padding-left:30px">[clo] is rly cutee and nice to me so i love her alot and pat her head a lot and i miss [clo] meow hug her shes rly cool and does rly cool things and shes cute and likes frieren and meow</p>
333
334oh oops i left my answering machine out.`,
335 markdownOnly: true,
336 cloverExtensions,
337 }).text,
338 ).toBe(`<q-block>how is/was your valentine’s day?</q-block>
339<p>it was overall good but i don’t understand why i’m loved as much as i am. but i appreciate her in every way. also every time i look away, my <a href="/file/q+a/frieren.jpeg">frieren collection</a> is slowly growing.</p>
340<p style="color:#81D8D0;padding-left:30px">[clo] is rly cutee and nice to me so i love her alot and pat her head a lot and i miss [clo] meow hug her shes rly cool and does rly cool things and shes cute and likes frieren and meow</p>
341<p>oh oops i left my answering machine out.</p>
342`);
343});
src/lib.rs+1-1
...@@ -43,7 +43,7 @@ pub struct CloverExtensions {...@@ -43,7 +43,7 @@ pub struct CloverExtensions {
43 pub artifact_ref: String,43 pub artifact_ref: String,
44 /// Element name for question refs. `#2602142011` -> `<element>2602142011</element>`44 /// Element name for question refs. `#2602142011` -> `<element>2602142011</element>`
45 pub question_ref: String,45 pub question_ref: String,
46 /// Element name for labelled redactions. `##name##` -> `<element>name</element>`46 /// Element name for labelled redactions. `#name#` -> `<element>name</element>`
47 pub labelled_redaction: String,47 pub labelled_redaction: String,
48}48}
4949
src/plugin/clover.rs+60-35
...@@ -21,11 +21,15 @@ pub struct CloverConfig(pub CloverExtensions);...@@ -21,11 +21,15 @@ pub struct CloverConfig(pub CloverExtensions);
2121
22impl MarkdownItExt for CloverConfig {}22impl MarkdownItExt for CloverConfig {}
2323
24/// Marker used to separate question lines (will become <br>)
25const QUESTION_LINE_BREAK: &str = "\x1F";
26
24/// Register clover extension rules27/// Register clover extension rules
25pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {28pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
26 md.ext.insert(CloverConfig(config));29 md.ext.insert(CloverConfig(config));
27 md.block.add_rule::<QuestionBlockRule>();30 md.block.add_rule::<QuestionBlockRule>();
28 md.block.add_rule::<HtmlBlockRule>();31 md.block.add_rule::<HtmlBlockRule>();
32 md.inline.add_rule::<QuestionLineBreakRule>();
29 md.inline.add_rule::<ArtifactRefRule>();33 md.inline.add_rule::<ArtifactRefRule>();
30 md.inline.add_rule::<QuestionRefRule>();34 md.inline.add_rule::<QuestionRefRule>();
31 md.inline.add_rule::<LabelledRedactionRule>();35 md.inline.add_rule::<LabelledRedactionRule>();
...@@ -38,15 +42,10 @@ pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {...@@ -38,15 +42,10 @@ pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
38#[derive(Debug)]42#[derive(Debug)]
39pub struct QuestionBlock {43pub struct QuestionBlock {
40 pub element: String,44 pub element: String,
41 /// Whether this question follows another question (needs <br /> prefix)
42 pub needs_break: bool,
43}45}
4446
45impl NodeValue for QuestionBlock {47impl NodeValue for QuestionBlock {
46 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {48 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));49 fmt.text_raw(&format!("<{}>", self.element));
51 fmt.contents(&node.children);50 fmt.contents(&node.children);
52 fmt.text_raw(&format!("</{}>", self.element));51 fmt.text_raw(&format!("</{}>", self.element));
...@@ -54,6 +53,32 @@ impl NodeValue for QuestionBlock {...@@ -54,6 +53,32 @@ impl NodeValue for QuestionBlock {
54 }53 }
55}54}
5655
56/// Node for line breaks within question blocks
57#[derive(Debug)]
58pub struct QuestionLineBreak;
59
60impl NodeValue for QuestionLineBreak {
61 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
62 fmt.text_raw("<br>");
63 }
64}
65
66/// Inline rule to convert line break markers to <br>
67pub struct QuestionLineBreakRule;
68
69impl InlineRule for QuestionLineBreakRule {
70 const MARKER: char = '\x1F';
71
72 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
73 let input = &state.src[state.pos..state.pos_max];
74 if input.starts_with(QUESTION_LINE_BREAK) {
75 Some((Node::new(QuestionLineBreak), 1))
76 } else {
77 None
78 }
79 }
80}
81
57pub struct QuestionBlockRule;82pub struct QuestionBlockRule;
5883
59impl BlockRule for QuestionBlockRule {84impl BlockRule for QuestionBlockRule {
...@@ -64,31 +89,36 @@ impl BlockRule for QuestionBlockRule {...@@ -64,31 +89,36 @@ impl BlockRule for QuestionBlockRule {
64 return None;89 return None;
65 }90 }
6691
67 let line = state.get_line(state.line);92 let first_line = state.get_line(state.line);
68 if !line.starts_with("q: ") {93 if !first_line.starts_with("q: ") {
69 return None;94 return None;
70 }95 }
7196
72 // Check if previous line was also a question block97 // Consume all consecutive q: lines
73 let needs_break = if state.line > 0 {98 let start_line = state.line;
74 let prev_line = state.get_line(state.line - 1);99 let mut line_count = 0;
75 prev_line.starts_with("q: ")100 let mut contents = Vec::new();
76 } else {101
77 false102 while start_line + line_count < state.line_max {
78 };103 let line = state.get_line(start_line + line_count);
104 if !line.starts_with("q: ") {
105 break;
106 }
107 contents.push(&line[3..]);
108 line_count += 1;
109 }
110
111 // Join lines with marker - converted to <br> by QuestionLineBreakRule
112 let combined = contents.join(QUESTION_LINE_BREAK);
113 let mapping = vec![(0, state.line_offsets[start_line].first_nonspace + 3)];
79114
80 let mut node = Node::new(QuestionBlock {115 let mut node = Node::new(QuestionBlock {
81 element: config.0.question.clone(),116 element: config.0.question.clone(),
82 needs_break,
83 });117 });
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.children118 node.children
89 .push(Node::new(InlineRoot::new(content, mapping)));119 .push(Node::new(InlineRoot::new(combined, mapping)));
90120
91 Some((node, 1))121 Some((node, line_count))
92 }122 }
93}123}
94124
...@@ -216,11 +246,6 @@ impl InlineRule for QuestionRefRule {...@@ -216,11 +246,6 @@ impl InlineRule for QuestionRefRule {
216 return None;246 return None;
217 }247 }
218248
219 // Check it's not ## (that's labelled redaction)
220 if input.starts_with("##") {
221 return None;
222 }
223
224 let rest = &input[1..];249 let rest = &input[1..];
225 let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count();250 let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count();
226251
...@@ -249,7 +274,7 @@ impl InlineRule for QuestionRefRule {...@@ -249,7 +274,7 @@ impl InlineRule for QuestionRefRule {
249}274}
250275
251// =============================================================================276// =============================================================================
252// Labelled Redaction: `##label##`277// Labelled Redaction: `#label#`
253// =============================================================================278// =============================================================================
254279
255#[derive(Debug)]280#[derive(Debug)]
...@@ -273,24 +298,24 @@ impl InlineRule for LabelledRedactionRule {...@@ -273,24 +298,24 @@ impl InlineRule for LabelledRedactionRule {
273 let config = state.md.ext.get::<CloverConfig>()?;298 let config = state.md.ext.get::<CloverConfig>()?;
274299
275 let input = &state.src[state.pos..state.pos_max];300 let input = &state.src[state.pos..state.pos_max];
276 if !input.starts_with("##") {301 if !input.starts_with("#") {
277 return None;302 return None;
278 }303 }
279304
280 let rest = &input[2..];305 let rest = &input[1..];
281306
282 // Find closing ##307 // Find closing #
283 let label_end = rest.find("##")?;308 let label_end = rest.find("#")?;
284 if label_end == 0 {309 if label_end == 0 {
285 return None; // Empty label310 return None; // Empty label
286 }311 }
287312
288 let label = &rest[..label_end];313 let label = &rest[..label_end];
289314
290 // Label should be simple identifier-like (alphanumeric, hyphens, underscores, spaces)315 // Label should be simple identifier-like (alphanumeric, hyphens, underscores)
291 if !label316 if !label
292 .chars()317 .chars()
293 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ')318 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
294 {319 {
295 return None;320 return None;
296 }321 }
...@@ -300,7 +325,7 @@ impl InlineRule for LabelledRedactionRule {...@@ -300,7 +325,7 @@ impl InlineRule for LabelledRedactionRule {
300 label: label.to_string(),325 label: label.to_string(),
301 });326 });
302327
303 // ## + label + ##328 // # + label + #
304 Some((node, 2 + label_end + 2))329 Some((node, label_end + 2))
305 }330 }
306}331}