authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-15 21:47:05-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 00:51:53-08:00
logaca1b27d0ed5c6a73be9851f71733839cb7615f3
tree3d11b1c8e9698039dbc52ecbf5ee58d30297e462
parent36b20a372040175f6f576f1a126970ef239c2229
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: emit statements at top of file


9 files changed, 180 insertions(+), 26 deletions(-)

examples/marko-run/src/tags/markdown-layout.marko+79-2
......@@ -28,9 +28,86 @@ export interface Input {
2828 <${input.content} />
2929 </main>
3030 <aside>
31 <if=input.module?.meta?.description>
32 <p>
33 ${input.module.meta.description}
34 </p>
35 </>
3136 <for|header| of=input.outline>
32 <li>${header.html}</li>
37 <li><a href="#" + header.id>${JSON.stringify(header)}</a></li>
3338 </for>
3439 </aside>
3540 </div>
36</div>
\ No newline at end of file
41</div>
42
43<style>
44 .markdown-layout {
45 max-width: 1200px;
46 margin: 0 auto;
47 padding: 2rem;
48 }
49
50 .markdown-layout > h1 {
51 margin: 0 0 2rem 0;
52 font-size: 2.5rem;
53 font-weight: 700;
54 }
55
56 .markdown-layout > div {
57 display: grid;
58 grid-template-columns: 1fr 280px;
59 gap: 3rem;
60 align-items: start;
61 }
62
63 .markdown-layout main {
64 min-width: 0;
65 }
66
67 .markdown-layout aside {
68 position: sticky;
69 top: 2rem;
70 padding: 1.5rem;
71 background: #f8f9fa;
72 border-radius: 0.5rem;
73 border: 1px solid #e9ecef;
74 }
75
76 .markdown-layout aside p {
77 margin: 0 0 1.5rem 0;
78 color: #495057;
79 line-height: 1.6;
80 }
81
82 .markdown-layout aside ul {
83 list-style: none;
84 padding: 0;
85 margin: 0;
86 }
87
88 .markdown-layout aside li {
89 margin: 0.5rem 0;
90 }
91
92 .markdown-layout aside a {
93 color: #0066cc;
94 text-decoration: none;
95 font-size: 0.9rem;
96 display: block;
97 padding: 0.25rem 0;
98 }
99
100 .markdown-layout aside a:hover {
101 text-decoration: underline;
102 }
103
104 @media (max-width: 768px) {
105 .markdown-layout > div {
106 grid-template-columns: 1fr;
107 }
108
109 .markdown-layout aside {
110 position: static;
111 }
112 }
113</style>
\ No newline at end of file
src/lib.rs+47-14
......@@ -93,6 +93,9 @@ pub fn transform(
9393 Vec::new()
9494 };
9595
96 // Extract statements before rendering - they need to be hoisted above Layout
97 let extracted_statements = extract_statements(&mut ast);
98
9699 let mut text = ast.render();
97100
98101 // Inject heading IDs if we collected headings
......@@ -100,29 +103,37 @@ pub fn transform(
100103 text = plugin::toc::inject_heading_ids(&text, &headings);
101104 }
102105
103 // Prepend preamble output if we extracted it
106 // Build hoisted statements: preamble (frontmatter exports) + extracted statements
107 let mut hoisted = String::new();
104108 if let Some(preamble) = preamble_output {
105 text = preamble + &text;
109 hoisted.push_str(&preamble);
106110 }
111 hoisted.push_str(&extracted_statements);
107112
108113 // Wrap with Layout component if layout_import is provided
114 // Statements must come before the Layout tag
109115 if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) {
110116 if let Some(self_path) = self_path {
111117 text = format!(
112 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n\n<Layout__markodown__ module=self__markodown__ outline={}>\n{}</>",
113 layout_path,
114 self_path,
115 outline,
116 text
117 );
118 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=self__markodown__ outline={}>\n{}</>",
119 layout_path,
120 self_path,
121 hoisted,
122 outline,
123 text
124 );
118125 } else {
119126 text = format!(
120 "import Layout__markodown__ from \"{}\";\n\n<Layout__markodown__ module=null outline={}>\n{}</>",
121 layout_path,
122 outline,
123 text
124 );
127 "import Layout__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=null outline={}>\n{}</>",
128 layout_path,
129 hoisted,
130 outline,
131 text
132 );
125133 }
134 } else {
135 // No Layout wrapping, just prepend hoisted statements
136 text = hoisted + &text;
126137 }
127138
128139 Ok(Output { text, format })
......@@ -166,12 +177,34 @@ pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic {
166177 err
167178}
168179
180/// Extract all StatementBlock content from the AST and clear them.
181/// Returns the collected statement content as a single string.
182fn extract_statements(node: &mut markdown_it::Node) -> String {
183 let mut statements = String::new();
184
185 fn walk(node: &mut markdown_it::Node, statements: &mut String) {
186 if let Some(stmt) = node.cast_mut::<plugin::StatementBlock>() {
187 statements.push_str(&stmt.content);
188 stmt.content.clear();
189 }
190 for child in &mut node.children {
191 walk(child, statements);
192 }
193 }
194
195 walk(node, &mut statements);
196 statements
197}
198
169199/// Check if the AST contains any Marko-specific features
170200fn has_marko_features(node: &markdown_it::Node) -> bool {
171 // Check if this node is a RawBlock
201 // Check if this node is a RawBlock or StatementBlock
172202 if node.cast::<plugin::RawBlock>().is_some() {
173203 return true;
174204 }
205 if node.cast::<plugin::StatementBlock>().is_some() {
206 return true;
207 }
175208
176209 // Check for Marko tags
177210 if node.cast::<MarkoOpen>().is_some()
src/plugin/frontmatter.rs+2-2
......@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};
22use markdown_it::Node;
33use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
44
5use crate::plugin::{get_line_raw, ErrorBlock, RawBlock};
5use crate::plugin::{get_line_raw, ErrorBlock, StatementBlock};
66
77/// Parse frontmatter (---) at document start
88pub(crate) struct Rule;
......@@ -243,6 +243,6 @@ impl BlockRule for Rule {
243243 lines_consumed += 1;
244244 }
245245
246 Some((Node::new(RawBlock { content }), lines_consumed))
246 Some((Node::new(StatementBlock { content }), lines_consumed))
247247 }
248248}
src/plugin/mod.rs+14-1
......@@ -40,7 +40,7 @@ impl NodeValue for ErrorBlock {
4040 }
4141}
4242
43/// A raw node that passes Marko syntax verbaitim
43/// A raw node that passes Marko syntax verbatim
4444#[derive(Debug)]
4545pub(crate) struct RawBlock {
4646 pub content: String,
......@@ -52,6 +52,19 @@ impl NodeValue for RawBlock {
5252 }
5353}
5454
55/// A statement block (import, export, static, server, client) that needs hoisting.
56/// These are kept separate from RawBlock so they can be extracted before Layout wrapping.
57#[derive(Debug)]
58pub struct StatementBlock {
59 pub content: String,
60}
61
62impl NodeValue for StatementBlock {
63 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
64 fmt.text_raw(&self.content);
65 }
66}
67
5568/// Helper to get a line with original whitespace preserved
5669pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str {
5770 if line < state.line_max {
src/plugin/statement.rs+2-2
......@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};
22use markdown_it::Node;
33
44use crate::adjust_err;
5use crate::plugin::{ErrorBlock, RawBlock};
5use crate::plugin::{ErrorBlock, StatementBlock};
66use crate::typescript::scan_first_statement_forbid_trailing;
77
88/// Parse JavaScript statements (import, export, static X, server X, client X)
......@@ -57,7 +57,7 @@ impl BlockRule for Rule {
5757 let content = &unbounded_src[0..unbounded_src.len().min(total_end + 1)];
5858 let line_count = 1 + content[..total_end].chars().filter(|&c| c == '\n').count();
5959
60 let node = Node::new(RawBlock {
60 let node = Node::new(StatementBlock {
6161 content: content.into(),
6262 });
6363 Some((node, line_count))
tests/fixtures.rs+2-2
......@@ -20,7 +20,7 @@ fn run_fixture(name: &str) {
2020 let expected = fs::read_to_string(&html_path)
2121 .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display()));
2222
23 let output = transform(&source, Some(OutputFormat::Html), None).unwrap();
23 let output = transform(&source, Some(OutputFormat::Html), None, None).unwrap();
2424
2525 assert_eq!(
2626 output.format,
......@@ -40,7 +40,7 @@ fn run_fixture(name: &str) {
4040 let expected = fs::read_to_string(&marko_path)
4141 .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display()));
4242
43 let output = transform(&source, Some(OutputFormat::Marko), None).unwrap();
43 let output = transform(&source, Some(OutputFormat::Marko), None, None).unwrap();
4444
4545 assert_eq!(
4646 output.format,
tests/fixtures/13-kitchen-sink.marko+3-3
......@@ -2,12 +2,12 @@ export const title = "Full Example";
22import Chart from "./chart.marko";
33static const year = 2026;
44export const slug = "full-example";
5// Page starts here
6<h1>${title}</h1>
7<p>This is a <strong>complete</strong> example of <em>markodown</em> featuring ${year}.</p>
85server {
96 const data = await fetchData();
107}
8// Page starts here
9<h1>${title}</h1>
10<p>This is a <strong>complete</strong> example of <em>markodown</em> featuring ${year}.</p>
1111<if=data>
1212<h2>Chart</h2>
1313<Chart data=data year=year />
tests/fixtures/23-outline-extracting.marko created+24
......@@ -0,0 +1,24 @@
1<define/Header_1__markodown__>
2good morning
3</>
4<define/Header_2__markodown__>
5good night
6</>
7<define/Header_3__markodown__>
8<strong>snow</strong> time
9</>
10<Layout__markodown__ module=self__markodown__ outline=[
11 { level: 1, id: 'good-morning', content: Header_1__markodown__ },
12 { level: 2, id: 'good-night', content: Header_2__markodown__ },
13 { level: 3, id: 'snow-time', content: Header_3__markodown__ },
14 { level: 4, id: 'id', content: Header_4__markodown__ },
15]>
16<h1#good-morning><Header_1__markodown__/></h1>
17<p>content 1</p>
18<h2#good-night><Header_2__markodown__></h2>
19<p>content 2</p>
20<h3#snow-time><Header_3__markodown__></h3>
21<p>content 3</p>
22<h4#id><Header_4__markodown__></>
23<p>content 4</p>
24</>
tests/fixtures/23-outline-extracting.mdo created+7
......@@ -0,0 +1,7 @@
1<h1>good morning</h1>
2
3## good night
4
5### **snow** time
6
7<h4#id>rain **time**</>