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.
1717> - [Install](#install)
1818> - [Components](#components)
1919> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#outline-table-of-contents)
20> - [Frontmatter](#frontmatter)
2121> - [Comments](#comments)
2222> - [Paragraph Detection](#paragraph-detection)
2323> - [Static Statements](#static-statements)
......@@ -73,7 +73,7 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
7373
7474## Install
7575
76Markdown is distributed on [NPM](http://npmjs.com/markodown) and
76Markodown is distributed on [NPM](http://npmjs.com/markodown) and
7777[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7878
7979```sh
......@@ -82,8 +82,8 @@ npx jsr add @clo/markodown
8282```
8383
8484The compiler can be directly used from `transform`, and there are also plugins
85for Rollup/Rolldown/Vite and esbuild. For example, configure Markdown with Marko
86Run:
85for Rollup/Rolldown/Vite and esbuild. For example, configure Markodown with
86Marko Run:
8787
8888```ts
8989import marko from "@marko/run/vite";
......@@ -117,7 +117,7 @@ add complex content to your pages.
117117
118118<footer.copyright-info>
119119 made with love... (c) ${new Date().getFullYear()}
120</div>
120</footer>
121121```
122122
123123[tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags
lib/clover.test.ts+51-30
......@@ -1,5 +1,5 @@
1import { describe, it, expect } from "vitest";
2import { transform, CloverQuestionExtensions } from "./mod.ts";
1import { describe, expect, it } from "vitest";
2import { CloverQuestionExtensions, transform } from "./mod.ts";
33
44const cloverExtensions: CloverQuestionExtensions = {
55 question: "q-block",
......@@ -32,9 +32,9 @@ describe("clover extensions", () => {
3232 });
3333 expect(result.success).toBe(true);
3434 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>");
35 expect(result.text).toContain(
36 "<q-block>first question<br>second question</q-block>",
37 );
3838 }
3939 });
4040
......@@ -73,7 +73,7 @@ describe("clover extensions", () => {
7373 expect(result.success).toBe(true);
7474 if (result.success) {
7575 expect(result.text).toContain(
76 '<custom-element attr="value">content</custom-element>'
76 '<custom-element attr="value">content</custom-element>',
7777 );
7878 }
7979 });
......@@ -102,7 +102,7 @@ describe("clover extensions", () => {
102102 expect(result.success).toBe(true);
103103 if (result.success) {
104104 expect(result.text).toContain(
105 "<artifact-ref>its-snowing</artifact-ref>"
105 "<artifact-ref>its-snowing</artifact-ref>",
106106 );
107107 }
108108 });
......@@ -117,7 +117,7 @@ describe("clover extensions", () => {
117117 if (result.success) {
118118 expect(result.text).toContain("<artifact-ref>first-one</artifact-ref>");
119119 expect(result.text).toContain(
120 "<artifact-ref>second-one</artifact-ref>"
120 "<artifact-ref>second-one</artifact-ref>",
121121 );
122122 }
123123 });
......@@ -131,7 +131,7 @@ describe("clover extensions", () => {
131131 expect(result.success).toBe(true);
132132 if (result.success) {
133133 expect(result.text).toContain(
134 "<artifact-ref>valid-slug123</artifact-ref>"
134 "<artifact-ref>valid-slug123</artifact-ref>",
135135 );
136136 expect(result.text).toContain("@INVALID");
137137 expect(result.text).not.toContain("<artifact-ref>INVALID");
......@@ -161,7 +161,7 @@ describe("clover extensions", () => {
161161 expect(result.success).toBe(true);
162162 if (result.success) {
163163 expect(result.text).toContain(
164 "<question-ref>2602142011</question-ref>"
164 "<question-ref>2602142011</question-ref>",
165165 );
166166 }
167167 });
......@@ -175,7 +175,7 @@ describe("clover extensions", () => {
175175 expect(result.success).toBe(true);
176176 if (result.success) {
177177 expect(result.text).toContain(
178 "<question-ref>260214201112</question-ref>"
178 "<question-ref>260214201112</question-ref>",
179179 );
180180 }
181181 });
......@@ -205,31 +205,31 @@ describe("clover extensions", () => {
205205 expect(result.success).toBe(true);
206206 if (result.success) {
207207 expect(result.text).toContain(
208 "<question-ref>2602142011</question-ref>"
208 "<question-ref>2602142011</question-ref>",
209209 );
210210 expect(result.text).toContain(
211 "<question-ref>2602142012</question-ref>"
211 "<question-ref>2602142012</question-ref>",
212212 );
213213 }
214214 });
215215 });
216216
217217 describe("labelled redactions", () => {
218 it("transforms ##label## to redaction element", () => {
218 it("transforms #label# to redaction element", () => {
219219 const result = transform({
220 source: "the ##secret name## is hidden",
220 source: "the #name# is hidden",
221221 markdownOnly: true,
222222 cloverExtensions,
223223 });
224224 expect(result.success).toBe(true);
225225 if (result.success) {
226 expect(result.text).toContain("<redacted>secret name</redacted>");
226 expect(result.text).toContain("<redacted>name</redacted>");
227227 }
228228 });
229229
230230 it("handles hyphens and underscores in labels", () => {
231231 const result = transform({
232 source: "##my-secret_label##",
232 source: "#my-secret_label#",
233233 markdownOnly: true,
234234 cloverExtensions,
235235 });
......@@ -241,21 +241,21 @@ describe("clover extensions", () => {
241241
242242 it("does not match empty labels", () => {
243243 const result = transform({
244 source: "text #### more",
244 source: "text ## ### #### more",
245245 markdownOnly: true,
246246 cloverExtensions,
247247 });
248248 expect(result.success).toBe(true);
249249 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("####");
250 expect(result.text).not.toContain("<redacted>");
251 // ## in the middle of text stays as-is
252 expect(result.text).toContain("##");
253253 }
254254 });
255255
256256 it("handles multiple redactions", () => {
257257 const result = transform({
258 source: "##first## and ##second##",
258 source: "#first# and #second#",
259259 markdownOnly: true,
260260 cloverExtensions,
261261 });
......@@ -273,7 +273,7 @@ describe("clover extensions", () => {
273273 source: `q: what is @some-artifact about?
274274q: also see #2602142011
275275
276The ##secret## is revealed.
276The #secret# is revealed.
277277
278278@html <hr class="divider" />`,
279279 markdownOnly: true,
......@@ -281,12 +281,12 @@ The ##secret## is revealed.
281281 });
282282 expect(result.success).toBe(true);
283283 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
284 expect(result.text).toBe(
285 `<q-block>what is <artifact-ref>some-artifact</artifact-ref> about?<br>also see <question-ref>2602142011</question-ref></q-block>
286<p>The <redacted>secret</redacted> is revealed.</p>
287<hr class="divider" />
288`,
289 );
290290 }
291291 });
292292
......@@ -306,7 +306,8 @@ The ##secret## is revealed.
306306 describe("without cloverExtensions", () => {
307307 it("does not parse clover syntax when extensions not provided", () => {
308308 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##",
310311 markdownOnly: true,
311312 });
312313 expect(result.success).toBe(true);
......@@ -320,3 +321,23 @@ The ##secret## is revealed.
320321 });
321322 });
322323});
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 {
4343 pub artifact_ref: String,
4444 /// Element name for question refs. `#2602142011` -> `<element>2602142011</element>`
4545 pub question_ref: String,
46 /// Element name for labelled redactions. `##name##` -> `<element>name</element>`
46 /// Element name for labelled redactions. `#name#` -> `<element>name</element>`
4747 pub labelled_redaction: String,
4848}
4949
src/plugin/clover.rs+60-35
......@@ -21,11 +21,15 @@ pub struct CloverConfig(pub CloverExtensions);
2121
2222impl MarkdownItExt for CloverConfig {}
2323
24/// Marker used to separate question lines (will become <br>)
25const QUESTION_LINE_BREAK: &str = "\x1F";
26
2427/// Register clover extension rules
2528pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
2629 md.ext.insert(CloverConfig(config));
2730 md.block.add_rule::<QuestionBlockRule>();
2831 md.block.add_rule::<HtmlBlockRule>();
32 md.inline.add_rule::<QuestionLineBreakRule>();
2933 md.inline.add_rule::<ArtifactRefRule>();
3034 md.inline.add_rule::<QuestionRefRule>();
3135 md.inline.add_rule::<LabelledRedactionRule>();
......@@ -38,15 +42,10 @@ pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
3842#[derive(Debug)]
3943pub struct QuestionBlock {
4044 pub element: String,
41 /// Whether this question follows another question (needs <br /> prefix)
42 pub needs_break: bool,
4345}
4446
4547impl NodeValue for QuestionBlock {
4648 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
47 if self.needs_break {
48 fmt.text_raw("<br />");
49 }
5049 fmt.text_raw(&format!("<{}>", self.element));
5150 fmt.contents(&node.children);
5251 fmt.text_raw(&format!("</{}>", self.element));
......@@ -54,6 +53,32 @@ impl NodeValue for QuestionBlock {
5453 }
5554}
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
5782pub struct QuestionBlockRule;
5883
5984impl BlockRule for QuestionBlockRule {
......@@ -64,31 +89,36 @@ impl BlockRule for QuestionBlockRule {
6489 return None;
6590 }
6691
67 let line = state.get_line(state.line);
68 if !line.starts_with("q: ") {
92 let first_line = state.get_line(state.line);
93 if !first_line.starts_with("q: ") {
6994 return None;
7095 }
7196
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 };
97 // Consume all consecutive q: lines
98 let start_line = state.line;
99 let mut line_count = 0;
100 let mut contents = Vec::new();
101
102 while start_line + line_count < state.line_max {
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
80115 let mut node = Node::new(QuestionBlock {
81116 element: config.0.question.clone(),
82 needs_break,
83117 });
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)];
88118 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))
92122 }
93123}
94124
......@@ -216,11 +246,6 @@ impl InlineRule for QuestionRefRule {
216246 return None;
217247 }
218248
219 // Check it's not ## (that's labelled redaction)
220 if input.starts_with("##") {
221 return None;
222 }
223
224249 let rest = &input[1..];
225250 let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count();
226251
......@@ -249,7 +274,7 @@ impl InlineRule for QuestionRefRule {
249274}
250275
251276// =============================================================================
252// Labelled Redaction: `##label##`
277// Labelled Redaction: `#label#`
253278// =============================================================================
254279
255280#[derive(Debug)]
......@@ -273,24 +298,24 @@ impl InlineRule for LabelledRedactionRule {
273298 let config = state.md.ext.get::<CloverConfig>()?;
274299
275300 let input = &state.src[state.pos..state.pos_max];
276 if !input.starts_with("##") {
301 if !input.starts_with("#") {
277302 return None;
278303 }
279304
280 let rest = &input[2..];
305 let rest = &input[1..];
281306
282 // Find closing ##
283 let label_end = rest.find("##")?;
307 // Find closing #
308 let label_end = rest.find("#")?;
284309 if label_end == 0 {
285310 return None; // Empty label
286311 }
287312
288313 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)
291316 if !label
292317 .chars()
293 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ')
318 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
294319 {
295320 return None;
296321 }
......@@ -300,7 +325,7 @@ impl InlineRule for LabelledRedactionRule {
300325 label: label.to_string(),
301326 });
302327
303 // ## + label + ##
304 Some((node, 2 + label_end + 2))
328 // # + label + #
329 Some((node, label_end + 2))
305330 }
306331}