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 {...@@ -28,9 +28,86 @@ export interface Input {
28 <${input.content} />28 <${input.content} />
29 </main>29 </main>
30 <aside>30 <aside>
31 <if=input.module?.meta?.description>
32 <p>
33 ${input.module.meta.description}
34 </p>
35 </>
31 <for|header| of=input.outline>36 <for|header| of=input.outline>
32 <li>${header.html}</li>37 <li><a href="#" + header.id>${JSON.stringify(header)}</a></li>
33 </for>38 </for>
34 </aside>39 </aside>
35 </div>40 </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(...@@ -93,6 +93,9 @@ pub fn transform(
93 Vec::new()93 Vec::new()
94 };94 };
9595
96 // Extract statements before rendering - they need to be hoisted above Layout
97 let extracted_statements = extract_statements(&mut ast);
98
96 let mut text = ast.render();99 let mut text = ast.render();
97100
98 // Inject heading IDs if we collected headings101 // Inject heading IDs if we collected headings
...@@ -100,29 +103,37 @@ pub fn transform(...@@ -100,29 +103,37 @@ pub fn transform(
100 text = plugin::toc::inject_heading_ids(&text, &headings);103 text = plugin::toc::inject_heading_ids(&text, &headings);
101 }104 }
102105
103 // Prepend preamble output if we extracted it106 // Build hoisted statements: preamble (frontmatter exports) + extracted statements
107 let mut hoisted = String::new();
104 if let Some(preamble) = preamble_output {108 if let Some(preamble) = preamble_output {
105 text = preamble + &text;109 hoisted.push_str(&preamble);
106 }110 }
111 hoisted.push_str(&extracted_statements);
107112
108 // Wrap with Layout component if layout_import is provided113 // Wrap with Layout component if layout_import is provided
114 // Statements must come before the Layout tag
109 if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) {115 if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) {
110 if let Some(self_path) = self_path {116 if let Some(self_path) = self_path {
111 text = format!(117 text = format!(
112 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n\n<Layout__markodown__ module=self__markodown__ outline={}>\n{}</>",118 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=self__markodown__ outline={}>\n{}</>",
113 layout_path,119 layout_path,
114 self_path,120 self_path,
115 outline,121 hoisted,
116 text122 outline,
117 );123 text
124 );
118 } else {125 } else {
119 text = format!(126 text = format!(
120 "import Layout__markodown__ from \"{}\";\n\n<Layout__markodown__ module=null outline={}>\n{}</>",127 "import Layout__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=null outline={}>\n{}</>",
121 layout_path,128 layout_path,
122 outline,129 hoisted,
123 text130 outline,
124 );131 text
132 );
125 }133 }
134 } else {
135 // No Layout wrapping, just prepend hoisted statements
136 text = hoisted + &text;
126 }137 }
127138
128 Ok(Output { text, format })139 Ok(Output { text, format })
...@@ -166,12 +177,34 @@ pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic {...@@ -166,12 +177,34 @@ pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic {
166 err177 err
167}178}
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
169/// Check if the AST contains any Marko-specific features199/// Check if the AST contains any Marko-specific features
170fn has_marko_features(node: &markdown_it::Node) -> bool {200fn has_marko_features(node: &markdown_it::Node) -> bool {
171 // Check if this node is a RawBlock201 // Check if this node is a RawBlock or StatementBlock
172 if node.cast::<plugin::RawBlock>().is_some() {202 if node.cast::<plugin::RawBlock>().is_some() {
173 return true;203 return true;
174 }204 }
205 if node.cast::<plugin::StatementBlock>().is_some() {
206 return true;
207 }
175208
176 // Check for Marko tags209 // Check for Marko tags
177 if node.cast::<MarkoOpen>().is_some()210 if node.cast::<MarkoOpen>().is_some()
src/plugin/frontmatter.rs+2-2
...@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};...@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;2use markdown_it::Node;
3use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};3use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
44
5use crate::plugin::{get_line_raw, ErrorBlock, RawBlock};5use crate::plugin::{get_line_raw, ErrorBlock, StatementBlock};
66
7/// Parse frontmatter (---) at document start7/// Parse frontmatter (---) at document start
8pub(crate) struct Rule;8pub(crate) struct Rule;
...@@ -243,6 +243,6 @@ impl BlockRule for Rule {...@@ -243,6 +243,6 @@ impl BlockRule for Rule {
243 lines_consumed += 1;243 lines_consumed += 1;
244 }244 }
245245
246 Some((Node::new(RawBlock { content }), lines_consumed))246 Some((Node::new(StatementBlock { content }), lines_consumed))
247 }247 }
248}248}
src/plugin/mod.rs+14-1
...@@ -40,7 +40,7 @@ impl NodeValue for ErrorBlock {...@@ -40,7 +40,7 @@ impl NodeValue for ErrorBlock {
40 }40 }
41}41}
4242
43/// A raw node that passes Marko syntax verbaitim43/// A raw node that passes Marko syntax verbatim
44#[derive(Debug)]44#[derive(Debug)]
45pub(crate) struct RawBlock {45pub(crate) struct RawBlock {
46 pub content: String,46 pub content: String,
...@@ -52,6 +52,19 @@ impl NodeValue for RawBlock {...@@ -52,6 +52,19 @@ impl NodeValue for RawBlock {
52 }52 }
53}53}
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
55/// Helper to get a line with original whitespace preserved68/// Helper to get a line with original whitespace preserved
56pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str {69pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str {
57 if line < state.line_max {70 if line < state.line_max {
src/plugin/statement.rs+2-2
...@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};...@@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::Node;2use markdown_it::Node;
33
4use crate::adjust_err;4use crate::adjust_err;
5use crate::plugin::{ErrorBlock, RawBlock};5use crate::plugin::{ErrorBlock, StatementBlock};
6use crate::typescript::scan_first_statement_forbid_trailing;6use crate::typescript::scan_first_statement_forbid_trailing;
77
8/// Parse JavaScript statements (import, export, static X, server X, client X)8/// Parse JavaScript statements (import, export, static X, server X, client X)
...@@ -57,7 +57,7 @@ impl BlockRule for Rule {...@@ -57,7 +57,7 @@ impl BlockRule for Rule {
57 let content = &unbounded_src[0..unbounded_src.len().min(total_end + 1)];57 let content = &unbounded_src[0..unbounded_src.len().min(total_end + 1)];
58 let line_count = 1 + content[..total_end].chars().filter(|&c| c == '\n').count();58 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 {
61 content: content.into(),61 content: content.into(),
62 });62 });
63 Some((node, line_count))63 Some((node, line_count))
tests/fixtures.rs+2-2
...@@ -20,7 +20,7 @@ fn run_fixture(name: &str) {...@@ -20,7 +20,7 @@ fn run_fixture(name: &str) {
20 let expected = fs::read_to_string(&html_path)20 let expected = fs::read_to_string(&html_path)
21 .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display()));21 .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
25 assert_eq!(25 assert_eq!(
26 output.format,26 output.format,
...@@ -40,7 +40,7 @@ fn run_fixture(name: &str) {...@@ -40,7 +40,7 @@ fn run_fixture(name: &str) {
40 let expected = fs::read_to_string(&marko_path)40 let expected = fs::read_to_string(&marko_path)
41 .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display()));41 .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
45 assert_eq!(45 assert_eq!(
46 output.format,46 output.format,
tests/fixtures/13-kitchen-sink.marko+3-3
...@@ -2,12 +2,12 @@ export const title = "Full Example";...@@ -2,12 +2,12 @@ export const title = "Full Example";
2import Chart from "./chart.marko";2import Chart from "./chart.marko";
3static const year = 2026;3static const year = 2026;
4export const slug = "full-example";4export 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>
8server {5server {
9 const data = await fetchData();6 const data = await fetchData();
10}7}
8// Page starts here
9<h1>${title}</h1>
10<p>This is a <strong>complete</strong> example of <em>markodown</em> featuring ${year}.</p>
11<if=data>11<if=data>
12<h2>Chart</h2>12<h2>Chart</h2>
13<Chart data=data year=year />13<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**</>