| 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 | 28 | <${input.content} /> |
| 29 | 29 | </main> |
| 30 | 30 | <aside> |
| 31 | <if=input.module?.meta?.description> | |
| 32 | <p> | |
| 33 | ${input.module.meta.description} | |
| 34 | </p> | |
| 35 | </> | |
| 31 | 36 | <for|header| of=input.outline> |
| 32 | <li>${header.html}</li> | |
| 37 | <li><a href="#" + header.id>${JSON.stringify(header)}</a></li> | |
| 33 | 38 | </for> |
| 34 | 39 | </aside> |
| 35 | 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 | 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 | 99 | let mut text = ast.render(); |
| 97 | 100 | |
| 98 | 101 | // Inject heading IDs if we collected headings |
| ... | ... | @@ -100,29 +103,37 @@ pub fn transform( |
| 100 | 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 | 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 | 113 | // Wrap with Layout component if layout_import is provided |
| 114 | // Statements must come before the Layout tag | |
| 109 | 115 | if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) { |
| 110 | 116 | if let Some(self_path) = self_path { |
| 111 | 117 | 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 | ); | |
| 118 | 125 | } else { |
| 119 | 126 | 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 | ); | |
| 125 | 133 | } |
| 134 | } else { | |
| 135 | // No Layout wrapping, just prepend hoisted statements | |
| 136 | text = hoisted + &text; | |
| 126 | 137 | } |
| 127 | 138 | |
| 128 | 139 | Ok(Output { text, format }) |
| ... | ... | @@ -166,12 +177,34 @@ pub fn adjust_err(mut err: OxcDiagnostic, offset: isize) -> OxcDiagnostic { |
| 166 | 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 | 199 | /// Check if the AST contains any Marko-specific features |
| 170 | 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 | 202 | if node.cast::<plugin::RawBlock>().is_some() { |
| 173 | 203 | return true; |
| 174 | 204 | } |
| 205 | if node.cast::<plugin::StatementBlock>().is_some() { | |
| 206 | return true; | |
| 207 | } | |
| 175 | 208 | |
| 176 | 209 | // Check for Marko tags |
| 177 | 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 | 2 | use markdown_it::Node; |
| 3 | 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 | 7 | /// Parse frontmatter (---) at document start |
| 8 | 8 | pub(crate) struct Rule; |
| ... | ... | @@ -243,6 +243,6 @@ impl BlockRule for Rule { |
| 243 | 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 | 40 | } |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | /// A raw node that passes Marko syntax verbaitim | |
| 43 | /// A raw node that passes Marko syntax verbatim | |
| 44 | 44 | #[derive(Debug)] |
| 45 | 45 | pub(crate) struct RawBlock { |
| 46 | 46 | pub content: String, |
| ... | ... | @@ -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 | 68 | /// Helper to get a line with original whitespace preserved |
| 56 | 69 | pub(crate) fn get_line_raw<'a, 'b>(state: &'a BlockState<'a, 'b>, line: usize) -> &'b str { |
| 57 | 70 | if line < state.line_max { |
src/plugin/statement.rs+2-2| ... | ... | @@ -2,7 +2,7 @@ use markdown_it::parser::block::{BlockRule, BlockState}; |
| 2 | 2 | use markdown_it::Node; |
| 3 | 3 | |
| 4 | 4 | use crate::adjust_err; |
| 5 | use crate::plugin::{ErrorBlock, RawBlock}; | |
| 5 | use crate::plugin::{ErrorBlock, StatementBlock}; | |
| 6 | 6 | use crate::typescript::scan_first_statement_forbid_trailing; |
| 7 | 7 | |
| 8 | 8 | /// Parse JavaScript statements (import, export, static X, server X, client X) |
| ... | ... | @@ -57,7 +57,7 @@ impl BlockRule for Rule { |
| 57 | 57 | let content = &unbounded_src[0..unbounded_src.len().min(total_end + 1)]; |
| 58 | 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 | 61 | content: content.into(), |
| 62 | 62 | }); |
| 63 | 63 | Some((node, line_count)) |
tests/fixtures.rs+2-2| ... | ... | @@ -20,7 +20,7 @@ fn run_fixture(name: &str) { |
| 20 | 20 | let expected = fs::read_to_string(&html_path) |
| 21 | 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 | 25 | assert_eq!( |
| 26 | 26 | output.format, |
| ... | ... | @@ -40,7 +40,7 @@ fn run_fixture(name: &str) { |
| 40 | 40 | let expected = fs::read_to_string(&marko_path) |
| 41 | 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 | 45 | assert_eq!( |
| 46 | 46 | output.format, |
tests/fixtures/13-kitchen-sink.marko+3-3| ... | ... | @@ -2,12 +2,12 @@ export const title = "Full Example"; |
| 2 | 2 | import Chart from "./chart.marko"; |
| 3 | 3 | static const year = 2026; |
| 4 | 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 | 5 | server { |
| 9 | 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 | 11 | <if=data> |
| 12 | 12 | <h2>Chart</h2> |
| 13 | 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**</> |