authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-18 00:08:58-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-18 02:46:06-08:00
log3c0d72e45ae96aa23bb1c9a13364fd949e90098d
treeae1a7ce6613416ecafeeebc21745c81a20fd26b9
parentce070f60cf2f9d6c1966b3cf36c1efc1ff81e796
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: some more bugs


22 files changed, 259 insertions(+), 50 deletions(-)

src/component_transforms.rs+6-9
...@@ -180,7 +180,7 @@ pub fn generate_layout_boilerplate(used: &UsedElements) -> String {...@@ -180,7 +180,7 @@ pub fn generate_layout_boilerplate(used: &UsedElements) -> String {
180 if used.code_block {180 if used.code_block {
181 out.push_str(concat!(181 out.push_str(concat!(
182 "<define/CodeBlockComponentFallback__markodown__|{ language, content }|>\n",182 "<define/CodeBlockComponentFallback__markodown__|{ language, content }|>\n",
183 " <pre><code class=(language && 'language-' + language)><${content}/></code></pre>\n",183 " <pre><code class=(language && 'language-' + language)>${content}</code></pre>\n",
184 "</>\n",184 "</>\n",
185 "<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />\n",185 "<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />\n",
186 ));186 ));
...@@ -279,14 +279,11 @@ impl NodeValue for CodeBlockComponentNode {...@@ -279,14 +279,11 @@ impl NodeValue for CodeBlockComponentNode {
279 if let Some(meta) = &self.meta {279 if let Some(meta) = &self.meta {
280 fmt.text_raw(&format!(" {}", meta));280 fmt.text_raw(&format!(" {}", meta));
281 }281 }
282282 fmt.text_raw(&format!(
283 fmt.text_raw(">\n");283 " content=\"{}\"",
284 // Escape content for Marko template literal:284 escape_template_literal(&self.content)
285 // - Use {:?} to escape quotes and backslashes285 ));
286 // - Additionally escape ${ to prevent nested template expressions286 fmt.text_raw("/>\n");
287 let escaped = escape_template_literal(&self.content);
288 fmt.text_raw(&format!("${{\"{}\" }}", escaped));
289 fmt.text_raw("\n</>\n");
290 }287 }
291}288}
292289
src/lib.rs+21-2
...@@ -461,6 +461,25 @@ pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> Ox...@@ -461,6 +461,25 @@ pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> Ox
461 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))461 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))
462}462}
463463
464/// Convert a byte offset into `src` to 1-indexed (line, column).
465/// Used by both the WASM layer and error fixture tests.
466pub fn offset_to_line_col(src: &str, offset: usize) -> (u32, u32) {
467 let mut line = 1u32;
468 let mut col = 1u32;
469 for (i, ch) in src.char_indices() {
470 if i >= offset {
471 break;
472 }
473 if ch == '\n' {
474 line += 1;
475 col = 1;
476 } else {
477 col += 1;
478 }
479 }
480 (line, col)
481}
482
464#[cfg(test)]483#[cfg(test)]
465mod tests {484mod tests {
466 use super::*;485 use super::*;
...@@ -1024,7 +1043,7 @@ mod tests {...@@ -1024,7 +1043,7 @@ mod tests {
1024 fn layout_code_block_uses_component_in_body() {1043 fn layout_code_block_uses_component_in_body() {
1025 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");1044 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
1026 assert!(1045 assert!(
1027 out.contains("<CodeBlockComponent__markodown__ language=\"ts\">"),1046 out.contains("<CodeBlockComponent__markodown__ language=\"ts\""),
1028 "fenced code block should be replaced with CodeBlockComponent"1047 "fenced code block should be replaced with CodeBlockComponent"
1029 );1048 );
1030 }1049 }
...@@ -1033,7 +1052,7 @@ mod tests {...@@ -1033,7 +1052,7 @@ mod tests {
1033 fn layout_code_block_without_language() {1052 fn layout_code_block_without_language() {
1034 let out = run_with_layout("```\nsome code\n```", "./l.marko");1053 let out = run_with_layout("```\nsome code\n```", "./l.marko");
1035 assert!(1054 assert!(
1036 out.contains("<CodeBlockComponent__markodown__>"),1055 out.contains("<CodeBlockComponent__markodown__"),
1037 "code block with no language should still use CodeBlockComponent (no language attr)"1056 "code block with no language should still use CodeBlockComponent (no language attr)"
1038 );1057 );
1039 }1058 }
src/typescript.rs+51-8
...@@ -8,10 +8,16 @@ use oxc_span::{GetSpan, SourceType};...@@ -8,10 +8,16 @@ use oxc_span::{GetSpan, SourceType};
88
9pub fn scan_first_statement_forbid_trailing(source: &str) -> Result<u32, OxcDiagnostic> {9pub fn scan_first_statement_forbid_trailing(source: &str) -> Result<u32, OxcDiagnostic> {
10 let mut allocator = Allocator::new();10 let mut allocator = Allocator::new();
11 let expr = parse_stmt_extra(source, 0, &mut allocator)?;11 let (stmt, parse_errors) = parse_stmt_extra(source, 0, &mut allocator)?;
12 let span = expr.span();12 let span = stmt.span();
13 let len = span.end - span.start;13 let len = span.end - span.start;
1414
15 // If OXC reported parse errors while recovering the statement, surface the
16 // first one directly — it points at the actual invalid token.
17 if let Some(first) = parse_errors.into_iter().next() {
18 return Err(first);
19 }
20
15 if let Some(trailing) = source[span.end as usize..].lines().next() {21 if let Some(trailing) = source[span.end as usize..].lines().next() {
16 if !trailing.trim().is_empty() {22 if !trailing.trim().is_empty() {
17 return Err(err(23 return Err(err(
...@@ -28,7 +34,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(...@@ -28,7 +34,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
28 source: &'src str,34 source: &'src str,
29 offset: isize,35 offset: isize,
30 allocator: &'alloc mut Allocator,36 allocator: &'alloc mut Allocator,
31) -> Result<Statement<'alloc>, OxcDiagnostic> {37) -> Result<(Statement<'alloc>, Vec<OxcDiagnostic>), OxcDiagnostic> {
32 if source.is_empty() {38 if source.is_empty() {
33 return Err(err(39 return Err(err(
34 "Expected expression, found end of file",40 "Expected expression, found end of file",
...@@ -43,9 +49,13 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(...@@ -43,9 +49,13 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
43 .with_jsx(false);49 .with_jsx(false);
4450
45 let mut result = oxc_parser::Parser::new(allocator, source, source_type).parse();51 let mut result = oxc_parser::Parser::new(allocator, source, source_type).parse();
46 if !result.errors.is_empty() && result.program.body.is_empty() {52 // Capture errors from the initial full-source parse. These are the only errors
47 let first_err = result53 // relevant to the caller — errors from truncated-candidate loop iterations below
48 .errors54 // are artifacts of parsing incomplete source and must not be surfaced.
55 let initial_errors = std::mem::take(&mut result.errors);
56
57 if !initial_errors.is_empty() && result.program.body.is_empty() {
58 let first_err = initial_errors
49 .into_iter()59 .into_iter()
50 .next()60 .next()
51 .expect("no errors but no result!");61 .expect("no errors but no result!");
...@@ -84,17 +94,50 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(...@@ -84,17 +94,50 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
84 break;94 break;
85 }95 }
86 }96 }
97
98 let stmt = result.program.body.into_iter().next().ok_or_else(|| {
99 err(
100 "Expected statement",
101 offset.max(0).cast_unsigned() as u32,
102 1,
103 )
104 })?;
105 // Surface the OXC error only if it falls within the same line as the
106 // recovered statement (i.e. no newline between statement end and error).
107 // If there's a newline before the error, it's trailing content (markdown
108 // or another statement) that the caller will handle.
109 let stmt_end = stmt.span().end as usize;
110 let errors = if !source[stmt_end..first_err_offset].contains('\n') {
111 vec![adjust_err(first_err, offset)]
112 } else {
113 vec![]
114 };
115 return Ok((stmt, errors));
87 } else {116 } else {
88 assert!(!result.panicked);117 assert!(!result.panicked);
89 }118 }
90119
91 result.program.body.into_iter().next().ok_or_else(|| {120 // Full-source parse succeeded; surface only errors that fall within the statement's
121 // span. Errors beyond the span are about trailing content (markdown, other statements)
122 // and should be handled by the trailing-content check in the caller, not here.
123 let stmt = result.program.body.into_iter().next().ok_or_else(|| {
92 err(124 err(
93 "Expected statement",125 "Expected statement",
94 offset.max(0).cast_unsigned() as u32,126 offset.max(0).cast_unsigned() as u32,
95 1,127 1,
96 )128 )
97 })129 })?;
130 let stmt_end = stmt.span().end as usize;
131 let within_stmt_errors: Vec<OxcDiagnostic> = initial_errors
132 .into_iter()
133 .filter(|e| {
134 e.labels
135 .as_deref()
136 .and_then(|l| l.first())
137 .map_or(false, |l| l.offset() < stmt_end)
138 })
139 .collect();
140 Ok((stmt, within_stmt_errors))
98}141}
99142
100pub fn parse_expr_extra<'alloc, 'src: 'alloc>(143pub fn parse_expr_extra<'alloc, 'src: 'alloc>(
src/wasm.rs+2-20
...@@ -27,24 +27,6 @@ struct WasmLabel {...@@ -27,24 +27,6 @@ struct WasmLabel {
27 width: u32,27 width: u32,
28}28}
2929
30/// Convert a byte offset to 1-indexed line and column numbers.
31fn offset_to_line_col(src: &str, offset: usize) -> (u32, u32) {
32 let mut line = 1u32;
33 let mut col = 1u32;
34 for (i, ch) in src.char_indices() {
35 if i >= offset {
36 break;
37 }
38 if ch == '\n' {
39 line += 1;
40 col = 1;
41 } else {
42 col += 1;
43 }
44 }
45 (line, col)
46}
47
48/// Convert an OxcDiagnostic to a WasmDiagnostic.30/// Convert an OxcDiagnostic to a WasmDiagnostic.
49/// - Takes the first label and absorbs its position into the root error.31/// - Takes the first label and absorbs its position into the root error.
50/// - If there are multiple labels or labels with text, forward them.32/// - If there are multiple labels or labels with text, forward them.
...@@ -53,7 +35,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {...@@ -53,7 +35,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {
5335
54 // Determine root line/column from first label, or default to 1:136 // Determine root line/column from first label, or default to 1:1
55 let (line, column) = if let Some(first) = labels.first() {37 let (line, column) = if let Some(first) = labels.first() {
56 offset_to_line_col(src, first.offset())38 crate::offset_to_line_col(src, first.offset())
57 } else {39 } else {
58 (1, 1)40 (1, 1)
59 };41 };
...@@ -72,7 +54,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {...@@ -72,7 +54,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {
72 labels54 labels
73 .iter()55 .iter()
74 .map(|label| {56 .map(|label| {
75 let (l, c) = offset_to_line_col(src, label.offset());57 let (l, c) = crate::offset_to_line_col(src, label.offset());
76 WasmLabel {58 WasmLabel {
77 message: label.label().unwrap_or_default().to_string(),59 message: label.label().unwrap_or_default().to_string(),
78 line: l,60 line: l,
tests/error-fixtures.rs created+142
...@@ -0,0 +1,142 @@
1use markodown::{offset_to_line_col, transform, ComponentImports};
2use std::fs;
3use std::path::Path;
4
5/// Run an error fixture: transform the `.mdo` input, expect it to fail,
6/// and compare the formatted errors against the `.err` file.
7///
8/// Each line of the `.err` file is `line:col message`, e.g.:
9/// 3:5 Mismatched closing tag: expected </div>, found </span>
10///
11/// The first label on each diagnostic determines the reported position.
12/// Diagnostics with no labels are reported as `1:1`.
13fn run_error_fixture(name: &str) {
14 run_error_fixture_with_options(name, None, None);
15}
16
17fn run_error_fixture_with_options(
18 name: &str,
19 layout: Option<&str>,
20 component_imports: Option<ComponentImports>,
21) {
22 let base = Path::new("tests/error-fixtures").join(name);
23 let input_path = base.with_extension("mdo");
24 let err_path = base.with_extension("err");
25
26 let source = fs::read_to_string(&input_path)
27 .unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display()));
28 let expected = fs::read_to_string(&err_path)
29 .unwrap_or_else(|e| panic!("failed to read {}: {e}", err_path.display()));
30
31 let result = transform(
32 &source,
33 None,
34 layout.map(|s| s.to_string()),
35 component_imports,
36 None,
37 false,
38 None,
39 );
40
41 let errors = match result {
42 Err(e) => e,
43 Ok(_) => panic!("[{name}] expected transform to fail but it succeeded"),
44 };
45
46 // Format each diagnostic as:
47 // error: <message>
48 // line:col+len [label message]
49 // line:col+len [label message]
50 // Multiple errors are separated by a blank line.
51 let actual_lines: Vec<String> = errors
52 .iter()
53 .map(|diag| {
54 let mut out = format!("error: {}", diag.message);
55 if let Some(labels) = diag.labels.as_deref() {
56 for label in labels {
57 let (line, col) = offset_to_line_col(&source, label.offset());
58 let len = label.len();
59 if let Some(msg) = label.label() {
60 out.push_str(&format!("\n {line}:{col}+{len} {msg}"));
61 } else {
62 out.push_str(&format!("\n {line}:{col}+{len}"));
63 }
64 }
65 }
66 out
67 })
68 .collect();
69
70 let actual = actual_lines.join("\n\n");
71 let expected = expected.trim();
72 let actual = actual.trim();
73
74 assert_eq!(
75 actual,
76 expected,
77 "\n\n[{name}] error output mismatch\n\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n"
78 );
79}
80
81// -------------------------------------------------------------------------
82// Mismatched close tag
83// -------------------------------------------------------------------------
84
85#[test]
86fn error_fixture_mismatched_close() {
87 run_error_fixture("mismatched-close");
88}
89
90// -------------------------------------------------------------------------
91// Unclosed tag
92// -------------------------------------------------------------------------
93
94#[test]
95fn error_fixture_unclosed_tag() {
96 run_error_fixture("unclosed-tag");
97}
98
99// -------------------------------------------------------------------------
100// Close without open
101// -------------------------------------------------------------------------
102
103#[test]
104fn error_fixture_close_without_open() {
105 run_error_fixture("close-without-open");
106}
107
108// -------------------------------------------------------------------------
109// Unclosed inline tag
110// -------------------------------------------------------------------------
111
112#[test]
113fn error_fixture_inline_unclosed() {
114 run_error_fixture("inline-unclosed");
115}
116
117// -------------------------------------------------------------------------
118// Invalid template expression
119// -------------------------------------------------------------------------
120
121#[test]
122fn error_fixture_bad_template() {
123 run_error_fixture("bad-template");
124}
125
126// -------------------------------------------------------------------------
127// Malformed import/statement
128// -------------------------------------------------------------------------
129
130#[test]
131fn error_fixture_bad_statement() {
132 run_error_fixture("bad-statement");
133}
134
135// -------------------------------------------------------------------------
136// Invalid YAML frontmatter
137// -------------------------------------------------------------------------
138
139#[test]
140fn error_fixture_bad_frontmatter() {
141 run_error_fixture("bad-frontmatter");
142}
tests/error-fixtures/bad-frontmatter.err created+2
...@@ -0,0 +1,2 @@
1error: YAML syntax error: did not find expected ',' or ']' at line 3 column 1, while parsing a flow sequence at line 2 column 9
2 3:16+1
tests/error-fixtures/bad-frontmatter.mdo created+6
...@@ -0,0 +1,6 @@
1---
2title: valid
3broken: [unclosed
4---
5
6Some content.
tests/error-fixtures/bad-statement.err created+2
...@@ -0,0 +1,2 @@
1error: Expected `from` but found `@`
2 1:10+1 `from` expected
tests/error-fixtures/bad-statement.mdo created+1
...@@ -0,0 +1 @@
1import @@@ from "nowhere";
tests/error-fixtures/bad-template.err created+2
...@@ -0,0 +1,2 @@
1error: Unexpected token
2 1:22+1
tests/error-fixtures/bad-template.mdo created+1
...@@ -0,0 +1 @@
1Some text ${@@invalid} here.
tests/error-fixtures/close-without-open.err created+2
...@@ -0,0 +1,2 @@
1error: Closing tag </div> without matching open
2 3:3+3
tests/error-fixtures/close-without-open.mdo created+3
...@@ -0,0 +1,3 @@
1hello world
2
3</div>
tests/error-fixtures/inline-unclosed.err created+2
...@@ -0,0 +1,2 @@
1error: Unclosed inline tag <b>
2 1:14+1
tests/error-fixtures/inline-unclosed.mdo created+1
...@@ -0,0 +1 @@
1This has an <b>unclosed inline tag.
tests/error-fixtures/mismatched-close.err created+3
...@@ -0,0 +1,3 @@
1error: Mismatched closing tag: expected </div>, found </span>
2 1:1+4 opened here
3 3:3+4 closed here
tests/error-fixtures/mismatched-close.mdo created+3
...@@ -0,0 +1,3 @@
1<div>
2hello
3</span>
tests/error-fixtures/unclosed-tag.err created+2
...@@ -0,0 +1,2 @@
1error: Unclosed tag <div>
2 1:1+4
tests/error-fixtures/unclosed-tag.mdo created+2
...@@ -0,0 +1,2 @@
1<div>
2hello world
tests/fixtures/25-code-block-import.marko+2-6
...@@ -1,9 +1,5 @@...@@ -1,9 +1,5 @@
1import CodeBlockComponent__markodown__ from "./code-block.marko";1import CodeBlockComponent__markodown__ from "./code-block.marko";
2<h1>Code Examples</h1>2<h1>Code Examples</h1>
3<CodeBlockComponent__markodown__ language="ts">3<CodeBlockComponent__markodown__ language="ts" content="const greeting = `Hello \${name}!`;\nconst value = \${1 + 2};\n"/>
4${"const greeting = `Hello \${name}!`;\nconst value = \${1 + 2};\n" }4<CodeBlockComponent__markodown__ language="marko" content="<for|item| of=items>\n <div>\${item.name}</div>\n</for>\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>5<p>Inline: <code>hello \${world}</code></p>
tests/fixtures/29-layout-all-components.marko+2-4
...@@ -6,7 +6,7 @@ import * as LayoutModule__markodown__ from "./layout.marko";...@@ -6,7 +6,7 @@ import * as LayoutModule__markodown__ from "./layout.marko";
6</>6</>
7<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />7<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
8<define/CodeBlockComponentFallback__markodown__|{ language, content }|>8<define/CodeBlockComponentFallback__markodown__|{ language, content }|>
9 <pre><code class=(language && 'language-' + language)><${content}/></code></pre>9 <pre><code class=(language && 'language-' + language)>${content}</code></pre>
10</>10</>
11<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />11<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />
12<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />12<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />
...@@ -26,7 +26,5 @@ Hello...@@ -26,7 +26,5 @@ Hello
26<BlockquoteComponent__markodown__>26<BlockquoteComponent__markodown__>
27<p>A blockquote.</p>27<p>A blockquote.</p>
28</>28</>
29<CodeBlockComponent__markodown__ language="ts">29<CodeBlockComponent__markodown__ language="ts" content="const x = 1;\n"/>
30${"const x = 1;\n" }
31</>
32</>30</>
wasm.sh+1-1
...@@ -19,7 +19,7 @@ echo "Generating JS bindings..."...@@ -19,7 +19,7 @@ echo "Generating JS bindings..."
19 "$WASM"19 "$WASM"
20, wasm-opt -Os lib/bindgen/markodown_bg.wasm -o lib/bindgen/markodown_bg.wasm20, wasm-opt -Os lib/bindgen/markodown_bg.wasm -o lib/bindgen/markodown_bg.wasm
2121
22BASE64=$(base64 < "lib/bindgen/markodown_bg.wasm")22BASE64="$(base64 -w 0 < "lib/bindgen/markodown_bg.wasm")"
2323
24cat > lib/bindgen/wasm_bytes.js << EOF24cat > lib/bindgen/wasm_bytes.js << EOF
25/* @ts-self-types="./wasm_bytes.d.ts" */25/* @ts-self-types="./wasm_bytes.d.ts" */