authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 21:49:05-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-18 02:46:06-08:00
logd25d757a902f98e69e9178395f7857907d37d030
treec5511e80dbc498516f059c0ec7c4259581865272
parent42a5c17e506a403cb512b3b0911376820175109a
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

chore: ok it might be usable now


20 files changed, 1481 insertions(+), 45 deletions(-)

README.md+74-16
......@@ -14,13 +14,14 @@ leveraging the existing ecosystem.
1414
1515> **CONTENTS**:
1616>
17> - [Install](#install)
18> - [Components](#components)
19> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#frontmatter)
21> - [Comments](#comments)
22> - [Paragraph Detection](#paragraph-detection)
23> - [Static Statements](#static-statements)
17> - [Usage](#usage)
18> - [Components](#components)
19> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#frontmatter)
21> - [Comments](#comments)
22> - [Paragraph Detection](#paragraph-detection)
23> - [Static Statements](#static-statements)
24> - [Config](#config)
2425
2526Here's a glance at how things look. Complete example documents in <./examples>
2627
......@@ -71,14 +72,15 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
7172</blog-layout>
7273````
7374
74## Install
75## Usage
7576
7677Markodown is distributed on
7778[NPM](https://npmjs.com/package/@paperclover/markodown) and
7879[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7980
8081```sh
81npm i @paperclover/markodown
82# alias install
83npm i @clo/markodown@npm:@paperclover/markodown
8284# or
8385npx jsr add @clo/markodown
8486```
......@@ -89,7 +91,7 @@ Marko Run:
8991
9092```ts
9193import marko from "@marko/run/vite";
92import markodown from "markodown";
94import markodown from "@clo/markodown";
9395import { defineConfig } from "vite";
9496
9597export default defineConfig({
......@@ -105,7 +107,7 @@ export default defineConfig({
105107});
106108```
107109
108## Components
110### Components
109111
110112All Marko features are supported, such as [tag resolution], [attribute tags],
111113[class shorthands], and template expressions. This makes it so much easier to
......@@ -127,7 +129,7 @@ add complex content to your pages.
127129[attribute tags]: https://markojs.com/docs/reference/language#attribute-tags
128130[class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id
129131
130## Outline / Table of Contents
132### Outline / Table of Contents
131133
132134You can use Markodown to write blogs and long documents, then extract a table of
133135contents. This is done with two mechanisms.
......@@ -160,7 +162,7 @@ heading titles.
160162...
161163```
162164
163## Frontmatter
165### Frontmatter
164166
165167All frontmatter fields are converted into exports. For example, a framework that
166168reads the `meta` export for Open Graph can be easily satisfied with frontmatter.
......@@ -180,7 +182,7 @@ meta:
180182# ${meta.title}
181183```
182184
183## Comments
185### Comments
184186
185187Line, Block, and HTML comments work like they do in Marko/JavaScript.
186188
......@@ -194,7 +196,7 @@ Text that is complete.
194196// TODO: we gotta finish it!
195197```
196198
197## Paragraph Detection
199### Paragraph Detection
198200
199201Like Markdown, you can place content between components, but you can also place
200202inline markdown anywhere between tags. Effectively, this means that text gets
......@@ -213,7 +215,7 @@ this paragraph gets wrapped in a `<p>` tag!
213215</div>
214216```
215217
216## Static Statements
218### Static Statements
217219
218220You can define module-level functions and variables,
219221[same as you can in Marko](https://markojs.com/docs/reference/language#statements).
......@@ -239,3 +241,59 @@ ${"server"} components are a bad idea. (template literal)
239241
240242Though you can say import as long as it's not the first item.
241243```
244
245## Config
246
247You can configure Markodown globally via arguments to the `transform` function.
248
249### Frontmatter Layout Configuration
250
251If frontmatter defines a `layout` property, is acts as a component import that
252wraps the page. (This can also be configured globally with the `layoutImport`
253property to `transform`).
254
255```
256---
257title: my amazing post
258layout: ../layout.marko
259---
260
261## my document
262
263yap yap
264```
265
266In `layout.marko`, you can customize extensively how the document is formatted.
267
268```marko
269import { Heading } from "@clo/markodown";
270
271export interface Input {
272 content: Marko.Body;
273
274 /** Markdown scans for headings (h1..h6) */
275 outline: Heading[];
276 /** This is the namespace import of the main document.
277 * You can reflect frontmatter, or do whatever with this. */
278 module: Record<string, unknown>;
279}
280
281<main>
282<h1>${input.module.title ?? "Blog Post"}</h1>
283<aside>
284 <for|heading| of=input.outline>
285 // heading content includes formatting, even custom tags.
286 <li><a href=`#${heading.id}`><${heading.content}/></a></li>
287 </for>
288</aside>
289
290<${input.content} />
291</main>
292
293// Additionally, built-in components can be altered.
294import CustomHeader from "./custom-header.marko";
295export const components = {
296 heading: CustomHeader,
297 // link, image, codeBlock, blockquote
298};
299```
examples/hoj.mdo+1
......@@ -93,6 +93,7 @@ download for [all of the project files][files].
9393</p>
9494
9595## mentions on the q&a
96
9697<questions-embed="history-of-japan-reanimated" />
9798
9899</main>
examples/slop.mdo created+182
......@@ -0,0 +1,182 @@
1---
2// this file is entirely ai generated and is probably pure slop.
3// i just think its funny.
4
5meta:
6 title: "IFRA 2026 Technical Digest"
7 description: >-
8 The authoritative racing-form analysis for interdimensional
9 ferret competitors, ratified by the council of seven.
10theme:
11 bg: "#1a0033"
12 fg: "#f0e6ff"
13 primary: "#c084fc"
14 accent: "#f472b6"
15---
16import "./ferret-digest.css";
17import RaceCard from "./race-card.marko";
18import Timeline from "./timeline.marko";
19
20static const season = 2026;
21static const topSpeed = 4.2;
22server console.log("digest rendered for season", season);
23client console.log("welcome to the digest, organic reader");
24
25// m/s — disputed by the Pleiades bureau
26// editorial: stats are unofficial until ratified by the IFRA tribunal
27
28<main>
29<nav>[← home](/)</nav>
30
31# IFRA ${season} Technical Digest
32
33*compiled by the editorial board · interdimensional ferret racing association*
34
35---
36
37Welcome to the **${season} IFRA Technical Digest** — the most exhaustive
38breakdown of ferret racing statistics ever assembled across 7 confirmed
39dimensions and 3 disputed ones. All figures are current as of cycle 14.
40
41> "Speed is not a property of the ferret.
42> Speed is a property of the *relationship between the ferret and the void.*"
43>
44> — Commissioner Bryndal, IFRA opening address, 2019
45
46## Race Categories
47
48There are three primary formats, each demanding distinct physiological
49and metaphysical qualities from the competitor:
50
511. **Sprint** — 20 m, pure acceleration, zero dimensional portals allowed
522. **Obstacle Course** — 60 m, includes hedge mazes and one (1) sentient fog bank
533. **Cross-Dimensional Marathon** — distance undefined; time is non-linear here
54
55All competitors must register at `≤450g`. Biometric exemptions are handled
56on a case-by-case basis by the tribunal of weights and measures.
57
58---
59
60## Current Standings
61
62<if=standingsAvailable>
63
64The season-${season} leaderboard is live. Top performers this cycle:
65
66<for|ferret, i| of=competitors>
67
68### ${i + 1}. ${ferret.name} <span style="opacity:.5">(${ferret.origin})</span>
69
70Personal best: **${ferret.pb}s** · Current form: *${ferret.form}*
71
72<RaceCard data=ferret />
73
74</for>
75
76</if>
77<else>
78
79Standings are **unavailable** pending tribunal ratification of the
80Mirror-7 portal incident. Check back next cycle.
81
82</else>
83
84---
85
86## Speed Records by Dimension
87
88| Dimension | Record Holder | Time (s) | Portals Used |
89|-----------|--------------|----------|-------------|
90| Earth-Prime | Biscuit | 1.8 | 0 |
91| Mirror-7 | Biscuit (reflected) | 1.7 | 2 |
92| Dimension Ω | Crumble | 2.1 | 1 |
93| The Beige Zone | unknown | — | unknown |
94
95The all-time record of **${topSpeed}m/s** was set during the 2022 Orion-6
96sprint, shortly after Biscuit consumed a small sandwich. The record remains
97under review due to [unclear nutritional regulations][rule-12b].
98
99[rule-12b]: /rules#12b
100
101---
102
103## Track Conditions: Cycle 14
104
105<if=conditionsAvailable>
106
107Conditions are nominal. Live telemetry excerpt from the Orion-6 sensor array:
108
109```json
110{
111 "track": "Orion-6",
112 "wind_ms": 0.03,
113 "humidity": 0.42,
114 "dimensional_stability": "mostly stable",
115 "sentient_fog_banks": 1,
116 "fog_bank_mood": "brooding"
117}
118```
119
120Fog bank disposition is elevated — handlers should avoid direct eye contact.
121
122</if>
123<else>
124
125Telemetry data is **temporarily offline** due to a dimensional calibration
126event. Estimated restoration: 2–4 cycles.
127
128</else>
129
130---
131
132## Inline Controls
133
134Submit a competitor correction:
135<input type="text" placeholder="ferret name" />
136<button onClick() { submitCorrection() }>submit</button>
137
138Toggle dark mode: <theme-toggle label="dark" />
139
140---
141
142## Historical Timeline
143
144<Timeline>
145<@event year=2019>
146First interdimensional portal installed at the Orion-6 track.
147Seventeen ferrets immediately ran directly into it.
148</>
149<@event year=2021>
150The Beige Zone is discovered. Initial reports describe it as "vaguely unsettling".
151</>
152<@event year=2022>
153Biscuit sets the all-time speed record. Sandwich still unaccounted for.
154</>
155<@event year=2025>
156Mirror-7 portal incident. Details classified pending tribunal review.
157</>
158<@event year=season current=true>
159Season ${season} underway. You are reading this in real time.
160</>
161</>
162
163---
164
165## References & Further Reading
166
167- IFRA official records: <https://ifra.void/records>
168- Ferret aerodynamics paper: [On the Mustelid Boundary Layer][paper]
169- Dimensional portal specifications: [IFRA Engineering Manual, Vol. 3][eng]
170- Biscuit fan site: <https://biscuit.fan>
171
172[paper]: https://arxiv.void/abs/2026.00042
173[eng]: /docs/engineering-manual-vol3
174
175---
176
177<!-- legal: no ferrets were harmed in the production of this digest.
178 the beige zone is not endorsed by the association. -->
179
180// document ends — next update: cycle 15
181
182</main>
src/component_transforms.rs+141
......@@ -80,6 +80,139 @@ pub fn generate_imports(imports: &ComponentImports) -> String {
8080 result
8181}
8282
83/// Which non-heading markdown element types are present in the document.
84/// Used to emit only the necessary layout-component boilerplate.
85#[derive(Debug, Default, Clone, Copy)]
86pub struct UsedElements {
87 pub code_block: bool,
88 pub link: bool,
89 pub image: bool,
90 pub blockquote: bool,
91}
92
93/// Scan the AST and return which element types are present.
94pub fn detect_used_elements(node: &Node) -> UsedElements {
95 let mut used = UsedElements::default();
96 detect_recursive(node, &mut used);
97 used
98}
99
100fn detect_recursive(node: &Node, used: &mut UsedElements) {
101 if node
102 .cast::<markdown_it::plugins::cmark::block::fence::CodeFence>()
103 .is_some()
104 || node
105 .cast::<markdown_it::plugins::cmark::block::code::CodeBlock>()
106 .is_some()
107 {
108 used.code_block = true;
109 }
110 if node
111 .cast::<markdown_it::plugins::cmark::inline::link::Link>()
112 .is_some()
113 {
114 used.link = true;
115 }
116 if node
117 .cast::<markdown_it::plugins::cmark::inline::image::Image>()
118 .is_some()
119 {
120 used.image = true;
121 }
122 if node
123 .cast::<markdown_it::plugins::cmark::block::blockquote::Blockquote>()
124 .is_some()
125 {
126 used.blockquote = true;
127 }
128 for child in &node.children {
129 detect_recursive(child, used);
130 }
131}
132
133/// Generate the Marko boilerplate that wires up layout-sourced components.
134///
135/// Always emits the heading fallback `<define>` + `<const>`.
136/// Conditionally emits entries for code block, link, image, and blockquote
137/// based on which element types are actually present in the document.
138///
139/// Fallbacks:
140/// - heading: a `<define>` that renders `<h{level}>` dynamically
141/// - code block: a `<define>` that renders `<pre><code>`
142/// - link: the string `'a'` (Marko resolves string dynamic tags to HTML elements)
143/// - image: the string `'img'`
144/// - blockquote: the string `'blockquote'`
145pub fn generate_layout_boilerplate(used: &UsedElements) -> String {
146 let mut out = String::new();
147
148 // Heading — always present when a layout is active
149 out.push_str(concat!(
150 "<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>\n",
151 " <${'h' + level} ...attrs><${content}></>\n",
152 "</>\n",
153 "<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />\n",
154 ));
155
156 // Code block — fallback renders <pre><code class="language-...">
157 if used.code_block {
158 out.push_str(concat!(
159 "<define/CodeBlockComponentFallback__markodown__|{ language, content }|>\n",
160 " <pre><code class=(language && 'language-' + language)><${content}/></code></pre>\n",
161 "</>\n",
162 "<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />\n",
163 ));
164 }
165
166 // Link — fallback is the HTML element name string 'a'
167 if used.link {
168 out.push_str("<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />\n");
169 }
170
171 // Image — fallback is the HTML element name string 'img'
172 if used.image {
173 out.push_str("<const/ImageComponent__markodown__ = LayoutModule__markodown__.components?.image ?? 'img' />\n");
174 }
175
176 // Blockquote — fallback is the HTML element name string 'blockquote'
177 if used.blockquote {
178 out.push_str("<const/BlockquoteComponent__markodown__ = LayoutModule__markodown__.components?.blockquote ?? 'blockquote' />\n");
179 }
180
181 out
182}
183
184/// Transform heading elements (h1-h6) to use `HeadingComponent__markodown__`.
185/// Called when a layout is active, so the layout can supply a heading component
186/// via its `components.heading` export (with a built-in fallback).
187/// Does not emit an import — the component is resolved at runtime via `<const>`.
188pub fn transform_headings(node: &mut Node) {
189 for child in &mut node.children {
190 transform_headings(child);
191 }
192 transform_heading(node);
193}
194
195/// Transform non-heading elements present in `used` to use their layout-sourced
196/// components. Must be called after outline extraction and heading transforms,
197/// and only for element types not already handled by explicit `componentImports`.
198pub fn transform_layout_components(node: &mut Node, used: &UsedElements) {
199 for child in &mut node.children {
200 transform_layout_components(child, used);
201 }
202 if used.code_block {
203 transform_code_block(node);
204 }
205 if used.link {
206 transform_link(node);
207 }
208 if used.image {
209 transform_image(node);
210 }
211 if used.blockquote {
212 transform_blockquote(node);
213 }
214}
215
83216/// Transform all elements according to the component imports configuration.
84217pub fn transform_components(node: &mut Node, imports: &ComponentImports) {
85218 // Process children first (bottom-up traversal)
......@@ -227,6 +360,10 @@ fn transform_heading(node: &mut Node) {
227360
228361 // Create a new MarkoBlockComplete to replace this node
229362 let mut open = OpenOwned::from_tag_name(&heading_component());
363 // Transfer id injected by outline extraction (via node.attrs) into the tag
364 if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") {
365 open.insert_id_attr(id);
366 }
230367 open.insert_attr(&format!("level={level}"));
231368
232369 // We need to take ownership of children
......@@ -250,6 +387,10 @@ fn transform_heading(node: &mut Node) {
250387 let level = heading.level;
251388
252389 let mut open = OpenOwned::from_tag_name(&heading_component());
390 // Transfer id injected by outline extraction (via node.attrs) into the tag
391 if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") {
392 open.insert_id_attr(id);
393 }
253394 open.insert_attr(&format!("level={level}"));
254395
255396 let children = std::mem::take(&mut node.children);
src/lib.rs+757-11
......@@ -71,16 +71,24 @@ pub fn transform(
7171) -> Result<Output, Vec<OxcDiagnostic>> {
7272 // Pre-process to extract preamble (blank lines, imports) and frontmatter
7373 // This is needed because markdown-it skips blank lines before running block rules
74 let (preamble_output, remaining_source, preamble_offset) =
74 let (preamble_output, remaining_source, preamble_offset, frontmatter_layout) =
7575 match plugin::extract_preamble_and_frontmatter(source) {
7676 Ok(Some(result)) => {
7777 let offset = result.bytes_consumed;
78 (Some(result.output), &source[offset..], offset)
78 (
79 Some(result.output),
80 &source[offset..],
81 offset,
82 result.layout_import,
83 )
7984 }
80 Ok(None) => (None, source, 0),
85 Ok(None) => (None, source, 0, None),
8186 Err(e) => return Err(vec![e]),
8287 };
8388
89 // Frontmatter `layout` overrides the layout_import option
90 let layout_import = frontmatter_layout.or(layout_import);
91
8492 let md = &mut markdown_it::MarkdownIt::new();
8593
8694 plugin::add_all(md, markdown_only, &clover_extensions);
......@@ -144,6 +152,38 @@ pub fn transform(
144152 None
145153 };
146154
155 // When a layout is active, source heading, code block, link, image, and blockquote
156 // components from the layout's `components` export (with built-in fallbacks).
157 // Detect which element types are actually present so we only emit necessary boilerplate.
158 // This must happen AFTER outline extraction so headings are still h1-h6 when collected.
159 let layout_used = if layout_import.is_some() {
160 let detected = component_transforms::detect_used_elements(&ast);
161 // Only take over types not already handled by an explicit componentImports entry
162 let used = component_transforms::UsedElements {
163 code_block: detected.code_block
164 && component_imports
165 .as_ref()
166 .map_or(true, |i| i.code_block.is_none()),
167 link: detected.link
168 && component_imports
169 .as_ref()
170 .map_or(true, |i| i.link.is_none()),
171 image: detected.image
172 && component_imports
173 .as_ref()
174 .map_or(true, |i| i.image.is_none()),
175 blockquote: detected.blockquote
176 && component_imports
177 .as_ref()
178 .map_or(true, |i| i.blockquote.is_none()),
179 };
180 component_transforms::transform_headings(&mut ast);
181 component_transforms::transform_layout_components(&mut ast, &used);
182 Some(used)
183 } else {
184 None
185 };
186
147187 // Transform components if componentImports is configured
148188 // This must happen AFTER outline extraction so headings are still h1-h6 when collected
149189 if let Some(ref imports) = component_imports {
......@@ -152,12 +192,14 @@ pub fn transform(
152192
153193 // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions
154194 // - Block code (fenced/indented): only escaped if no custom code_block component is configured
195 // and not already transformed by the layout component path
155196 // - Inline code: always escaped since there's no custom component option for it
156197 if format == OutputFormat::Marko {
157 let escape_block = component_imports
158 .as_ref()
159 .map(|i| i.code_block.is_none())
160 .unwrap_or(true);
198 let layout_took_code_block = layout_used.as_ref().map_or(false, |u| u.code_block);
199 let escape_block = !layout_took_code_block
200 && component_imports
201 .as_ref()
202 .map_or(true, |i| i.code_block.is_none());
161203 component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block);
162204 }
163205
......@@ -179,19 +221,24 @@ pub fn transform(
179221
180222 // Wrap with Layout component if layout_import is provided
181223 // Statements and defines must come before the Layout tag
182 if let (Some(layout_path), Some(result)) = (layout_import, outline_result) {
224 if let (Some(layout_path), Some(result), Some(used)) =
225 (layout_import, outline_result, layout_used)
226 {
183227 let outline_array = outline::format_outline_array(&result.headings);
184228
185 // Add defines after other hoisted content
229 // Add heading defines after other hoisted content (statements etc.)
186230 hoisted.push_str(&result.defines);
187231
232 // Boilerplate for sourcing components from the layout's exports, with fallbacks.
233 let boilerplate = component_transforms::generate_layout_boilerplate(&used);
234
188235 if let Some(self_path) = self_import {
189236 text = format!(
190 "import Layout__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{hoisted}\n<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{text}</>"
237 "import Layout__markodown__ from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{boilerplate}\n{hoisted}\n<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{text}</>"
191238 );
192239 } else {
193240 text = format!(
194 "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}\n<Layout__markodown__ module=null outline={outline_array}>\n{text}</>"
241 "import Layout__markodown__ from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\n{boilerplate}\n{hoisted}\n<Layout__markodown__ module=null outline={outline_array}>\n{text}</>"
195242 );
196243 }
197244 } else {
......@@ -367,3 +414,702 @@ fn validate_marko_tags(
367414pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> OxcDiagnostic {
368415 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))
369416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 fn run(source: &str) -> String {
423 transform(source, None, None, None, None, false, None)
424 .expect("transform failed")
425 .text
426 }
427
428 fn run_with_layout(source: &str, layout: &str) -> String {
429 transform(
430 source,
431 None,
432 Some(layout.to_string()),
433 None,
434 None,
435 false,
436 None,
437 )
438 .expect("transform failed")
439 .text
440 }
441
442 fn run_with_layout_and_self(source: &str, layout: &str, self_path: &str) -> String {
443 transform(
444 source,
445 None,
446 Some(layout.to_string()),
447 None,
448 Some(self_path.to_string()),
449 false,
450 None,
451 )
452 .expect("transform failed")
453 .text
454 }
455
456 // -------------------------------------------------------------------------
457 // Heading component boilerplate (layout active)
458 // -------------------------------------------------------------------------
459
460 #[test]
461 fn layout_emits_module_import() {
462 let out = run_with_layout("# Hello", "./layout.marko");
463 assert!(
464 out.contains("import * as LayoutModule__markodown__ from \"./layout.marko\";"),
465 "missing LayoutModule import"
466 );
467 }
468
469 #[test]
470 fn layout_emits_heading_fallback_define() {
471 let out = run_with_layout("# Hello", "./layout.marko");
472 assert!(
473 out.contains("<define/HeadingComponentFallback__markodown__"),
474 "missing HeadingComponentFallback define"
475 );
476 // fallback renders a plain h{level} tag
477 assert!(
478 out.contains("<${'h' + level}"),
479 "missing dynamic tag in fallback"
480 );
481 }
482
483 #[test]
484 fn layout_emits_heading_component_const() {
485 let out = run_with_layout("# Hello", "./layout.marko");
486 assert!(
487 out.contains("<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />"),
488 "missing HeadingComponent const"
489 );
490 }
491
492 #[test]
493 fn layout_boilerplate_before_layout_tag() {
494 let out = run_with_layout("# Hello", "./layout.marko");
495 let boilerplate_pos = out
496 .find("HeadingComponentFallback__markodown__")
497 .expect("boilerplate not found");
498 let layout_tag_pos = out
499 .find("<Layout__markodown__")
500 .expect("Layout tag not found");
501 assert!(
502 boilerplate_pos < layout_tag_pos,
503 "boilerplate must appear before the Layout tag"
504 );
505 }
506
507 #[test]
508 fn layout_heading_uses_component_not_plain_html() {
509 let out = run_with_layout("# Hello\n\n## World", "./layout.marko");
510 assert!(
511 out.contains("<HeadingComponent__markodown__"),
512 "headings should use HeadingComponent"
513 );
514 // plain <h1> should not appear in the body (only inside the fallback define)
515 // the fallback define uses ${'h' + level}, not literal <h1>
516 assert!(
517 !out.contains("<h1>") && !out.contains("<h2"),
518 "plain h1/h2 tags should not appear in body"
519 );
520 }
521
522 #[test]
523 fn layout_no_headings_still_emits_boilerplate() {
524 // Even with no headings, the module import and const are emitted so
525 // the layout can use components.heading if it wants to
526 let out = run_with_layout("Just some prose.", "./layout.marko");
527 assert!(
528 out.contains("LayoutModule__markodown__"),
529 "LayoutModule import must appear even without headings"
530 );
531 assert!(
532 out.contains("HeadingComponent__markodown__"),
533 "HeadingComponent const must appear even without headings"
534 );
535 }
536
537 #[test]
538 fn layout_with_self_import_uses_self_module() {
539 let out = run_with_layout_and_self("# Hello", "./layout.marko", "./self.marko");
540 assert!(
541 out.contains("import * as self__markodown__ from \"./self.marko\";"),
542 "missing self import"
543 );
544 assert!(
545 out.contains("module=self__markodown__"),
546 "Layout tag should use self module"
547 );
548 assert!(
549 !out.contains("module=null"),
550 "should not have null module when selfImport is set"
551 );
552 }
553
554 // -------------------------------------------------------------------------
555 // Frontmatter layout field
556 // -------------------------------------------------------------------------
557
558 #[test]
559 fn frontmatter_layout_not_exported_as_const() {
560 let source = "---\nlayout: ./l.marko\ntitle: Hi\n---\n\ntext";
561 let out = run(source);
562 assert!(
563 !out.contains("export const layout"),
564 "layout key must not be exported"
565 );
566 assert!(
567 out.contains("export const title"),
568 "other fields must still export"
569 );
570 }
571
572 #[test]
573 fn frontmatter_layout_activates_layout_wrapping() {
574 let source = "---\nlayout: ./l.marko\n---\n\ntext";
575 let out = run(source);
576 assert!(
577 out.contains("import Layout__markodown__ from \"./l.marko\";"),
578 "Layout import missing"
579 );
580 assert!(out.contains("<Layout__markodown__"), "Layout tag missing");
581 }
582
583 #[test]
584 fn frontmatter_layout_activates_heading_boilerplate() {
585 let source = "---\nlayout: ./l.marko\n---\n\n# Hello";
586 let out = run(source);
587 assert!(
588 out.contains("LayoutModule__markodown__"),
589 "LayoutModule import must be present when frontmatter layout activates wrapping"
590 );
591 assert!(
592 out.contains("HeadingComponent__markodown__"),
593 "HeadingComponent const must be present"
594 );
595 }
596
597 #[test]
598 fn frontmatter_layout_overrides_layout_import_option() {
599 let source = "---\nlayout: ./frontmatter.marko\n---\n\ntext";
600 let out = transform(
601 source,
602 None,
603 Some("./option.marko".to_string()),
604 None,
605 None,
606 false,
607 None,
608 )
609 .unwrap()
610 .text;
611 assert!(
612 out.contains("frontmatter.marko"),
613 "frontmatter layout should win"
614 );
615 assert!(
616 !out.contains("option.marko"),
617 "option layout should be overridden"
618 );
619 }
620
621 #[test]
622 fn frontmatter_layout_with_preamble_imports() {
623 // Preamble imports should appear before the Layout tag (not before the layout module import)
624 let source = "import Foo from \"./Foo.marko\"\n---\nlayout: ./l.marko\n---\n\ntext";
625 let out = run(source);
626 let preamble_pos = out.find("import Foo").expect("preamble import missing");
627 let layout_tag_pos = out
628 .find("<Layout__markodown__")
629 .expect("Layout tag missing");
630 assert!(
631 preamble_pos < layout_tag_pos,
632 "preamble imports must come before the Layout tag"
633 );
634 }
635
636 // -------------------------------------------------------------------------
637 // Heading component in body output
638 // -------------------------------------------------------------------------
639
640 #[test]
641 fn atx_heading_wrapped_in_component_with_level() {
642 let out = run_with_layout("# Title\n\n## Subtitle", "./l.marko");
643 assert!(
644 out.contains("level=1"),
645 "h1 should get level=1 on component"
646 );
647 assert!(
648 out.contains("level=2"),
649 "h2 should get level=2 on component"
650 );
651 }
652
653 #[test]
654 fn atx_heading_id_injected_into_component_tag() {
655 let out = run_with_layout("## my section", "./l.marko");
656 assert!(
657 out.contains("#my-section"),
658 "id shorthand should be on the HeadingComponent tag"
659 );
660 }
661
662 #[test]
663 fn marko_heading_with_explicit_id_preserved() {
664 let out = run_with_layout("<h2#custom-id>Some heading</>", "./l.marko");
665 assert!(
666 out.contains("#custom-id"),
667 "explicit id should be preserved on HeadingComponent tag"
668 );
669 assert!(out.contains("level=2"), "level attr should be added");
670 }
671
672 #[test]
673 fn heading_content_hoisted_into_define() {
674 let out = run_with_layout("# **bold** heading", "./l.marko");
675 assert!(
676 out.contains("<define/Heading_1__markodown__>"),
677 "heading content should be hoisted into a define"
678 );
679 assert!(
680 out.contains("<strong>bold</strong>"),
681 "rendered content should appear in the define block"
682 );
683 }
684
685 #[test]
686 fn heading_content_ref_used_in_outline_array() {
687 let out = run_with_layout("# Hello", "./l.marko");
688 assert!(
689 out.contains("content: Heading_1__markodown__"),
690 "outline array should reference the define component"
691 );
692 }
693
694 // -------------------------------------------------------------------------
695 // Setext headings (underline style) with layout
696 // -------------------------------------------------------------------------
697
698 #[test]
699 fn setext_h1_wrapped_in_component() {
700 // Setext h1: underlined with ===
701 let out = run_with_layout("Title\n=====", "./l.marko");
702 assert!(
703 out.contains("level=1"),
704 "setext h1 should get level=1 on HeadingComponent"
705 );
706 assert!(
707 out.contains("<HeadingComponent__markodown__"),
708 "setext h1 should use HeadingComponent"
709 );
710 }
711
712 #[test]
713 fn setext_h2_id_slug_from_text() {
714 // Setext h2: underlined with ---
715 let out = run_with_layout("My Section\n----------", "./l.marko");
716 assert!(
717 out.contains("#my-section"),
718 "setext h2 should have slug id on component tag"
719 );
720 assert!(out.contains("level=2"), "setext h2 should get level=2");
721 }
722
723 // -------------------------------------------------------------------------
724 // Multi-line Marko heading (<h2>\ncontent\n</h2>) with layout
725 // -------------------------------------------------------------------------
726
727 #[test]
728 fn multiline_marko_heading_with_layout() {
729 // <h3> with content on next line (MarkoOpen + MarkoClose in AST)
730 let out = run_with_layout("<h3>\nsome content\n</h3>", "./l.marko");
731 assert!(
732 out.contains("level=3"),
733 "multi-line <h3> should get level=3"
734 );
735 assert!(
736 out.contains("<HeadingComponent__markodown__"),
737 "multi-line <h3> should be renamed to HeadingComponent"
738 );
739 // The named close tag </h3> should become </> — not leave a stray </h3>
740 assert!(
741 !out.contains("</h3>"),
742 "named close tag </h3> should be converted to </>"
743 );
744 }
745
746 // -------------------------------------------------------------------------
747 // Duplicate heading IDs through layout
748 // -------------------------------------------------------------------------
749
750 #[test]
751 fn duplicate_heading_ids_deduplicated_in_outline() {
752 let out = run_with_layout("## Intro\n\n## Intro\n\n## Intro", "./l.marko");
753 assert!(
754 out.contains("id: 'intro'"),
755 "first occurrence uses base slug"
756 );
757 assert!(
758 out.contains("id: 'intro-1'"),
759 "second occurrence gets -1 suffix"
760 );
761 assert!(
762 out.contains("id: 'intro-2'"),
763 "third occurrence gets -2 suffix"
764 );
765 }
766
767 #[test]
768 fn duplicate_heading_ids_deduplicated_on_component_tags() {
769 let out = run_with_layout("## Intro\n\n## Intro", "./l.marko");
770 assert!(
771 out.contains("#intro ") || out.contains("#intro level"),
772 "first heading should have #intro"
773 );
774 assert!(
775 out.contains("#intro-1"),
776 "second heading should have #intro-1"
777 );
778 }
779
780 // -------------------------------------------------------------------------
781 // Non-string `layout` value is silently ignored
782 // -------------------------------------------------------------------------
783
784 #[test]
785 fn non_string_layout_value_does_not_activate_layout() {
786 // layout: 42 should be ignored (not a string path)
787 let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext";
788 let out = run(source);
789 assert!(
790 !out.contains("Layout__markodown__"),
791 "non-string layout value must not activate layout wrapping"
792 );
793 }
794
795 #[test]
796 fn non_string_layout_value_not_exported() {
797 // The `layout` key should be consumed and not appear as an export,
798 // even when it's a non-string type
799 let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext";
800 let out = run(source);
801 assert!(
802 !out.contains("export const layout"),
803 "layout key must never be exported regardless of its value type"
804 );
805 assert!(
806 out.contains("export const title"),
807 "other fields should still export"
808 );
809 }
810
811 // -------------------------------------------------------------------------
812 // Frontmatter with only the layout key (no other exports)
813 // -------------------------------------------------------------------------
814
815 #[test]
816 fn layout_only_frontmatter_produces_no_extra_exports() {
817 let source = "---\nlayout: ./l.marko\n---\n\ntext";
818 let out = run(source);
819 assert!(
820 !out.contains("export const"),
821 "layout-only frontmatter should produce no export statements"
822 );
823 assert!(
824 out.contains("Layout__markodown__"),
825 "layout wrapping should still activate"
826 );
827 }
828
829 // -------------------------------------------------------------------------
830 // layout + componentImports.heading conflict
831 // -------------------------------------------------------------------------
832
833 #[test]
834 fn layout_and_component_imports_heading_both_define_same_name() {
835 // When both layoutImport and componentImports.heading are set, they both
836 // try to define `HeadingComponent__markodown__` — one via <const> and one
837 // via import. This pins the current behavior so any change is deliberate.
838 let out = transform(
839 "# Hello",
840 None,
841 Some("./l.marko".to_string()),
842 Some(ComponentImports {
843 heading: Some("./h.marko".to_string()),
844 ..Default::default()
845 }),
846 None,
847 false,
848 None,
849 )
850 .unwrap()
851 .text;
852 // Both definitions appear — this is a known conflict.
853 // The <const> from the layout boilerplate wins at runtime in Marko
854 // because it appears before the import in the rendered output.
855 assert!(
856 out.contains("HeadingComponent__markodown__"),
857 "HeadingComponent name must appear"
858 );
859 assert!(
860 out.contains("import HeadingComponent__markodown__ from \"./h.marko\""),
861 "explicit heading import should appear"
862 );
863 assert!(
864 out.contains("<const/HeadingComponent__markodown__"),
865 "layout const should also appear"
866 );
867 }
868
869 // -------------------------------------------------------------------------
870 // Layout code block component
871 // -------------------------------------------------------------------------
872
873 #[test]
874 fn layout_code_block_emits_fallback_define_and_const() {
875 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
876 assert!(
877 out.contains("<define/CodeBlockComponentFallback__markodown__"),
878 "code block fallback define must be emitted when code block is present"
879 );
880 assert!(
881 out.contains("LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__"),
882 "code block const must read from layout components"
883 );
884 }
885
886 #[test]
887 fn layout_code_block_uses_component_in_body() {
888 let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko");
889 assert!(
890 out.contains("<CodeBlockComponent__markodown__ language=\"ts\">"),
891 "fenced code block should be replaced with CodeBlockComponent"
892 );
893 }
894
895 #[test]
896 fn layout_code_block_without_language() {
897 let out = run_with_layout("```\nsome code\n```", "./l.marko");
898 assert!(
899 out.contains("<CodeBlockComponent__markodown__>"),
900 "code block with no language should still use CodeBlockComponent (no language attr)"
901 );
902 }
903
904 #[test]
905 fn layout_code_block_content_escaped_as_template_literal() {
906 // Code content with ${...} must be escaped so Marko doesn't interpret it
907 let out = run_with_layout("```\n${danger}\n```", "./l.marko");
908 assert!(
909 out.contains("\\${"),
910 "template expression in code content must be escaped"
911 );
912 }
913
914 #[test]
915 fn no_code_block_omits_code_block_boilerplate() {
916 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
917 assert!(
918 !out.contains("CodeBlockComponent"),
919 "code block boilerplate must be absent when no code block in document"
920 );
921 }
922
923 // -------------------------------------------------------------------------
924 // Layout link component
925 // -------------------------------------------------------------------------
926
927 #[test]
928 fn layout_link_emits_const_with_string_fallback() {
929 let out = run_with_layout("[text](https://example.com)", "./l.marko");
930 assert!(
931 out.contains("LayoutModule__markodown__.components?.link ?? 'a'"),
932 "link const must use 'a' as fallback"
933 );
934 // no <define> needed for link — the string 'a' is the fallback
935 assert!(
936 !out.contains("LinkComponentFallback"),
937 "link must not emit a fallback define"
938 );
939 }
940
941 #[test]
942 fn layout_link_uses_component_in_body() {
943 let out = run_with_layout("[click here](https://example.com)", "./l.marko");
944 assert!(
945 out.contains("<LinkComponent__markodown__"),
946 "link should be replaced with LinkComponent"
947 );
948 assert!(
949 out.contains("href=\"https://example.com\""),
950 "href attribute should be preserved"
951 );
952 }
953
954 #[test]
955 fn layout_link_with_title() {
956 let out = run_with_layout("[text](https://example.com \"My Title\")", "./l.marko");
957 assert!(
958 out.contains("title=\"My Title\""),
959 "link title attribute should be present"
960 );
961 }
962
963 #[test]
964 fn no_link_omits_link_boilerplate() {
965 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
966 assert!(
967 !out.contains("LinkComponent"),
968 "link boilerplate must be absent when no links in document"
969 );
970 }
971
972 // -------------------------------------------------------------------------
973 // Layout image component
974 // -------------------------------------------------------------------------
975
976 #[test]
977 fn layout_image_emits_const_with_string_fallback() {
978 let out = run_with_layout("![alt](./img.png)", "./l.marko");
979 assert!(
980 out.contains("LayoutModule__markodown__.components?.image ?? 'img'"),
981 "image const must use 'img' as fallback"
982 );
983 assert!(
984 !out.contains("ImageComponentFallback"),
985 "image must not emit a fallback define"
986 );
987 }
988
989 #[test]
990 fn layout_image_uses_component_in_body() {
991 let out = run_with_layout("![my alt](./photo.png)", "./l.marko");
992 assert!(
993 out.contains("<ImageComponent__markodown__"),
994 "image should be replaced with ImageComponent"
995 );
996 assert!(
997 out.contains("src=\"./photo.png\""),
998 "src attribute should be preserved"
999 );
1000 assert!(
1001 out.contains("alt=\"my alt\""),
1002 "alt attribute should be preserved"
1003 );
1004 }
1005
1006 #[test]
1007 fn layout_image_with_title() {
1008 let out = run_with_layout("![alt](./img.png \"caption\")", "./l.marko");
1009 assert!(
1010 out.contains("title=\"caption\""),
1011 "image title attribute should be present"
1012 );
1013 }
1014
1015 #[test]
1016 fn no_image_omits_image_boilerplate() {
1017 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1018 assert!(
1019 !out.contains("ImageComponent"),
1020 "image boilerplate must be absent when no images in document"
1021 );
1022 }
1023
1024 // -------------------------------------------------------------------------
1025 // Layout blockquote component
1026 // -------------------------------------------------------------------------
1027
1028 #[test]
1029 fn layout_blockquote_emits_const_with_string_fallback() {
1030 let out = run_with_layout("> some quote", "./l.marko");
1031 assert!(
1032 out.contains("LayoutModule__markodown__.components?.blockquote ?? 'blockquote'"),
1033 "blockquote const must use 'blockquote' as fallback"
1034 );
1035 assert!(
1036 !out.contains("BlockquoteComponentFallback"),
1037 "blockquote must not emit a fallback define"
1038 );
1039 }
1040
1041 #[test]
1042 fn layout_blockquote_uses_component_in_body() {
1043 let out = run_with_layout("> some quote", "./l.marko");
1044 assert!(
1045 out.contains("<BlockquoteComponent__markodown__"),
1046 "blockquote should be replaced with BlockquoteComponent"
1047 );
1048 }
1049
1050 #[test]
1051 fn no_blockquote_omits_blockquote_boilerplate() {
1052 let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko");
1053 assert!(
1054 !out.contains("BlockquoteComponent"),
1055 "blockquote boilerplate must be absent when no blockquotes in document"
1056 );
1057 }
1058
1059 // -------------------------------------------------------------------------
1060 // componentImports takes precedence over layout for the same element type
1061 // -------------------------------------------------------------------------
1062
1063 #[test]
1064 fn explicit_component_import_suppresses_layout_code_block() {
1065 let out = transform(
1066 "```ts\nconst x = 1;\n```",
1067 None,
1068 Some("./l.marko".to_string()),
1069 Some(ComponentImports {
1070 code_block: Some("./cb.marko".to_string()),
1071 ..Default::default()
1072 }),
1073 None,
1074 false,
1075 None,
1076 )
1077 .unwrap()
1078 .text;
1079 // explicit import wins — no layout const for code block
1080 assert!(
1081 !out.contains("LayoutModule__markodown__.components?.codeBlock"),
1082 "layout code block boilerplate should be suppressed by explicit componentImports.codeBlock"
1083 );
1084 assert!(
1085 out.contains("import CodeBlockComponent__markodown__ from \"./cb.marko\""),
1086 "explicit code block import should be present"
1087 );
1088 }
1089
1090 #[test]
1091 fn explicit_component_import_suppresses_layout_link() {
1092 let out = transform(
1093 "[text](https://example.com)",
1094 None,
1095 Some("./l.marko".to_string()),
1096 Some(ComponentImports {
1097 link: Some("./link.marko".to_string()),
1098 ..Default::default()
1099 }),
1100 None,
1101 false,
1102 None,
1103 )
1104 .unwrap()
1105 .text;
1106 assert!(
1107 !out.contains("LayoutModule__markodown__.components?.link"),
1108 "layout link boilerplate should be suppressed"
1109 );
1110 assert!(
1111 out.contains("import LinkComponent__markodown__ from \"./link.marko\""),
1112 "explicit link import should be present"
1113 );
1114 }
1115}
src/plugin/frontmatter.rs+128-5
......@@ -33,8 +33,9 @@ fn yaml_value_to_js(val: &serde_yml::Value) -> String {
3333 }
3434}
3535
36/// Convert YAML mapping to JavaScript export statements
37fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
36/// Convert YAML mapping to JavaScript export statements.
37/// Keys listed in `skip_keys` are omitted from the output.
38fn yaml_to_exports(value: &serde_yml::Value, skip_keys: &[&str]) -> Result<String, String> {
3839 let map = match value {
3940 serde_yml::Value::Mapping(m) => m,
4041 _ => {
......@@ -52,6 +53,10 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
5253 _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")),
5354 };
5455
56 if skip_keys.contains(&key_str.as_str()) {
57 continue;
58 }
59
5560 if matches!(val, serde_yml::Value::Tagged(_)) {
5661 return Err("YAML tags are not supported in markodown frontmatter".to_string());
5762 }
......@@ -65,12 +70,25 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
6570 Ok(exports.join("\n"))
6671}
6772
73/// Extract the `layout` string value from a YAML mapping, if present.
74fn extract_layout_key(value: &serde_yml::Value) -> Option<String> {
75 let map = value.as_mapping()?;
76 let key = serde_yml::Value::String("layout".to_string());
77 match map.get(&key)? {
78 serde_yml::Value::String(s) => Some(s.clone()),
79 _ => None,
80 }
81}
82
6883/// Result of extracting preamble (imports/blank lines) and frontmatter from source
6984pub struct PreambleResult {
7085 /// The generated output (imports + exports from frontmatter)
7186 pub output: String,
7287 /// Number of bytes consumed from the source
7388 pub bytes_consumed: usize,
89 /// The `layout` key from frontmatter, if present. This overrides the
90 /// `layoutImport` transform option for this document.
91 pub layout_import: Option<String>,
7492}
7593
7694/// Extract leading blank lines, import statements, and frontmatter from source.
......@@ -115,8 +133,13 @@ pub fn extract_preamble_and_frontmatter(
115133
116134 // Parse YAML and convert to exports
117135 let yaml = strip_js_comments(&yaml_lines.join("\n"));
118 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
119 Ok(value) => yaml_to_exports(&value).map_err(OxcDiagnostic::error)?,
136 let (exports, layout_import) = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
137 Ok(value) => {
138 // Extract the `layout` key before converting to exports
139 let layout = extract_layout_key(&value);
140 let exports = yaml_to_exports(&value, &["layout"]).map_err(OxcDiagnostic::error)?;
141 (exports, layout)
142 }
120143 Err(e) => {
121144 let offset = e
122145 .location()
......@@ -160,9 +183,109 @@ pub fn extract_preamble_and_frontmatter(
160183 Ok(Some(PreambleResult {
161184 output,
162185 bytes_consumed,
186 layout_import,
163187 }))
164188}
165189
190#[cfg(test)]
191mod tests {
192 use super::extract_preamble_and_frontmatter;
193 use crate::transform;
194
195 #[test]
196 fn test_frontmatter_layout_extracted_as_layout_import() {
197 let source = "---\nlayout: ./my-layout.marko\ntitle: Hello\n---\n\n# Hi";
198 let result = extract_preamble_and_frontmatter(source)
199 .expect("no error")
200 .expect("some result");
201 assert_eq!(
202 result.layout_import.as_deref(),
203 Some("./my-layout.marko"),
204 "layout key should be extracted"
205 );
206 // `layout` must not appear as an export
207 assert!(
208 !result.output.contains("export const layout"),
209 "layout should not be exported"
210 );
211 // other keys are still exported
212 assert!(
213 result.output.contains("export const title"),
214 "title should still be exported"
215 );
216 }
217
218 #[test]
219 fn test_frontmatter_without_layout_gives_none() {
220 let source = "---\ntitle: Hello\n---\n\n# Hi";
221 let result = extract_preamble_and_frontmatter(source)
222 .expect("no error")
223 .expect("some result");
224 assert!(
225 result.layout_import.is_none(),
226 "no layout_import when key absent"
227 );
228 }
229
230 #[test]
231 fn test_frontmatter_layout_overrides_option() {
232 let source = "---\nlayout: ./frontmatter-layout.marko\n---\n\n# Hello";
233 let output = transform(
234 source,
235 None,
236 Some("./option-layout.marko".to_string()),
237 None,
238 None,
239 false,
240 None,
241 )
242 .expect("transform succeeded");
243 // frontmatter layout wins over the option
244 assert!(
245 output.text.contains("frontmatter-layout.marko"),
246 "frontmatter layout should be used"
247 );
248 assert!(
249 !output.text.contains("option-layout.marko"),
250 "option layout should be overridden"
251 );
252 }
253
254 #[test]
255 fn test_frontmatter_layout_used_when_no_option() {
256 let source = "---\nlayout: ./my-layout.marko\n---\n\n# Hello";
257 let output =
258 transform(source, None, None, None, None, false, None).expect("transform succeeded");
259 assert!(
260 output.text.contains("my-layout.marko"),
261 "frontmatter layout should be applied"
262 );
263 assert!(
264 output.text.contains("Layout__markodown__"),
265 "layout wrapper should be emitted"
266 );
267 }
268
269 #[test]
270 fn test_option_layout_used_when_no_frontmatter_layout() {
271 let source = "---\ntitle: Hello\n---\n\n# Hello";
272 let output = transform(
273 source,
274 None,
275 Some("./option-layout.marko".to_string()),
276 None,
277 None,
278 false,
279 None,
280 )
281 .expect("transform succeeded");
282 assert!(
283 output.text.contains("option-layout.marko"),
284 "option layout should still work"
285 );
286 }
287}
288
166289impl BlockRule for Rule {
167290 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
168291 // Only at the start of the document (no preamble case - handled by preprocessing)
......@@ -194,7 +317,7 @@ impl BlockRule for Rule {
194317 // Parse YAML and convert to exports
195318 let yaml = strip_js_comments(&lines.join("\n"));
196319 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
197 Ok(value) => match yaml_to_exports(&value) {
320 Ok(value) => match yaml_to_exports(&value, &["layout"]) {
198321 Ok(js) => js,
199322 Err(msg) => {
200323 return Some((
src/plugin/statement.rs+9-1
......@@ -40,8 +40,16 @@ impl BlockRule for Rule {
4040
4141 let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..];
4242
43 // Statements never span blank lines. Limit the source passed to the TypeScript
44 // parser to avoid it trying to parse subsequent Markdown/Marko content as TS.
45 let limited_end = unbounded_src
46 .find("\n\n")
47 .map(|p| p + 1) // include the newline that ends the statement
48 .unwrap_or(unbounded_src.len());
49 let limited_src = &unbounded_src[..limited_end];
50
4351 let statement_end =
44 match scan_first_statement_forbid_trailing(&unbounded_src[keyword_trim as usize..]) {
52 match scan_first_statement_forbid_trailing(&limited_src[keyword_trim as usize..]) {
4553 Ok(ok) => ok,
4654 Err(err) => {
4755 return Some((
tests/fixtures.rs+47
......@@ -236,3 +236,50 @@ fn fixture_25_code_block_import() {
236236fn fixture_26_code_block_import() {
237237 run_fixture_with_options("26-code-block-import", None, None, None);
238238}
239
240#[test]
241fn fixture_27_frontmatter_layout() {
242 // layout key in frontmatter activates layout wrapping without a layoutImport option
243 run_fixture("27-frontmatter-layout");
244}
245
246#[test]
247fn fixture_28_outline_self_import() {
248 // layout + selfImport produces module=self__markodown__ and the extra import
249 run_fixture_with_options(
250 "28-outline-self-import",
251 Some("./layout.marko"),
252 None,
253 Some("./self.marko"),
254 );
255}
256
257#[test]
258fn fixture_29_layout_all_components() {
259 // layout emits boilerplate for all element types present in the document
260 run_fixture_with_options(
261 "29-layout-all-components",
262 Some("./layout.marko"),
263 None,
264 None,
265 );
266}
267
268#[test]
269fn fixture_30_layout_selective_components() {
270 // only element types actually used in the document get boilerplate — others are omitted
271 run_fixture_with_options(
272 "30-layout-selective-components",
273 Some("./layout.marko"),
274 None,
275 None,
276 );
277}
278
279#[test]
280fn fixture_31_import_with_markdown_content() {
281 // import statement followed (after blank line) by Marko tags with Markdown inline content
282 // regression: statement rule was passing unbounded source to OXC which tried to parse
283 // markdown link syntax [text](url) as a TypeScript array expression
284 run_fixture("31-import-with-markdown-content");
285}
tests/fixtures/23-outline-extracting.marko+13-7
......@@ -1,4 +1,10 @@
11import Layout__markodown__ from "./layout.marko";
2import * as LayoutModule__markodown__ from "./layout.marko";
3<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>
4 <${'h' + level} ...attrs><${content}></>
5</>
6<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
7
28<define/Heading_1__markodown__>
39good morning
410</>
......@@ -18,12 +24,12 @@ rain <strong>time</strong>
1824 { level: 3, id: 'snow-time', content: Heading_3__markodown__ },
1925 { level: 4, id: 'id', content: Heading_4__markodown__ },
2026]>
21<h1#good-morning><Heading_1__markodown__/>
22</h1>
23<h2 id="good-night"><Heading_2__markodown__/>
24</h2>
25<h3 id="snow-time"><Heading_3__markodown__/>
26</h3>
27<h4#id><Heading_4__markodown__/>
27<HeadingComponent__markodown__#good-morning level=1><Heading_1__markodown__/>
28</>
29<HeadingComponent__markodown__#good-night level=2><Heading_2__markodown__/>
30</>
31<HeadingComponent__markodown__#snow-time level=3><Heading_3__markodown__/>
32</>
33<HeadingComponent__markodown__#id level=4><Heading_4__markodown__/>
2834</>
2935</>
tests/fixtures/27-frontmatter-layout.marko created+20
......@@ -0,0 +1,20 @@
1import Layout__markodown__ from "./page-layout.marko";
2import * as LayoutModule__markodown__ from "./page-layout.marko";
3<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>
4 <${'h' + level} ...attrs><${content}></>
5</>
6<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
7
8export const title = "Hello World";
9
10<define/Heading_1__markodown__>
11${title}
12</>
13
14<Layout__markodown__ module=null outline=[
15 { level: 1, id: 'heading', content: Heading_1__markodown__ },
16]>
17<HeadingComponent__markodown__#heading level=1><Heading_1__markodown__/>
18</>
19<p>Some content here.</p>
20</>
tests/fixtures/27-frontmatter-layout.mdo created+8
......@@ -0,0 +1,8 @@
1---
2layout: ./page-layout.marko
3title: Hello World
4---
5
6# ${title}
7
8Some content here.
tests/fixtures/28-outline-self-import.marko created+25
......@@ -0,0 +1,25 @@
1import Layout__markodown__ from "./layout.marko";
2import * as LayoutModule__markodown__ from "./layout.marko";
3import * as self__markodown__ from "./self.marko";
4<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>
5 <${'h' + level} ...attrs><${content}></>
6</>
7<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
8
9<define/Heading_1__markodown__>
10Introduction
11</>
12<define/Heading_2__markodown__>
13Details
14</>
15
16<Layout__markodown__ module=self__markodown__ outline=[
17 { level: 2, id: 'introduction', content: Heading_1__markodown__ },
18 { level: 3, id: 'details', content: Heading_2__markodown__ },
19]>
20<HeadingComponent__markodown__#introduction level=2><Heading_1__markodown__/>
21</>
22<p>Some text.</p>
23<HeadingComponent__markodown__#details level=3><Heading_2__markodown__/>
24</>
25</>
tests/fixtures/28-outline-self-import.mdo created+5
......@@ -0,0 +1,5 @@
1## Introduction
2
3Some text.
4
5### Details
tests/fixtures/29-layout-all-components.marko created+31
......@@ -0,0 +1,31 @@
1import Layout__markodown__ from "./layout.marko";
2import * as LayoutModule__markodown__ from "./layout.marko";
3<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>
4 <${'h' + level} ...attrs><${content}></>
5</>
6<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
7<define/CodeBlockComponentFallback__markodown__|{ language, content }|>
8 <pre><code class=(language && 'language-' + language)><${content}/></code></pre>
9</>
10<const/CodeBlockComponent__markodown__ = LayoutModule__markodown__.components?.codeBlock ?? CodeBlockComponentFallback__markodown__ />
11<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />
12<const/ImageComponent__markodown__ = LayoutModule__markodown__.components?.image ?? 'img' />
13<const/BlockquoteComponent__markodown__ = LayoutModule__markodown__.components?.blockquote ?? 'blockquote' />
14
15<define/Heading_1__markodown__>
16Hello
17</>
18
19<Layout__markodown__ module=null outline=[
20 { level: 1, id: 'hello', content: Heading_1__markodown__ },
21]>
22<HeadingComponent__markodown__#hello level=1><Heading_1__markodown__/>
23</>
24<p>A <LinkComponent__markodown__ href="https://example.com">link</> and an <ImageComponent__markodown__ src="./photo.png" alt="image" title="caption" />.</p>
25<BlockquoteComponent__markodown__>
26<p>A blockquote.</p>
27</>
28<CodeBlockComponent__markodown__ language="ts">
29${"const x = 1;\n" }
30</>
31</>
tests/fixtures/29-layout-all-components.mdo created+9
......@@ -0,0 +1,9 @@
1# Hello
2
3A [link](https://example.com) and an ![image](./photo.png "caption").
4
5> A blockquote.
6
7```ts
8const x = 1;
9```
tests/fixtures/30-layout-selective-components.marko created+19
......@@ -0,0 +1,19 @@
1import Layout__markodown__ from "./layout.marko";
2import * as LayoutModule__markodown__ from "./layout.marko";
3<define/HeadingComponentFallback__markodown__|{ level, content, ...attrs }|>
4 <${'h' + level} ...attrs><${content}></>
5</>
6<const/HeadingComponent__markodown__ = LayoutModule__markodown__.components?.heading ?? HeadingComponentFallback__markodown__ />
7<const/LinkComponent__markodown__ = LayoutModule__markodown__.components?.link ?? 'a' />
8
9<define/Heading_1__markodown__>
10Title
11</>
12
13<Layout__markodown__ module=null outline=[
14 { level: 1, id: 'title', content: Heading_1__markodown__ },
15]>
16<HeadingComponent__markodown__#title level=1><Heading_1__markodown__/>
17</>
18<p>A <LinkComponent__markodown__ href="https://example.com">link</>.</p>
19</>
tests/fixtures/30-layout-selective-components.mdo created+3
......@@ -0,0 +1,3 @@
1# Title
2
3A [link](https://example.com).
tests/fixtures/31-import-with-markdown-content.marko created+4
......@@ -0,0 +1,4 @@
1import "./style.css";
2<main>
3<nav><a href="/">back to home</a></nav>
4</main>
tests/fixtures/31-import-with-markdown-content.mdo created+5
......@@ -0,0 +1,5 @@
1import "./style.css";
2
3<main>
4<nav>[back to home](/)</nav>
5</main>
wtf.mdo deleted-5
......@@ -1,5 +0,0 @@
1i thought this bug was fixed
2
3<h3 id='bbbbbbb'><code>aaa</code> mmmm</>
4
5wtf!!