From 7cbb22b69a03badf206949c1ef9d9c399ab97029 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 16 Feb 2026 20:08:25 -0800 Subject: [PATCH] chore: more meows --- .gitignore | 1 + README.md | 5 +- .../marko-run/src/tags/markdown-layout.marko | 10 +- lib/jsr.json | 5 +- lib/mod.ts | 11 +- lib/package.npm.json | 2 +- npm.sh | 17 +++ src/component_transforms.rs | 142 +++++++++++++++++- src/lib.rs | 11 ++ src/marko.rs | 107 ++++++++++++- src/outline.rs | 18 ++- src/plugin/mod.rs | 6 +- src/typescript.rs | 2 +- tests/fixtures.rs | 18 +++ tests/fixtures/25-code-block-import.marko | 9 ++ tests/fixtures/25-code-block-import.mdo | 14 ++ tests/fixtures/26-code-block-import.marko | 13 ++ tests/fixtures/26-code-block-import.mdo | 19 +++ wtf.mdo | 5 + 19 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 npm.sh create mode 100644 tests/fixtures/25-code-block-import.marko create mode 100644 tests/fixtures/25-code-block-import.mdo create mode 100644 tests/fixtures/26-code-block-import.marko create mode 100644 tests/fixtures/26-code-block-import.mdo create mode 100644 wtf.mdo diff --git a/.gitignore b/.gitignore index f527a9d7cb7454257b828edf0ee49ae653344ff7..5fde7c2e1a4d32f298268dc95e7908176067e78c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /lib/bindgen /lib/node_modules /lib/README.md +/lib/dist-npm .tmp diff --git a/README.md b/README.md index b476c00767b4b00d78f3f60c19df71bbd43addc7..e48779eaa6550f45c86c57ba1a100c89a83c8e65 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,12 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}. ## Install -Markodown is distributed on [NPM](https://npmjs.com/package/markodown) and +Markodown is distributed on +[NPM](https://npmjs.com/package/@paperclover/markodown) and [JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs. ```sh -npm i markodown +npm i @paperclover/markodown # or npx jsr add @clo/markodown ``` diff --git a/examples/marko-run/src/tags/markdown-layout.marko b/examples/marko-run/src/tags/markdown-layout.marko index 12a2b4afdaf29c5086c75eab81a369f6711f7493..6c2af182c33ad3145d08779ad2991eaea6cd1153 100644 --- a/examples/marko-run/src/tags/markdown-layout.marko +++ b/examples/marko-run/src/tags/markdown-layout.marko @@ -1,4 +1,4 @@ -export interface Header { +export interface Heading { level: 1 | 2 | 3 | 4 | 5 | 6; id: string; content: Marko.Body; @@ -8,7 +8,7 @@ export interface Input { content: Marko.Body; // automatically generated outline based on statically analyzable markdown - outline: Header[]; + outline: Heading[]; // access module exports, aka frontmatter module?: { @@ -33,8 +33,8 @@ export interface Input { ${input.module.meta.description}

- -
  • <${header.content}/>
  • + +
  • <${heading.content}/>
  • @@ -115,4 +115,4 @@ export interface Input { background-color: black; padding: 1rem; } - \ No newline at end of file + diff --git a/lib/jsr.json b/lib/jsr.json index 5ce7f3d6b50aa192f3c524a75b8b749943a3c13d..0eb5e0b26c128ea7281dab8b0b3bbdff2abea360 100644 --- a/lib/jsr.json +++ b/lib/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/markodown", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.5", "license": "ISC", "exports": { ".": "./mod.ts", @@ -17,7 +17,8 @@ "!bindgen", "!README.md", "package-lock.json", - "package.json" + "package.json", + "package.npm.json" ] } } diff --git a/lib/mod.ts b/lib/mod.ts index 9c2425d8c7d19c8614782a9d19fae0ca1dcb63e4..83d9fcb8eaa00b227c22e4d518dee171c308d411 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -80,7 +80,7 @@ export interface CloverQuestionExtensions { questionRef: string; /** * Element name for Labelled redactions. - * `##name##` -> `name` + * `#name#` -> `name` */ labelledRedaction: string; } @@ -91,20 +91,21 @@ export interface Success { success: true; text: string; errors: []; - outline: Header[] | null; + format: OutputFormat; } export interface Failure { success: false; text: null; errors: TransformError[]; - outline: Header[] | null; + outline: Heading[] | null; } -export interface Header { +export interface Heading { level: 1 | 2 | 3 | 4 | 5 | 6; id: string; - html: string; + // @ts-ignore fails if marko types not chilling + content: Marko.Body; } export interface TransformError { diff --git a/lib/package.npm.json b/lib/package.npm.json index 5a2315d29d4e13e9f18170fef64b347bf791a698..19f5abbec3c8455d1718496048f27abe0b89e086 100644 --- a/lib/package.npm.json +++ b/lib/package.npm.json @@ -1,6 +1,6 @@ { "name": "@paperclover/markodown", - "version": "1.0.0-rc.3", + "version": "VERSION", "homepage": "https://git.paperclover.net/clo/markodown", "type": "module", "peerDependencies": { diff --git a/npm.sh b/npm.sh new file mode 100644 index 0000000000000000000000000000000000000000..b02dc80b2e4628ec2be7e113869d7f5182790fca --- /dev/null +++ b/npm.sh @@ -0,0 +1,17 @@ +set -e + +rm -rf lib/dist-npm +mkdir lib/dist-npm + +cd lib +VERSION="$(cat jsr.json | jq .version -r)" + +cd dist-npm +echo '{}' > package.json +npx jsr add "@clo/markodown@$VERSION" + +cd node_modules/@clo/markodown +rm jsr.json +sed "s/VERSION/$VERSION/g" ../../../../package.npm.json > package.json + +npm publish --tag rc diff --git a/src/component_transforms.rs b/src/component_transforms.rs index a3ac27f2e8beac591c237fcd497fb9d67931f0e7..91cd7bd126aeb7e723b2f750def1cb4161b94960 100644 --- a/src/component_transforms.rs +++ b/src/component_transforms.rs @@ -133,9 +133,11 @@ impl NodeValue for CodeBlockComponentNode { } fmt.text_raw(">\n"); - // Escape content for Marko - need to wrap in a text node or use raw content - // Use a template literal to preserve the content exactly - fmt.text_raw(&format!("${{{:?}}}", self.content)); + // Escape content for Marko template literal: + // - Use {:?} to escape quotes and backslashes + // - Additionally escape ${ to prevent nested template expressions + let escaped = escape_template_literal(&self.content); + fmt.text_raw(&format!("${{\"{}\" }}", escaped)); fmt.text_raw("\n\n"); } } @@ -147,6 +149,32 @@ fn escape_attr(s: &str) -> String { .replace('\n', "\\n") } +/// Escape a string for use inside a Marko template literal. +/// This escapes backslashes, quotes, newlines, and `${` sequences +/// to prevent nested template expression interpretation. +fn escape_template_literal(s: &str) -> String { + let mut result = String::with_capacity(s.len() + s.len() / 8); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '$' if chars.peek() == Some(&'{') => { + // Escape ${ to prevent template expression interpretation + result.push_str("\\${"); + chars.next(); // consume the '{' + } + _ => result.push(c), + } + } + + result +} + /// Transform a code block node to use the code block component. fn transform_code_block(node: &mut Node) { // Handle fenced code blocks (``` or ~~~) @@ -397,6 +425,114 @@ fn transform_blockquote(node: &mut Node) { } } +/// A code fence node that escapes `${` for Marko output. +/// Used when no custom code block component is configured but output is Marko. +#[derive(Debug)] +struct EscapedCodeFence { + language: Option, + content: String, +} + +impl NodeValue for EscapedCodeFence { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.cr(); + fmt.text_raw("
    ");
    +        // Escape ${ sequences in the content
    +        let escaped = escape_marko_in_html(&self.content);
    +        fmt.text_raw(&escaped);
    +        fmt.text_raw("
    \n"); + } +} + +/// An inline code node that escapes `${` for Marko output. +#[derive(Debug)] +struct EscapedCodeInline { + content: String, +} + +impl NodeValue for EscapedCodeInline { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(""); + let escaped = escape_marko_in_html(&self.content); + fmt.text_raw(&escaped); + fmt.text_raw(""); + } +} + +/// Escape `${` sequences for Marko output in HTML context. +/// This escapes `${` to `\${` to prevent template expression interpretation. +fn escape_marko_in_html(s: &str) -> String { + let mut result = String::with_capacity(s.len() + s.len() / 16); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '<' => result.push_str("<"), + '>' => result.push_str(">"), + '&' => result.push_str("&"), + '"' => result.push_str("""), + '$' if chars.peek() == Some(&'{') => { + // Escape ${ to prevent template expression interpretation + result.push_str("\\${"); + chars.next(); // consume the '{' + } + _ => result.push(c), + } + } + + result +} + +/// Escape code blocks for Marko output. +/// - `escape_block`: if true, escape fenced and indented code blocks +/// - Inline code is always escaped since there's no custom component option for it +pub fn escape_code_blocks_for_marko(node: &mut Node, escape_block: bool) { + // Process children first (bottom-up traversal) + for child in &mut node.children { + escape_code_blocks_for_marko(child, escape_block); + } + + if escape_block { + // Transform fenced code blocks + if let Some(fence) = node.cast::() { + let language = fence + .info + .split_whitespace() + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let content = fence.content.clone(); + + *node = Node::new(EscapedCodeFence { language, content }); + return; + } + + // Transform indented code blocks + if let Some(code) = node.cast::() { + let content = code.content.clone(); + *node = Node::new(EscapedCodeFence { + language: None, + content, + }); + return; + } + } + + // Transform inline code - content is in children as text nodes + // Always escaped since there's no custom component option for inline code + if node + .cast::() + .is_some() + { + let content = node.collect_text(); + *node = Node::new(EscapedCodeInline { content }); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 31e32cffd92c1001538f219b7da125a9e9fc40ae..d8779b0fd740f083f73b4ebf9b1247f527b13c96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,6 +150,17 @@ pub fn transform( component_transforms::transform_components(&mut ast, imports); } + // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions + // - Block code (fenced/indented): only escaped if no custom code_block component is configured + // - Inline code: always escaped since there's no custom component option for it + if format == OutputFormat::Marko { + let escape_block = component_imports + .as_ref() + .map(|i| i.code_block.is_none()) + .unwrap_or(true); + component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block); + } + // Extract statements before rendering - they need to be hoisted above Layout let extracted_statements = hoist_statements(&mut ast); diff --git a/src/marko.rs b/src/marko.rs index f7b93ea3435681cc452ea522b8dbe808776af08f..b28eb4226590a9634f6b0db53d15351268754079 100644 --- a/src/marko.rs +++ b/src/marko.rs @@ -10,6 +10,13 @@ use crate::{ }, }; +/// HTML void elements that are implicitly self-closing. +/// These elements cannot have content and don't need a closing tag. +const VOID_ELEMENTS: &[&str] = &[ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", + "track", "wbr", +]; + pub fn parse_tag(src: &str) -> Result { assert_eq!(src.as_bytes()[0], b'<'); if src.starts_with(" Result { l.expect(">")?; } + // Automatically mark void elements as self-closing + if !self_closing && VOID_ELEMENTS.contains(&tag_name) { + self_closing = true; + } + Ok(Open::new( &src[0..l.offset as usize], Span::new(1, (tag_name.len() + 1) as u32), @@ -717,7 +729,7 @@ mod tests { "", "input", "", - false, + true, // input is a void element ); } @@ -727,7 +739,7 @@ mod tests { "", "input", "", - false, + true, // input is a void element ); } @@ -1027,6 +1039,97 @@ mod tests { assert!(result.is_ok()); } + // =========================================== + // Void elements (implicitly self-closing) + // =========================================== + + #[test] + fn test_void_element_img() { + check_open( + "", + "img", + "", + true, + ); + } + + #[test] + fn test_void_element_img_explicit() { + check_open( + "", + "img", + "", + true, + ); + } + + #[test] + fn test_void_element_br() { + check_open("
    ", "br", "
    ", true); + } + + #[test] + fn test_void_element_hr() { + check_open("
    ", "hr", "
    ", true); + } + + #[test] + fn test_void_element_input() { + check_open( + "", + "input", + "", + true, + ); + } + + #[test] + fn test_void_element_meta() { + check_open( + "", + "meta", + "", + true, + ); + } + + #[test] + fn test_void_element_link() { + check_open( + "", + "link", + "", + true, + ); + } + + #[test] + fn test_void_element_source() { + check_open( + "", + "source", + "", + true, + ); + } + + #[test] + fn test_void_element_wbr() { + check_open("", "wbr", "", true); + } + + #[test] + fn test_non_void_element_div() { + // div is NOT a void element, should not be self-closing + check_open("
    ", "div", "
    ", false); + } + + #[test] + fn test_non_void_element_span() { + // span is NOT a void element, should not be self-closing + check_open("", "span", "", false); + } + #[test] fn test_id_attr_shorthand_static() { let open = parse_open("").unwrap(); diff --git a/src/outline.rs b/src/outline.rs index a50ceaf4d9a7d9eb98df214f4afd17db3a126cf5..4625eee05d7e5c20337e40e5c25346eec0a8cb67 100644 --- a/src/outline.rs +++ b/src/outline.rs @@ -4,12 +4,14 @@ //! into hoisted `` blocks for use in the outline. use markdown_it::{Node, NodeValue, Renderer}; +use oxc_allocator::Allocator; use oxc_diagnostics::OxcDiagnostic; use oxc_span::Span; use std::collections::HashSet; use crate::marko_ast::AttributeValue; use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText}; +use crate::typescript::{parse_expr, parse_expr_extra}; /// A heading entry for the outline, with content reference for hoisting. #[derive(Debug, Clone)] @@ -245,8 +247,20 @@ fn collect_recursive( let id_info = open.open.id; let tag_span = open.open.as_ref().tag_name_span(); let existing_id = match id_info { - AttributeValue::Static { span, .. } => { - Some(open.open.src[span.start as usize..span.end as usize].to_string()) + AttributeValue::Static { span, is_quoted } => { + let string = &open.open.src[span.start as usize..span.end as usize]; + Some(if is_quoted { + let mut allocator = Allocator::new(); + let expr = parse_expr_extra(string, 0, &mut allocator); + match &expr { + Ok(oxc_ast::ast::Expression::StringLiteral(literal)) => { + literal.value.into_string() + } + _ => panic!("verified beforehand as a string literal"), + } + } else { + string.to_owned() + }) } _ => None, }; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index e885957200d86fcd7ed6b8b511b18b2760c8bec3..29046d85aedb18e10434ff72ecede2b15f8f4b0b 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -15,7 +15,11 @@ use oxc_diagnostics::OxcDiagnostic; /// Register all markodown extensions. /// If `markdown_only` is true, skip Marko-specific rules (tags, templates, statements). -pub fn add_all(md: &mut MarkdownIt, markdown_only: bool, clover_extensions: &Option) { +pub fn add_all( + md: &mut MarkdownIt, + markdown_only: bool, + clover_extensions: &Option, +) { // Always add frontmatter parsing (YAML metadata is standard markdown extension) md.block.add_rule::(); diff --git a/src/typescript.rs b/src/typescript.rs index 36ad7ef126f3e12578b26ebe2db15c30b6936f5d..5fb3d586715645556e77667fda6806b0c18a8ff5 100644 --- a/src/typescript.rs +++ b/src/typescript.rs @@ -96,7 +96,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>( }) } -fn parse_expr_extra<'alloc, 'src: 'alloc>( +pub fn parse_expr_extra<'alloc, 'src: 'alloc>( source: &'src str, offset: i32, allocator: &'alloc mut Allocator, diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 39d6e3f3824f9ce3ccbd447bff46cc81cfeb1c9b..a8fc30c3a29bf169638ed63f7496956bc6a647fd 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -218,3 +218,21 @@ fn fixture_24_heading_import() { None, ); } + +#[test] +fn fixture_25_code_block_import() { + run_fixture_with_options( + "25-code-block-import", + None, + Some(ComponentImports { + code_block: Some("./code-block.marko".to_string()), + ..Default::default() + }), + None, + ); +} + +#[test] +fn fixture_26_code_block_import() { + run_fixture_with_options("26-code-block-import", None, None, None); +} diff --git a/tests/fixtures/25-code-block-import.marko b/tests/fixtures/25-code-block-import.marko new file mode 100644 index 0000000000000000000000000000000000000000..5896b278594f027ef94c8e532b4595edd31cd595 --- /dev/null +++ b/tests/fixtures/25-code-block-import.marko @@ -0,0 +1,9 @@ +import CodeBlockComponent__markodown__ from "./code-block.marko"; +

    Code Examples

    + +${"const greeting = `Hello \${name}!`;\nconst value = \${1 + 2};\n" } + + +${"\n
    \${item.name}
    \n\n" } + +

    Inline: hello \${world}

    diff --git a/tests/fixtures/25-code-block-import.mdo b/tests/fixtures/25-code-block-import.mdo new file mode 100644 index 0000000000000000000000000000000000000000..9ffabeedaa0f1163c3960457e1d525cfe247adf1 --- /dev/null +++ b/tests/fixtures/25-code-block-import.mdo @@ -0,0 +1,14 @@ +# Code Examples + +```ts +const greeting = `Hello ${name}!`; +const value = ${1 + 2}; +``` + +```marko + +
    ${item.name}
    + +``` + +Inline: `hello ${world}` diff --git a/tests/fixtures/26-code-block-import.marko b/tests/fixtures/26-code-block-import.marko new file mode 100644 index 0000000000000000000000000000000000000000..7695d08971773d87fd189b0cee53413f395efdec --- /dev/null +++ b/tests/fixtures/26-code-block-import.marko @@ -0,0 +1,13 @@ +

    Code Examples

    +
    const greeting = `Hello \${name}!`;
    +const value = \${1 + 2};
    +
    +
    <for|item| of=items>
    +  <div>\${item.name}</div>
    +</for>
    +
    +

    Inline: hello \${world}

    +

    Multiple: \${a} and \${b}

    +
    indented code block
    +with \${template} expression
    +
    diff --git a/tests/fixtures/26-code-block-import.mdo b/tests/fixtures/26-code-block-import.mdo new file mode 100644 index 0000000000000000000000000000000000000000..86e0fcc65c380d8d73a9b0ab63be16b075045454 --- /dev/null +++ b/tests/fixtures/26-code-block-import.mdo @@ -0,0 +1,19 @@ +# Code Examples + +```ts +const greeting = `Hello ${name}!`; +const value = ${1 + 2}; +``` + +```marko + +
    ${item.name}
    + +``` + +Inline: `hello ${world}` + +Multiple: `${a}` and `${b}` + + indented code block + with ${template} expression diff --git a/wtf.mdo b/wtf.mdo new file mode 100644 index 0000000000000000000000000000000000000000..4e5e26e1e31c7b32f46a34d5e6563f1f1aa6f73f --- /dev/null +++ b/wtf.mdo @@ -0,0 +1,5 @@ +i thought this bug was fixed + +

    aaa mmmm + +wtf!! -- 2.54.0