| author | |
| committer | |
| log | aca1b27d0ed5c6a73be9851f71733839cb7615f3 |
| tree | 3d11b1c8e9698039dbc52ecbf5ee58d30297e462 |
| parent | 36b20a372040175f6f576f1a126970ef239c2229 |
| signature |
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 | }; |
| 95 | 95 | ||
| 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(); |
| 97 | 100 | ||
| 98 | // Inject heading IDs if we collected headings | 101 | // 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 | } |
| 102 | 105 | ||
| 103 | // Prepend preamble output if we extracted it | 106 | // 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); | ||
| 107 | 112 | ||
| 108 | // Wrap with Layout component if layout_import is provided | 113 | // 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 | text | 122 | 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 | text | 130 | outline, |
| 124 | ); | 131 | text |
| 132 | ); | ||
| 125 | } | 133 | } |
| 134 | } else { | ||
| 135 | // No Layout wrapping, just prepend hoisted statements | ||
| 136 | text = hoisted + &text; | ||
| 126 | } | 137 | } |
| 127 | 138 | ||
| 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 | err | 177 | err |
| 167 | } | 178 | } |
| 168 | 179 | ||
| 180 | /// Extract all StatementBlock content from the AST and clear them. | ||
| 181 | /// Returns the collected statement content as a single string. | ||
| 182 | fn 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 features | 199 | /// Check if the AST contains any Marko-specific features |
| 170 | fn has_marko_features(node: &markdown_it::Node) -> bool { | 200 | fn 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 |
| 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 | } | ||
| 175 | 208 | ||
| 176 | // Check for Marko tags | 209 | // 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}; |
| 2 | use markdown_it::Node; | 2 | use markdown_it::Node; |
| 3 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | 3 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; |
| 4 | 4 | ||
| 5 | use crate::plugin::{get_line_raw, ErrorBlock, RawBlock}; | 5 | use crate::plugin::{get_line_raw, ErrorBlock, StatementBlock}; |
| 6 | 6 | ||
| 7 | /// Parse frontmatter (---) at document start | 7 | /// Parse frontmatter (---) at document start |
| 8 | pub(crate) struct Rule; | 8 | pub(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 | } |
| 245 | 245 | ||
| 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 | } |
| 42 | 42 | ||
| 43 | /// A raw node that passes Marko syntax verbaitim | 43 | /// A raw node that passes Marko syntax verbatim |
| 44 | #[derive(Debug)] | 44 | #[derive(Debug)] |
| 45 | pub(crate) struct RawBlock { | 45 | pub(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 | } |
| 54 | 54 | ||
| 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)] | ||
| 58 | pub struct StatementBlock { | ||
| 59 | pub content: String, | ||
| 60 | } | ||
| 61 | |||
| 62 | impl 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 preserved | 68 | /// Helper to get a line with original whitespace preserved |
| 56 | pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str { | 69 | pub(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}; |
| 2 | use markdown_it::Node; | 2 | use markdown_it::Node; |
| 3 | 3 | ||
| 4 | use crate::adjust_err; | 4 | use crate::adjust_err; |
| 5 | use crate::plugin::{ErrorBlock, RawBlock}; | 5 | use crate::plugin::{ErrorBlock, StatementBlock}; |
| 6 | use crate::typescript::scan_first_statement_forbid_trailing; | 6 | use crate::typescript::scan_first_statement_forbid_trailing; |
| 7 | 7 | ||
| 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(); |
| 59 | 59 | ||
| 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())); |
| 22 | 22 | ||
| 23 | let output = transform(&source, Some(OutputFormat::Html), None).unwrap(); | 23 | let output = transform(&source, Some(OutputFormat::Html), None, None).unwrap(); |
| 24 | 24 | ||
| 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())); |
| 42 | 42 | ||
| 43 | let output = transform(&source, Some(OutputFormat::Marko), None).unwrap(); | 43 | let output = transform(&source, Some(OutputFormat::Marko), None, None).unwrap(); |
| 44 | 44 | ||
| 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"; |
| 2 | import Chart from "./chart.marko"; | 2 | import Chart from "./chart.marko"; |
| 3 | static const year = 2026; | 3 | static const year = 2026; |
| 4 | export const slug = "full-example"; | 4 | export 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> | ||
| 8 | server { | 5 | server { |
| 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__> | ||
| 2 | good morning | ||
| 3 | </> | ||
| 4 | <define/Header_2__markodown__> | ||
| 5 | good 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**</> | ||