authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 20:08:25-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 20:08:44-08:00
log7cbb22b69a03badf206949c1ef9d9c399ab97029
tree3ac616f18b8bed6ad46cc00f30f694b1f81f0e4a
parent851543ddbf208cf4588952869704aa35fa43053d
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

chore: more meows


19 files changed, 391 insertions(+), 24 deletions(-)

.gitignore+1
......@@ -2,4 +2,5 @@
22/lib/bindgen
33/lib/node_modules
44/lib/README.md
5/lib/dist-npm
56.tmp
README.md+3-2
......@@ -73,11 +73,12 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
7373
7474## Install
7575
76Markodown is distributed on [NPM](https://npmjs.com/package/markodown) and
76Markodown is distributed on
77[NPM](https://npmjs.com/package/@paperclover/markodown) and
7778[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7879
7980```sh
80npm i markodown
81npm i @paperclover/markodown
8182# or
8283npx jsr add @clo/markodown
8384```
examples/marko-run/src/tags/markdown-layout.marko+5-5
......@@ -1,4 +1,4 @@
1export interface Header {
1export interface Heading {
22 level: 1 | 2 | 3 | 4 | 5 | 6;
33 id: string;
44 content: Marko.Body;
......@@ -8,7 +8,7 @@ export interface Input {
88 content: Marko.Body;
99
1010 // automatically generated outline based on statically analyzable markdown
11 outline: Header[];
11 outline: Heading[];
1212
1313 // access module exports, aka frontmatter
1414 module?: {
......@@ -33,8 +33,8 @@ export interface Input {
3333 ${input.module.meta.description}
3434 </p>
3535 </>
36 <for|header| of=input.outline>
37 <li><a href="#" + header.id><${header.content}/></a></li>
36 <for|heading| of=input.outline>
37 <li><a href="#" + heading.id><${heading.content}/></a></li>
3838 </for>
3939 </aside>
4040 </div>
......@@ -115,4 +115,4 @@ export interface Input {
115115 background-color: black;
116116 padding: 1rem;
117117 }
118</style>
\ No newline at end of file
118</style>
lib/jsr.json+3-2
......@@ -1,6 +1,6 @@
11{
22 "name": "@clo/markodown",
3 "version": "1.0.0-rc.3",
3 "version": "1.0.0-rc.5",
44 "license": "ISC",
55 "exports": {
66 ".": "./mod.ts",
......@@ -17,7 +17,8 @@
1717 "!bindgen",
1818 "!README.md",
1919 "package-lock.json",
20 "package.json"
20 "package.json",
21 "package.npm.json"
2122 ]
2223 }
2324}
lib/mod.ts+6-5
......@@ -80,7 +80,7 @@ export interface CloverQuestionExtensions {
8080 questionRef: string;
8181 /**
8282 * Element name for Labelled redactions.
83 * `##name##` -> `<labelledRedaction>name</labelledRedaction>`
83 * `#name#` -> `<labelledRedaction>name</labelledRedaction>`
8484 */
8585 labelledRedaction: string;
8686}
......@@ -91,20 +91,21 @@ export interface Success {
9191 success: true;
9292 text: string;
9393 errors: [];
94 outline: Header[] | null;
94 format: OutputFormat;
9595}
9696
9797export interface Failure {
9898 success: false;
9999 text: null;
100100 errors: TransformError[];
101 outline: Header[] | null;
101 outline: Heading[] | null;
102102}
103103
104export interface Header {
104export interface Heading {
105105 level: 1 | 2 | 3 | 4 | 5 | 6;
106106 id: string;
107 html: string;
107 // @ts-ignore fails if marko types not chilling
108 content: Marko.Body;
108109}
109110
110111export interface TransformError {
lib/package.npm.json+1-1
......@@ -1,6 +1,6 @@
11{
22 "name": "@paperclover/markodown",
3 "version": "1.0.0-rc.3",
3 "version": "VERSION",
44 "homepage": "https://git.paperclover.net/clo/markodown",
55 "type": "module",
66 "peerDependencies": {
npm.sh created+17
......@@ -0,0 +1,17 @@
1set -e
2
3rm -rf lib/dist-npm
4mkdir lib/dist-npm
5
6cd lib
7VERSION="$(cat jsr.json | jq .version -r)"
8
9cd dist-npm
10echo '{}' > package.json
11npx jsr add "@clo/markodown@$VERSION"
12
13cd node_modules/@clo/markodown
14rm jsr.json
15sed "s/VERSION/$VERSION/g" ../../../../package.npm.json > package.json
16
17npm publish --tag rc
src/component_transforms.rs+139-3
......@@ -133,9 +133,11 @@ impl NodeValue for CodeBlockComponentNode {
133133 }
134134
135135 fmt.text_raw(">\n");
136 // Escape content for Marko - need to wrap in a text node or use raw content
137 // Use a template literal to preserve the content exactly
138 fmt.text_raw(&format!("${{{:?}}}", self.content));
136 // Escape content for Marko template literal:
137 // - Use {:?} to escape quotes and backslashes
138 // - Additionally escape ${ to prevent nested template expressions
139 let escaped = escape_template_literal(&self.content);
140 fmt.text_raw(&format!("${{\"{}\" }}", escaped));
139141 fmt.text_raw("\n</>\n");
140142 }
141143}
......@@ -147,6 +149,32 @@ fn escape_attr(s: &str) -> String {
147149 .replace('\n', "\\n")
148150}
149151
152/// Escape a string for use inside a Marko template literal.
153/// This escapes backslashes, quotes, newlines, and `${` sequences
154/// to prevent nested template expression interpretation.
155fn escape_template_literal(s: &str) -> String {
156 let mut result = String::with_capacity(s.len() + s.len() / 8);
157 let mut chars = s.chars().peekable();
158
159 while let Some(c) = chars.next() {
160 match c {
161 '\\' => result.push_str("\\\\"),
162 '"' => result.push_str("\\\""),
163 '\n' => result.push_str("\\n"),
164 '\r' => result.push_str("\\r"),
165 '\t' => result.push_str("\\t"),
166 '$' if chars.peek() == Some(&'{') => {
167 // Escape ${ to prevent template expression interpretation
168 result.push_str("\\${");
169 chars.next(); // consume the '{'
170 }
171 _ => result.push(c),
172 }
173 }
174
175 result
176}
177
150178/// Transform a code block node to use the code block component.
151179fn transform_code_block(node: &mut Node) {
152180 // Handle fenced code blocks (``` or ~~~)
......@@ -397,6 +425,114 @@ fn transform_blockquote(node: &mut Node) {
397425 }
398426}
399427
428/// A code fence node that escapes `${` for Marko output.
429/// Used when no custom code block component is configured but output is Marko.
430#[derive(Debug)]
431struct EscapedCodeFence {
432 language: Option<String>,
433 content: String,
434}
435
436impl NodeValue for EscapedCodeFence {
437 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
438 fmt.cr();
439 fmt.text_raw("<pre><code");
440 if let Some(lang) = &self.language {
441 fmt.text_raw(&format!(" class=\"language-{}\"", lang));
442 }
443 fmt.text_raw(">");
444 // Escape ${ sequences in the content
445 let escaped = escape_marko_in_html(&self.content);
446 fmt.text_raw(&escaped);
447 fmt.text_raw("</code></pre>\n");
448 }
449}
450
451/// An inline code node that escapes `${` for Marko output.
452#[derive(Debug)]
453struct EscapedCodeInline {
454 content: String,
455}
456
457impl NodeValue for EscapedCodeInline {
458 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
459 fmt.text_raw("<code>");
460 let escaped = escape_marko_in_html(&self.content);
461 fmt.text_raw(&escaped);
462 fmt.text_raw("</code>");
463 }
464}
465
466/// Escape `${` sequences for Marko output in HTML context.
467/// This escapes `${` to `\${` to prevent template expression interpretation.
468fn escape_marko_in_html(s: &str) -> String {
469 let mut result = String::with_capacity(s.len() + s.len() / 16);
470 let mut chars = s.chars().peekable();
471
472 while let Some(c) = chars.next() {
473 match c {
474 '<' => result.push_str("&lt;"),
475 '>' => result.push_str("&gt;"),
476 '&' => result.push_str("&amp;"),
477 '"' => result.push_str("&quot;"),
478 '$' if chars.peek() == Some(&'{') => {
479 // Escape ${ to prevent template expression interpretation
480 result.push_str("\\${");
481 chars.next(); // consume the '{'
482 }
483 _ => result.push(c),
484 }
485 }
486
487 result
488}
489
490/// Escape code blocks for Marko output.
491/// - `escape_block`: if true, escape fenced and indented code blocks
492/// - Inline code is always escaped since there's no custom component option for it
493pub fn escape_code_blocks_for_marko(node: &mut Node, escape_block: bool) {
494 // Process children first (bottom-up traversal)
495 for child in &mut node.children {
496 escape_code_blocks_for_marko(child, escape_block);
497 }
498
499 if escape_block {
500 // Transform fenced code blocks
501 if let Some(fence) = node.cast::<markdown_it::plugins::cmark::block::fence::CodeFence>() {
502 let language = fence
503 .info
504 .split_whitespace()
505 .next()
506 .filter(|s| !s.is_empty())
507 .map(|s| s.to_string());
508 let content = fence.content.clone();
509
510 *node = Node::new(EscapedCodeFence { language, content });
511 return;
512 }
513
514 // Transform indented code blocks
515 if let Some(code) = node.cast::<markdown_it::plugins::cmark::block::code::CodeBlock>() {
516 let content = code.content.clone();
517 *node = Node::new(EscapedCodeFence {
518 language: None,
519 content,
520 });
521 return;
522 }
523 }
524
525 // Transform inline code - content is in children as text nodes
526 // Always escaped since there's no custom component option for inline code
527 if node
528 .cast::<markdown_it::plugins::cmark::inline::backticks::CodeInline>()
529 .is_some()
530 {
531 let content = node.collect_text();
532 *node = Node::new(EscapedCodeInline { content });
533 }
534}
535
400536#[cfg(test)]
401537mod tests {
402538 use super::*;
src/lib.rs+11
......@@ -150,6 +150,17 @@ pub fn transform(
150150 component_transforms::transform_components(&mut ast, imports);
151151 }
152152
153 // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions
154 // - Block code (fenced/indented): only escaped if no custom code_block component is configured
155 // - Inline code: always escaped since there's no custom component option for it
156 if format == OutputFormat::Marko {
157 let escape_block = component_imports
158 .as_ref()
159 .map(|i| i.code_block.is_none())
160 .unwrap_or(true);
161 component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block);
162 }
163
153164 // Extract statements before rendering - they need to be hoisted above Layout
154165 let extracted_statements = hoist_statements(&mut ast);
155166
src/marko.rs+105-2
......@@ -10,6 +10,13 @@ use crate::{
1010 },
1111};
1212
13/// HTML void elements that are implicitly self-closing.
14/// These elements cannot have content and don't need a closing tag.
15const VOID_ELEMENTS: &[&str] = &[
16 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
17 "track", "wbr",
18];
19
1320pub fn parse_tag(src: &str) -> Result<OpenOrClose, OxcDiagnostic> {
1421 assert_eq!(src.as_bytes()[0], b'<');
1522 if src.starts_with("</") {
......@@ -206,6 +213,11 @@ pub fn parse_open(src: &str) -> Result<Open, OxcDiagnostic> {
206213 l.expect(">")?;
207214 }
208215
216 // Automatically mark void elements as self-closing
217 if !self_closing && VOID_ELEMENTS.contains(&tag_name) {
218 self_closing = true;
219 }
220
209221 Ok(Open::new(
210222 &src[0..l.offset as usize],
211223 Span::new(1, (tag_name.len() + 1) as u32),
......@@ -717,7 +729,7 @@ mod tests {
717729 "<input type=\"checkbox\" checked>",
718730 "input",
719731 "<input type=\"checkbox\" checked>",
720 false,
732 true, // input is a void element
721733 );
722734 }
723735
......@@ -727,7 +739,7 @@ mod tests {
727739 "<input checked foo=1>",
728740 "input",
729741 "<input checked foo=1>",
730 false,
742 true, // input is a void element
731743 );
732744 }
733745
......@@ -1027,6 +1039,97 @@ mod tests {
10271039 assert!(result.is_ok());
10281040 }
10291041
1042 // ===========================================
1043 // Void elements (implicitly self-closing)
1044 // ===========================================
1045
1046 #[test]
1047 fn test_void_element_img() {
1048 check_open(
1049 "<img src=\"cat.jpg\">",
1050 "img",
1051 "<img src=\"cat.jpg\">",
1052 true,
1053 );
1054 }
1055
1056 #[test]
1057 fn test_void_element_img_explicit() {
1058 check_open(
1059 "<img src=\"cat.jpg\" />",
1060 "img",
1061 "<img src=\"cat.jpg\" />",
1062 true,
1063 );
1064 }
1065
1066 #[test]
1067 fn test_void_element_br() {
1068 check_open("<br>", "br", "<br>", true);
1069 }
1070
1071 #[test]
1072 fn test_void_element_hr() {
1073 check_open("<hr>", "hr", "<hr>", true);
1074 }
1075
1076 #[test]
1077 fn test_void_element_input() {
1078 check_open(
1079 "<input type=\"text\">",
1080 "input",
1081 "<input type=\"text\">",
1082 true,
1083 );
1084 }
1085
1086 #[test]
1087 fn test_void_element_meta() {
1088 check_open(
1089 "<meta charset=\"utf-8\">",
1090 "meta",
1091 "<meta charset=\"utf-8\">",
1092 true,
1093 );
1094 }
1095
1096 #[test]
1097 fn test_void_element_link() {
1098 check_open(
1099 "<link rel=\"stylesheet\" href=\"style.css\">",
1100 "link",
1101 "<link rel=\"stylesheet\" href=\"style.css\">",
1102 true,
1103 );
1104 }
1105
1106 #[test]
1107 fn test_void_element_source() {
1108 check_open(
1109 "<source src=\"video.mp4\">",
1110 "source",
1111 "<source src=\"video.mp4\">",
1112 true,
1113 );
1114 }
1115
1116 #[test]
1117 fn test_void_element_wbr() {
1118 check_open("<wbr>", "wbr", "<wbr>", true);
1119 }
1120
1121 #[test]
1122 fn test_non_void_element_div() {
1123 // div is NOT a void element, should not be self-closing
1124 check_open("<div>", "div", "<div>", false);
1125 }
1126
1127 #[test]
1128 fn test_non_void_element_span() {
1129 // span is NOT a void element, should not be self-closing
1130 check_open("<span>", "span", "<span>", false);
1131 }
1132
10301133 #[test]
10311134 fn test_id_attr_shorthand_static() {
10321135 let open = parse_open("<div#myId>").unwrap();
src/outline.rs+16-2
......@@ -4,12 +4,14 @@
44//! into hoisted `<define/>` blocks for use in the outline.
55
66use markdown_it::{Node, NodeValue, Renderer};
7use oxc_allocator::Allocator;
78use oxc_diagnostics::OxcDiagnostic;
89use oxc_span::Span;
910use std::collections::HashSet;
1011
1112use crate::marko_ast::AttributeValue;
1213use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText};
14use crate::typescript::{parse_expr, parse_expr_extra};
1315
1416/// A heading entry for the outline, with content reference for hoisting.
1517#[derive(Debug, Clone)]
......@@ -245,8 +247,20 @@ fn collect_recursive(
245247 let id_info = open.open.id;
246248 let tag_span = open.open.as_ref().tag_name_span();
247249 let existing_id = match id_info {
248 AttributeValue::Static { span, .. } => {
249 Some(open.open.src[span.start as usize..span.end as usize].to_string())
250 AttributeValue::Static { span, is_quoted } => {
251 let string = &open.open.src[span.start as usize..span.end as usize];
252 Some(if is_quoted {
253 let mut allocator = Allocator::new();
254 let expr = parse_expr_extra(string, 0, &mut allocator);
255 match &expr {
256 Ok(oxc_ast::ast::Expression::StringLiteral(literal)) => {
257 literal.value.into_string()
258 }
259 _ => panic!("verified beforehand as a string literal"),
260 }
261 } else {
262 string.to_owned()
263 })
250264 }
251265 _ => None,
252266 };
src/plugin/mod.rs+5-1
......@@ -15,7 +15,11 @@ use oxc_diagnostics::OxcDiagnostic;
1515
1616/// Register all markodown extensions.
1717/// If `markdown_only` is true, skip Marko-specific rules (tags, templates, statements).
18pub fn add_all(md: &mut MarkdownIt, markdown_only: bool, clover_extensions: &Option<CloverExtensions>) {
18pub fn add_all(
19 md: &mut MarkdownIt,
20 markdown_only: bool,
21 clover_extensions: &Option<CloverExtensions>,
22) {
1923 // Always add frontmatter parsing (YAML metadata is standard markdown extension)
2024 md.block.add_rule::<frontmatter::Rule>();
2125
src/typescript.rs+1-1
......@@ -96,7 +96,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
9696 })
9797}
9898
99fn parse_expr_extra<'alloc, 'src: 'alloc>(
99pub fn parse_expr_extra<'alloc, 'src: 'alloc>(
100100 source: &'src str,
101101 offset: i32,
102102 allocator: &'alloc mut Allocator,
tests/fixtures.rs+18
......@@ -218,3 +218,21 @@ fn fixture_24_heading_import() {
218218 None,
219219 );
220220}
221
222#[test]
223fn fixture_25_code_block_import() {
224 run_fixture_with_options(
225 "25-code-block-import",
226 None,
227 Some(ComponentImports {
228 code_block: Some("./code-block.marko".to_string()),
229 ..Default::default()
230 }),
231 None,
232 );
233}
234
235#[test]
236fn fixture_26_code_block_import() {
237 run_fixture_with_options("26-code-block-import", None, None, None);
238}
tests/fixtures/25-code-block-import.marko created+9
......@@ -0,0 +1,9 @@
1import CodeBlockComponent__markodown__ from "./code-block.marko";
2<h1>Code Examples</h1>
3<CodeBlockComponent__markodown__ language="ts">
4${"const greeting = `Hello \${name}!`;\nconst value = \${1 + 2};\n" }
5</>
6<CodeBlockComponent__markodown__ language="marko">
7${"<for|item| of=items>\n <div>\${item.name}</div>\n</for>\n" }
8</>
9<p>Inline: <code>hello \${world}</code></p>
tests/fixtures/25-code-block-import.mdo created+14
......@@ -0,0 +1,14 @@
1# Code Examples
2
3```ts
4const greeting = `Hello ${name}!`;
5const value = ${1 + 2};
6```
7
8```marko
9<for|item| of=items>
10 <div>${item.name}</div>
11</for>
12```
13
14Inline: `hello ${world}`
tests/fixtures/26-code-block-import.marko created+13
......@@ -0,0 +1,13 @@
1<h1>Code Examples</h1>
2<pre><code class="language-ts">const greeting = `Hello \${name}!`;
3const value = \${1 + 2};
4</code></pre>
5<pre><code class="language-marko">&lt;for|item| of=items&gt;
6 &lt;div&gt;\${item.name}&lt;/div&gt;
7&lt;/for&gt;
8</code></pre>
9<p>Inline: <code>hello \${world}</code></p>
10<p>Multiple: <code>\${a}</code> and <code>\${b}</code></p>
11<pre><code>indented code block
12with \${template} expression
13</code></pre>
tests/fixtures/26-code-block-import.mdo created+19
......@@ -0,0 +1,19 @@
1# Code Examples
2
3```ts
4const greeting = `Hello ${name}!`;
5const value = ${1 + 2};
6```
7
8```marko
9<for|item| of=items>
10 <div>${item.name}</div>
11</for>
12```
13
14Inline: `hello ${world}`
15
16Multiple: `${a}` and `${b}`
17
18 indented code block
19 with ${template} expression
wtf.mdo created+5
......@@ -0,0 +1,5 @@
1i thought this bug was fixed
2
3<h3 id='bbbbbbb'><code>aaa</code> mmmm</>
4
5wtf!!