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 {
180180 if used.code_block {
181181 out.push_str(concat!(
182182 "<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",
184184 "</>\n",
185185 "<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />\n",
186186 ));
......@@ -279,14 +279,11 @@ impl NodeValue for CodeBlockComponentNode {
279279 if let Some(meta) = &self.meta {
280280 fmt.text_raw(&format!(" {}", meta));
281281 }
282
283 fmt.text_raw(">\n");
284 // Escape content for Marko template literal:
285 // - Use {:?} to escape quotes and backslashes
286 // - Additionally escape ${ to prevent nested template expressions
287 let escaped = escape_template_literal(&self.content);
288 fmt.text_raw(&format!("${{\"{}\" }}", escaped));
289 fmt.text_raw("\n</>\n");
282 fmt.text_raw(&format!(
283 " content=\"{}\"",
284 escape_template_literal(&self.content)
285 ));
286 fmt.text_raw("/>\n");
290287 }
291288}
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
461461 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))
462462}
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
464483#[cfg(test)]
465484mod tests {
466485 use super::*;
......@@ -1024,7 +1043,7 @@ mod tests {
10241043 fn layout_code_block_uses_component_in_body() {
10251044 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
10261045 assert!(
1027 out.contains("<CodeBlockComponent__markodown__ language=\"ts\">"),
1046 out.contains("<CodeBlockComponent__markodown__ language=\"ts\""),
10281047 "fenced code block should be replaced with CodeBlockComponent"
10291048 );
10301049 }
......@@ -1033,7 +1052,7 @@ mod tests {
10331052 fn layout_code_block_without_language() {
10341053 let out = run_with_layout("```\nsome code\n```", "./l.marko");
10351054 assert!(
1036 out.contains("<CodeBlockComponent__markodown__>"),
1055 out.contains("<CodeBlockComponent__markodown__"),
10371056 "code block with no language should still use CodeBlockComponent (no language attr)"
10381057 );
10391058 }
src/typescript.rs+51-8
......@@ -8,10 +8,16 @@ use oxc_span::{GetSpan, SourceType};
88
99pub fn scan_first_statement_forbid_trailing(source: &str) -> Result<u32, OxcDiagnostic> {
1010 let mut allocator = Allocator::new();
11 let expr = parse_stmt_extra(source, 0, &mut allocator)?;
12 let span = expr.span();
11 let (stmt, parse_errors) = parse_stmt_extra(source, 0, &mut allocator)?;
12 let span = stmt.span();
1313 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
1521 if let Some(trailing) = source[span.end as usize..].lines().next() {
1622 if !trailing.trim().is_empty() {
1723 return Err(err(
......@@ -28,7 +34,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
2834 source: &'src str,
2935 offset: isize,
3036 allocator: &'alloc mut Allocator,
31) -> Result<Statement<'alloc>, OxcDiagnostic> {
37) -> Result<(Statement<'alloc>, Vec<OxcDiagnostic>), OxcDiagnostic> {
3238 if source.is_empty() {
3339 return Err(err(
3440 "Expected expression, found end of file",
......@@ -43,9 +49,13 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
4349 .with_jsx(false);
4450
4551 let mut result = oxc_parser::Parser::new(allocator, source, source_type).parse();
46 if !result.errors.is_empty() && result.program.body.is_empty() {
47 let first_err = result
48 .errors
52 // Capture errors from the initial full-source parse. These are the only errors
53 // relevant to the caller — errors from truncated-candidate loop iterations below
54 // 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
4959 .into_iter()
5060 .next()
5161 .expect("no errors but no result!");
......@@ -84,17 +94,50 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>(
8494 break;
8595 }
8696 }
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));
87116 } else {
88117 assert!(!result.panicked);
89118 }
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(|| {
92124 err(
93125 "Expected statement",
94126 offset.max(0).cast_unsigned() as u32,
95127 1,
96128 )
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))
98141}
99142
100143pub fn parse_expr_extra<'alloc, 'src: 'alloc>(
src/wasm.rs+2-20
......@@ -27,24 +27,6 @@ struct WasmLabel {
2727 width: u32,
2828}
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
4830/// Convert an OxcDiagnostic to a WasmDiagnostic.
4931/// - Takes the first label and absorbs its position into the root error.
5032/// - If there are multiple labels or labels with text, forward them.
......@@ -53,7 +35,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {
5335
5436 // Determine root line/column from first label, or default to 1:1
5537 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())
5739 } else {
5840 (1, 1)
5941 };
......@@ -72,7 +54,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic {
7254 labels
7355 .iter()
7456 .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());
7658 WasmLabel {
7759 message: label.label().unwrap_or_default().to_string(),
7860 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 @@
11import CodeBlockComponent__markodown__ from "./code-block.marko";
22<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</>
3<CodeBlockComponent__markodown__ language="ts" content="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"/>
95<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";
66</>
77<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
88<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>
1010</>
1111<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />
1212<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />
......@@ -26,7 +26,5 @@ Hello
2626<BlockquoteComponent__markodown__>
2727<p>A blockquote.</p>
2828</>
29<CodeBlockComponent__markodown__ language="ts">
30${"const x = 1;\n" }
31</>
29<CodeBlockComponent__markodown__ language="ts" content="const x = 1;\n"/>
3230</>
wasm.sh+1-1
......@@ -19,7 +19,7 @@ echo "Generating JS bindings..."
1919 "$WASM"
2020, 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
2424cat > lib/bindgen/wasm_bytes.js << EOF
2525/* @ts-self-types="./wasm_bytes.d.ts" */