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....@@ -14,13 +14,14 @@ leveraging the existing ecosystem.
1414
15> **CONTENTS**:15> **CONTENTS**:
16>16>
17> - [Install](#install)17> - [Usage](#usage)
18> - [Components](#components)18> - [Components](#components)
19> - [Outline / Table of Contents](#outline-table-of-contents)19> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#frontmatter)20> - [Frontmatter](#frontmatter)
21> - [Comments](#comments)21> - [Comments](#comments)
22> - [Paragraph Detection](#paragraph-detection)22> - [Paragraph Detection](#paragraph-detection)
23> - [Static Statements](#static-statements)23> - [Static Statements](#static-statements)
24> - [Config](#config)
2425
25Here's a glance at how things look. Complete example documents in <./examples>26Here'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()}....@@ -71,14 +72,15 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
71</blog-layout>72</blog-layout>
72````73````
7374
74## Install75## Usage
7576
76Markodown is distributed on77Markodown is distributed on
77[NPM](https://npmjs.com/package/@paperclover/markodown) and78[NPM](https://npmjs.com/package/@paperclover/markodown) and
78[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.79[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7980
80```sh81```sh
81npm i @paperclover/markodown82# alias install
83npm i @clo/markodown@npm:@paperclover/markodown
82# or84# or
83npx jsr add @clo/markodown85npx jsr add @clo/markodown
84```86```
...@@ -89,7 +91,7 @@ Marko Run:...@@ -89,7 +91,7 @@ Marko Run:
8991
90```ts92```ts
91import marko from "@marko/run/vite";93import marko from "@marko/run/vite";
92import markodown from "markodown";94import markodown from "@clo/markodown";
93import { defineConfig } from "vite";95import { defineConfig } from "vite";
9496
95export default defineConfig({97export default defineConfig({
...@@ -105,7 +107,7 @@ export default defineConfig({...@@ -105,7 +107,7 @@ export default defineConfig({
105});107});
106```108```
107109
108## Components110### Components
109111
110All Marko features are supported, such as [tag resolution], [attribute tags],112All Marko features are supported, such as [tag resolution], [attribute tags],
111[class shorthands], and template expressions. This makes it so much easier to113[class shorthands], and template expressions. This makes it so much easier to
...@@ -127,7 +129,7 @@ add complex content to your pages....@@ -127,7 +129,7 @@ add complex content to your pages.
127[attribute tags]: https://markojs.com/docs/reference/language#attribute-tags129[attribute tags]: https://markojs.com/docs/reference/language#attribute-tags
128[class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id130[class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id
129131
130## Outline / Table of Contents132### Outline / Table of Contents
131133
132You can use Markodown to write blogs and long documents, then extract a table of134You can use Markodown to write blogs and long documents, then extract a table of
133contents. This is done with two mechanisms.135contents. This is done with two mechanisms.
...@@ -160,7 +162,7 @@ heading titles....@@ -160,7 +162,7 @@ heading titles.
160...162...
161```163```
162164
163## Frontmatter165### Frontmatter
164166
165All frontmatter fields are converted into exports. For example, a framework that167All frontmatter fields are converted into exports. For example, a framework that
166reads the `meta` export for Open Graph can be easily satisfied with frontmatter.168reads the `meta` export for Open Graph can be easily satisfied with frontmatter.
...@@ -180,7 +182,7 @@ meta:...@@ -180,7 +182,7 @@ meta:
180# ${meta.title}182# ${meta.title}
181```183```
182184
183## Comments185### Comments
184186
185Line, Block, and HTML comments work like they do in Marko/JavaScript.187Line, Block, and HTML comments work like they do in Marko/JavaScript.
186188
...@@ -194,7 +196,7 @@ Text that is complete....@@ -194,7 +196,7 @@ Text that is complete.
194// TODO: we gotta finish it!196// TODO: we gotta finish it!
195```197```
196198
197## Paragraph Detection199### Paragraph Detection
198200
199Like Markdown, you can place content between components, but you can also place201Like Markdown, you can place content between components, but you can also place
200inline markdown anywhere between tags. Effectively, this means that text gets202inline markdown anywhere between tags. Effectively, this means that text gets
...@@ -213,7 +215,7 @@ this paragraph gets wrapped in a `<p>` tag!...@@ -213,7 +215,7 @@ this paragraph gets wrapped in a `<p>` tag!
213</div>215</div>
214```216```
215217
216## Static Statements218### Static Statements
217219
218You can define module-level functions and variables,220You can define module-level functions and variables,
219[same as you can in Marko](https://markojs.com/docs/reference/language#statements).221[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)...@@ -239,3 +241,59 @@ ${"server"} components are a bad idea. (template literal)
239241
240Though you can say import as long as it's not the first item.242Though you can say import as long as it's not the first item.
241```243```
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]....@@ -93,6 +93,7 @@ download for [all of the project files][files].
93</p>93</p>
9494
95## mentions on the q&a95## mentions on the q&a
96
96<questions-embed="history-of-japan-reanimated" />97<questions-embed="history-of-japan-reanimated" />
9798
98</main>99</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 {...@@ -80,6 +80,139 @@ pub fn generate_imports(imports: &ComponentImports) -> String {
80 result80 result
81}81}
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
83/// Transform all elements according to the component imports configuration.216/// Transform all elements according to the component imports configuration.
84pub fn transform_components(node: &mut Node, imports: &ComponentImports) {217pub fn transform_components(node: &mut Node, imports: &ComponentImports) {
85 // Process children first (bottom-up traversal)218 // Process children first (bottom-up traversal)
...@@ -227,6 +360,10 @@ fn transform_heading(node: &mut Node) {...@@ -227,6 +360,10 @@ fn transform_heading(node: &mut Node) {
227360
228 // Create a new MarkoBlockComplete to replace this node361 // Create a new MarkoBlockComplete to replace this node
229 let mut open = OpenOwned::from_tag_name(&heading_component());362 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 }
230 open.insert_attr(&format!("level={level}"));367 open.insert_attr(&format!("level={level}"));
231368
232 // We need to take ownership of children369 // We need to take ownership of children
...@@ -250,6 +387,10 @@ fn transform_heading(node: &mut Node) {...@@ -250,6 +387,10 @@ fn transform_heading(node: &mut Node) {
250 let level = heading.level;387 let level = heading.level;
251388
252 let mut open = OpenOwned::from_tag_name(&heading_component());389 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 }
253 open.insert_attr(&format!("level={level}"));394 open.insert_attr(&format!("level={level}"));
254395
255 let children = std::mem::take(&mut node.children);396 let children = std::mem::take(&mut node.children);
src/lib.rs+757-11
...@@ -71,16 +71,24 @@ pub fn transform(...@@ -71,16 +71,24 @@ pub fn transform(
71) -> Result<Output, Vec<OxcDiagnostic>> {71) -> Result<Output, Vec<OxcDiagnostic>> {
72 // Pre-process to extract preamble (blank lines, imports) and frontmatter72 // Pre-process to extract preamble (blank lines, imports) and frontmatter
73 // This is needed because markdown-it skips blank lines before running block rules73 // 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) =
75 match plugin::extract_preamble_and_frontmatter(source) {75 match plugin::extract_preamble_and_frontmatter(source) {
76 Ok(Some(result)) => {76 Ok(Some(result)) => {
77 let offset = result.bytes_consumed;77 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 )
79 }84 }
80 Ok(None) => (None, source, 0),85 Ok(None) => (None, source, 0, None),
81 Err(e) => return Err(vec![e]),86 Err(e) => return Err(vec![e]),
82 };87 };
8388
89 // Frontmatter `layout` overrides the layout_import option
90 let layout_import = frontmatter_layout.or(layout_import);
91
84 let md = &mut markdown_it::MarkdownIt::new();92 let md = &mut markdown_it::MarkdownIt::new();
8593
86 plugin::add_all(md, markdown_only, &clover_extensions);94 plugin::add_all(md, markdown_only, &clover_extensions);
...@@ -144,6 +152,38 @@ pub fn transform(...@@ -144,6 +152,38 @@ pub fn transform(
144 None152 None
145 };153 };
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
147 // Transform components if componentImports is configured187 // Transform components if componentImports is configured
148 // This must happen AFTER outline extraction so headings are still h1-h6 when collected188 // This must happen AFTER outline extraction so headings are still h1-h6 when collected
149 if let Some(ref imports) = component_imports {189 if let Some(ref imports) = component_imports {
...@@ -152,12 +192,14 @@ pub fn transform(...@@ -152,12 +192,14 @@ pub fn transform(
152192
153 // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions193 // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions
154 // - Block code (fenced/indented): only escaped if no custom code_block component is configured194 // - Block code (fenced/indented): only escaped if no custom code_block component is configured
195 // and not already transformed by the layout component path
155 // - Inline code: always escaped since there's no custom component option for it196 // - Inline code: always escaped since there's no custom component option for it
156 if format == OutputFormat::Marko {197 if format == OutputFormat::Marko {
157 let escape_block = component_imports198 let layout_took_code_block = layout_used.as_ref().map_or(false, |u| u.code_block);
158 .as_ref()199 let escape_block = !layout_took_code_block
159 .map(|i| i.code_block.is_none())200 && component_imports
160 .unwrap_or(true);201 .as_ref()
202 .map_or(true, |i| i.code_block.is_none());
161 component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block);203 component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block);
162 }204 }
163205
...@@ -179,19 +221,24 @@ pub fn transform(...@@ -179,19 +221,24 @@ pub fn transform(
179221
180 // Wrap with Layout component if layout_import is provided222 // Wrap with Layout component if layout_import is provided
181 // Statements and defines must come before the Layout tag223 // 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 {
183 let outline_array = outline::format_outline_array(&result.headings);227 let outline_array = outline::format_outline_array(&result.headings);
184228
185 // Add defines after other hoisted content229 // Add heading defines after other hoisted content (statements etc.)
186 hoisted.push_str(&result.defines);230 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
188 if let Some(self_path) = self_import {235 if let Some(self_path) = self_import {
189 text = format!(236 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}</>"
191 );238 );
192 } else {239 } else {
193 text = format!(240 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}</>"
195 );242 );
196 }243 }
197 } else {244 } else {
...@@ -367,3 +414,702 @@ fn validate_marko_tags(...@@ -367,3 +414,702 @@ fn validate_marko_tags(
367pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> OxcDiagnostic {414pub fn err<T: Into<Cow<'static, str>>>(str: T, offset: u32, length: usize) -> OxcDiagnostic {
368 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))415 OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length))
369}416}
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 {...@@ -33,8 +33,9 @@ fn yaml_value_to_js(val: &serde_yml::Value) -> String {
33 }33 }
34}34}
3535
36/// Convert YAML mapping to JavaScript export statements36/// Convert YAML mapping to JavaScript export statements.
37fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {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> {
38 let map = match value {39 let map = match value {
39 serde_yml::Value::Mapping(m) => m,40 serde_yml::Value::Mapping(m) => m,
40 _ => {41 _ => {
...@@ -52,6 +53,10 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {...@@ -52,6 +53,10 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
52 _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")),53 _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")),
53 };54 };
5455
56 if skip_keys.contains(&key_str.as_str()) {
57 continue;
58 }
59
55 if matches!(val, serde_yml::Value::Tagged(_)) {60 if matches!(val, serde_yml::Value::Tagged(_)) {
56 return Err("YAML tags are not supported in markodown frontmatter".to_string());61 return Err("YAML tags are not supported in markodown frontmatter".to_string());
57 }62 }
...@@ -65,12 +70,25 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {...@@ -65,12 +70,25 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result<String, String> {
65 Ok(exports.join("\n"))70 Ok(exports.join("\n"))
66}71}
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
68/// Result of extracting preamble (imports/blank lines) and frontmatter from source83/// Result of extracting preamble (imports/blank lines) and frontmatter from source
69pub struct PreambleResult {84pub struct PreambleResult {
70 /// The generated output (imports + exports from frontmatter)85 /// The generated output (imports + exports from frontmatter)
71 pub output: String,86 pub output: String,
72 /// Number of bytes consumed from the source87 /// Number of bytes consumed from the source
73 pub bytes_consumed: usize,88 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>,
74}92}
7593
76/// Extract leading blank lines, import statements, and frontmatter from source.94/// Extract leading blank lines, import statements, and frontmatter from source.
...@@ -115,8 +133,13 @@ pub fn extract_preamble_and_frontmatter(...@@ -115,8 +133,13 @@ pub fn extract_preamble_and_frontmatter(
115133
116 // Parse YAML and convert to exports134 // Parse YAML and convert to exports
117 let yaml = strip_js_comments(&yaml_lines.join("\n"));135 let yaml = strip_js_comments(&yaml_lines.join("\n"));
118 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {136 let (exports, layout_import) = match serde_yml::from_str::<serde_yml::Value>(&yaml) {
119 Ok(value) => yaml_to_exports(&value).map_err(OxcDiagnostic::error)?,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 }
120 Err(e) => {143 Err(e) => {
121 let offset = e144 let offset = e
122 .location()145 .location()
...@@ -160,9 +183,109 @@ pub fn extract_preamble_and_frontmatter(...@@ -160,9 +183,109 @@ pub fn extract_preamble_and_frontmatter(
160 Ok(Some(PreambleResult {183 Ok(Some(PreambleResult {
161 output,184 output,
162 bytes_consumed,185 bytes_consumed,
186 layout_import,
163 }))187 }))
164}188}
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
166impl BlockRule for Rule {289impl BlockRule for Rule {
167 fn run(state: &mut BlockState) -> Option<(Node, usize)> {290 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
168 // Only at the start of the document (no preamble case - handled by preprocessing)291 // Only at the start of the document (no preamble case - handled by preprocessing)
...@@ -194,7 +317,7 @@ impl BlockRule for Rule {...@@ -194,7 +317,7 @@ impl BlockRule for Rule {
194 // Parse YAML and convert to exports317 // Parse YAML and convert to exports
195 let yaml = strip_js_comments(&lines.join("\n"));318 let yaml = strip_js_comments(&lines.join("\n"));
196 let exports = match serde_yml::from_str::<serde_yml::Value>(&yaml) {319 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"]) {
198 Ok(js) => js,321 Ok(js) => js,
199 Err(msg) => {322 Err(msg) => {
200 return Some((323 return Some((
src/plugin/statement.rs+9-1
...@@ -40,8 +40,16 @@ impl BlockRule for Rule {...@@ -40,8 +40,16 @@ impl BlockRule for Rule {
4040
41 let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..];41 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
43 let statement_end =51 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..]) {
45 Ok(ok) => ok,53 Ok(ok) => ok,
46 Err(err) => {54 Err(err) => {
47 return Some((55 return Some((
tests/fixtures.rs+47
...@@ -236,3 +236,50 @@ fn fixture_25_code_block_import() {...@@ -236,3 +236,50 @@ fn fixture_25_code_block_import() {
236fn fixture_26_code_block_import() {236fn fixture_26_code_block_import() {
237 run_fixture_with_options("26-code-block-import", None, None, None);237 run_fixture_with_options("26-code-block-import", None, None, None);
238}238}
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 @@...@@ -1,4 +1,10 @@
1import Layout__markodown__ from "./layout.marko";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
2<define/Heading_1__markodown__>8<define/Heading_1__markodown__>
3good morning9good morning
4</>10</>
...@@ -18,12 +24,12 @@ rain <strong>time</strong>...@@ -18,12 +24,12 @@ rain <strong>time</strong>
18 { level: 3, id: 'snow-time', content: Heading_3__markodown__ },24 { level: 3, id: 'snow-time', content: Heading_3__markodown__ },
19 { level: 4, id: 'id', content: Heading_4__markodown__ },25 { level: 4, id: 'id', content: Heading_4__markodown__ },
20]>26]>
21<h1#good-morning><Heading_1__markodown__/>27<HeadingComponent__markodown__#good-morning level=1><Heading_1__markodown__/>
22</h1>28</>
23<h2 id="good-night"><Heading_2__markodown__/>29<HeadingComponent__markodown__#good-night level=2><Heading_2__markodown__/>
24</h2>30</>
25<h3 id="snow-time"><Heading_3__markodown__/>31<HeadingComponent__markodown__#snow-time level=3><Heading_3__markodown__/>
26</h3>32</>
27<h4#id><Heading_4__markodown__/>33<HeadingComponent__markodown__#id level=4><Heading_4__markodown__/>
28</>34</>
29</>35</>
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!!