authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-15 23:27:12-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-16 00:51:53-08:00
logc796d5ef4aa87f2db690d0090873c0c94c3aae04
tree3ae258caf772b940728eb0db848f7eff91ceeef7
parentcbd481161f7342c8238e2147f45e6048ad32ff4b
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: better outline generation


16 files changed, 1487 insertions(+), 340 deletions(-)

README.md+105-45
......@@ -1,5 +1,8 @@
11# Markodown
22
3> STATUS: Markodown is not yet in use at paperclover.net. However, the API is
4> complete and the library is functional. Give it a try!
5
36This is a weird markup language that combines features of [Markdown] and
47[Marko]. You can think of this as an alternative universe to MDX. Since Marko
58components are really easy to write, it makes this a great tool for writing
......@@ -9,12 +12,15 @@ leveraging the existing ecosystem.
912[Markdown]: https://en.wikipedia.org/wiki/Markdown
1013[Marko]: https://markojs.com/
1114
12> STATUS: Functional, but many components have not gone through battle testing.
13> There are likely edge cases where syntax breaks. Some portions of the overall
14> glue is generated by AI without full audits. The Marko tag parser is hand
15> written according to the documentation, but that document is not a
16> specification and there are likely implementation differences. Treat with
17> caution.
15> **CONTENTS**:
16>
17> - [Install](#install)
18> - [Components](#components)
19> - [Outline / Table of Contents](#outline-table-of-contents)
20> - [Frontmatter](#outline-table-of-contents)
21> - [Comments](#comments)
22> - [Paragraph Detection](#paragraph-detection)
23> - [Static Statements](#static-statements)
1824
1925Here's a glance at how things look. Complete example documents in <./examples>
2026
......@@ -65,42 +71,42 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}.
6571</blog-layout>
6672````
6773
68## Syntax Reference
74## Install
6975
70### Paragraphs
71
72All text blocks with spaces around them will be wrapped in a paragraph, like
73markdown does.
76Markdown is distributed on [NPM](http://npmjs.com/markodown) and
77[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs.
7478
79```sh
80npm i markodown
81npx jsr add @clo/markodown
7582```
76<div>not wrapped</div>
77<div>
78not wrapped
79</div>
8083
81<div>
84The compiler can be directly used from `transform`, and there are also plugins
85for Rollup/Rolldown/Vite and esbuild. For example, configure Markdown with Marko
86Run:
8287
83this paragraph gets wrapped in a `<p>` tag
84
85</div>
86```
87
88Note that all text is still processed for other block types
89
90```
91<div>[back to home](/)</div>
92// all other
93<nav>
94- [homepage](/)
95- [second page](/second)
96- [third page](/second)
97</nav>
88```ts
89import marko from "@marko/run/vite";
90import markodown from "markodown";
91import { defineConfig } from "vite";
92
93export default defineConfig({
94 plugins: [
95 marko(),
96 markodown({
97 // optionally wrap all markdown files in a layout, this component is
98 // given a list of headers to construct a table of contents.
99 layoutImport: "../tags/markdown-layout.marko",
100 }),
101 ],
102});
98103```
99104
100### Components
105## Components
101106
102All Marko features, such as [tag resolution], [attribute tags], [class
103shorthands], and template expressions.
107All Marko features, such as [tag resolution], [attribute tags],
108[class shorthands], and template expressions. This makes it so much easier to
109add complex content to your pages.
104110
105111```
106112## cool video
......@@ -109,28 +115,49 @@ shorthands], and template expressions.
109115 <@header>**music video**: in the summer</>
110116</clover-video>
111117
112<div.footer>
113 (c) ${new Date().getFullYear()}
118<footer.copyright-info>
119 made with love... (c) ${new Date().getFullYear()}
114120</div>
115121```
116122
117123[tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags
124[attribute tags]: https://markojs.com/docs/reference/language#attribute-tags
125[class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id
118126
119### Comments
127## Outline / Table of Contents
120128
121Line, Block, and HTML comments work like they do in Marko/JavaScript.
129You can use Markodown to write blogs and long documents, then extract a table of
130contents. This is done with two mechanisms.
131
132- `layoutImport` which wraps the entire document in a component, which is given
133 three attributes.
134 - `content`: the rendered content.
135 - `module`: the module namespace for the compiled Markodown file.
136 - `outline`: an array of `Heading` objects.
137- `componentImports`, which can let you customize the rendering of the headers
138 themselves.
139
140Using the basic heading tags is awesome, because you can very easily customize
141the generated permalinks for each heading, and still use Markdown within the
142heading titles.
122143
123144```
124# My Blog
145# my blog post
125146
126Text that is complete.
147<h2#markdown>about `markdown`</>
127148
128// ## An unfinished section of the blog
129//
130// TODO: we gotta add this!
149...
150
151<h2#marko>about `marko`</>
152
153...
154
155<h3#marko-extras>some extra details</>
156
157...
131158```
132159
133### Frontmatter
160## Frontmatter
134161
135162All frontmatter fields are converted into exports. For example, a framework that
136163reads the `meta` export for Open Graph can be easily satisfied with frontmatter.
......@@ -150,7 +177,40 @@ meta:
150177# ${meta.title}
151178```
152179
153### Static Statements
180## Comments
181
182Line, Block, and HTML comments work like they do in Marko/JavaScript.
183
184```
185# My Blog
186
187Text that is complete.
188
189// ## An unfinished section of the blog
190//
191// TODO: we gotta finish it!
192```
193
194## Paragraph Detection
195
196Like Markdown, you can place content between components, but you can also place
197inline markdown anywhere between tags. Effectively, this means that text gets
198wrapped in `<p>` tags if there is a blank line above and below it.
199
200```
201<div>not wrapped</div>
202<div>
203not wrapped either
204</div>
205
206<div>
207
208this paragraph gets wrapped in a `<p>` tag!
209
210</div>
211```
212
213## Static Statements
154214
155215Define module-level functions and variables.
156216
lib/jsr.json+10-1
......@@ -1,4 +1,13 @@
11{
22 "name": "@clo/markodown",
3 "version": "1.0.0-rc.1"
3 "version": "1.0.0-rc.1",
4 "license": "MIT",
5 "exports": {
6 ".": "./mod.ts",
7 "./esbuild.ts": "./esbuild.ts",
8 "./rollup.ts": "./rollup.ts"
9 },
10 "imports": {
11 "@marko/compiler": "@marko/compiler@^5.39.55"
12 }
413}
lib/mod.ts+17
......@@ -5,6 +5,20 @@ wasm.initSync({ module: bytes });
55
66type Transformed = Success | Failure;
77
8/** Configuration for replacing built-in elements with custom components. */
9export interface ComponentImports {
10 /** Replace heading elements (h1-h6) with a custom component. Receives `level=1-6`. */
11 heading?: string;
12 /** Replace code blocks with a custom component. Receives `language` and `meta`. */
13 codeBlock?: string;
14 /** Replace link elements with a custom component. */
15 link?: string;
16 /** Replace image elements with a custom component. */
17 image?: string;
18 /** Replace blockquote elements with a custom component. */
19 blockquote?: string;
20}
21
822export interface TransformOptions {
923 source: string;
1024 /**
......@@ -16,6 +30,8 @@ export interface TransformOptions {
1630 format?: Array<"marko" | "html">;
1731 /** Wraps the component in another component. Enables Table of Contents generation */
1832 layoutImport?: string;
33 /** Replace built-in elements with custom components */
34 componentImports?: ComponentImports;
1935}
2036
2137export type OutputFormat = "marko" | "html";
......@@ -69,5 +85,6 @@ export function transform(options: TransformOptions): Transformed {
6985 options.source,
7086 forceFormat,
7187 options?.layoutImport,
88 options?.componentImports,
7289 );
7390}
src/component_transforms.rs created+423
......@@ -0,0 +1,423 @@
1//! Component transformations for replacing built-in elements with custom components.
2//!
3//! This module walks the AST and replaces heading, code block, link, image, and
4//! blockquote elements with calls to custom components specified in ComponentImports.
5
6use markdown_it::{Node, NodeValue, Renderer};
7
8use crate::marko_ast::OpenOwned;
9use crate::plugin::tags::{MarkoBlockComplete, MarkoClose, MarkoOpen, MarkoOpenWithText};
10use crate::ComponentImports;
11
12/// Component name suffix to avoid collisions
13const SUFFIX: &str = "__markodown__";
14
15/// Heading component name
16fn heading_component() -> String {
17 format!("HeadingComponent{}", SUFFIX)
18}
19
20/// Code block component name
21fn code_block_component() -> String {
22 format!("CodeBlockComponent{}", SUFFIX)
23}
24
25/// Link component name
26fn link_component() -> String {
27 format!("LinkComponent{}", SUFFIX)
28}
29
30/// Image component name
31fn image_component() -> String {
32 format!("ImageComponent{}", SUFFIX)
33}
34
35/// Blockquote component name
36fn blockquote_component() -> String {
37 format!("BlockquoteComponent{}", SUFFIX)
38}
39
40/// Generate import statements for all configured component imports
41pub fn generate_imports(imports: &ComponentImports) -> String {
42 let mut result = String::new();
43
44 if let Some(path) = &imports.heading {
45 result.push_str(&format!(
46 "import {} from \"{}\";\n",
47 heading_component(),
48 path
49 ));
50 }
51
52 if let Some(path) = &imports.code_block {
53 result.push_str(&format!(
54 "import {} from \"{}\";\n",
55 code_block_component(),
56 path
57 ));
58 }
59
60 if let Some(path) = &imports.link {
61 result.push_str(&format!("import {} from \"{}\";\n", link_component(), path));
62 }
63
64 if let Some(path) = &imports.image {
65 result.push_str(&format!(
66 "import {} from \"{}\";\n",
67 image_component(),
68 path
69 ));
70 }
71
72 if let Some(path) = &imports.blockquote {
73 result.push_str(&format!(
74 "import {} from \"{}\";\n",
75 blockquote_component(),
76 path
77 ));
78 }
79
80 result
81}
82
83/// Transform all elements according to the component imports configuration.
84pub fn transform_components(node: &mut Node, imports: &ComponentImports) {
85 // Process children first (bottom-up traversal)
86 for child in &mut node.children {
87 transform_components(child, imports);
88 }
89
90 // Transform this node if applicable
91 if imports.heading.is_some() {
92 transform_heading(node);
93 }
94
95 if imports.code_block.is_some() {
96 transform_code_block(node);
97 }
98
99 if imports.link.is_some() {
100 transform_link(node);
101 }
102
103 if imports.image.is_some() {
104 transform_image(node);
105 }
106
107 if imports.blockquote.is_some() {
108 transform_blockquote(node);
109 }
110}
111
112/// A code block component node that renders as `<CodeBlockComponent language="x" meta="y">content</>`
113#[derive(Debug)]
114struct CodeBlockComponentNode {
115 /// Language identifier (e.g., "ts", "rust")
116 language: Option<String>,
117 /// Additional meta info after language
118 meta: Option<String>,
119 /// The code content
120 content: String,
121}
122
123impl NodeValue for CodeBlockComponentNode {
124 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
125 fmt.cr();
126 fmt.text_raw(&format!("<{}", code_block_component()));
127
128 if let Some(lang) = &self.language {
129 fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang)));
130 }
131 if let Some(meta) = &self.meta {
132 fmt.text_raw(&format!(" meta=\"{}\"", escape_attr(meta)));
133 }
134
135 fmt.text_raw(">\n");
136 // Escape content for Marko - need to wrap in a text node or use raw content
137 // Use a template literal to preserve the content exactly
138 fmt.text_raw(&format!("${{{:?}}}", self.content));
139 fmt.text_raw("\n</>\n");
140 }
141}
142
143/// Escape a string for use in an attribute value
144fn escape_attr(s: &str) -> String {
145 s.replace('\\', "\\\\")
146 .replace('"', "\\\"")
147 .replace('\n', "\\n")
148}
149
150/// Transform a code block node to use the code block component.
151fn transform_code_block(node: &mut Node) {
152 // Handle fenced code blocks (``` or ~~~)
153 if let Some(fence) = node.cast::<markdown_it::plugins::cmark::block::fence::CodeFence>() {
154 let info = &fence.info;
155 let mut parts = info.split_whitespace();
156 let language = parts
157 .next()
158 .filter(|s| !s.is_empty())
159 .map(|s| s.to_string());
160 let meta = {
161 let rest: String = parts.collect::<Vec<_>>().join(" ");
162 if rest.is_empty() {
163 None
164 } else {
165 Some(rest)
166 }
167 };
168 let content = fence.content.clone();
169
170 let new_node = Node::new(CodeBlockComponentNode {
171 language,
172 meta,
173 content,
174 });
175
176 *node = new_node;
177 return;
178 }
179
180 // Handle indented code blocks (4 spaces)
181 if let Some(code) = node.cast::<markdown_it::plugins::cmark::block::code::CodeBlock>() {
182 let content = code.content.clone();
183
184 let new_node = Node::new(CodeBlockComponentNode {
185 language: None,
186 meta: None,
187 content,
188 });
189
190 *node = new_node;
191 }
192}
193
194/// Transform a heading node (h1-h6) to use the heading component.
195fn transform_heading(node: &mut Node) {
196 // Handle markdown ATX headings (# ## ### etc)
197 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
198 let level = heading.level;
199
200 // Create a new MarkoBlockComplete to replace this node
201 let mut open = OpenOwned::from_tag_name(&heading_component());
202 open.insert_attr(&format!("level={}", level));
203
204 // We need to take ownership of children
205 let children = std::mem::take(&mut node.children);
206
207 // Create the new node
208 let mut new_node = Node::new(MarkoBlockComplete {
209 open,
210 close_tag: "</>".to_string(),
211 });
212 new_node.children = children;
213 new_node.srcmap = node.srcmap;
214
215 *node = new_node;
216 return;
217 }
218
219 // Handle setext headings (underline style)
220 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
221 {
222 let level = heading.level;
223
224 let mut open = OpenOwned::from_tag_name(&heading_component());
225 open.insert_attr(&format!("level={}", level));
226
227 let children = std::mem::take(&mut node.children);
228
229 let mut new_node = Node::new(MarkoBlockComplete {
230 open,
231 close_tag: "</>".to_string(),
232 });
233 new_node.children = children;
234 new_node.srcmap = node.srcmap;
235
236 *node = new_node;
237 return;
238 }
239
240 // Handle Marko open tags (h1-h6) - these have separate close tags
241 if let Some(marko_open) = node.cast_mut::<MarkoOpen>() {
242 if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) {
243 marko_open.open.replace_tag_name(&heading_component());
244 marko_open.open.insert_attr(&format!("level={}", level));
245 }
246 return;
247 }
248
249 // Handle Marko open with text tags (h1-h6)
250 if let Some(marko_open) = node.cast_mut::<MarkoOpenWithText>() {
251 if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) {
252 marko_open.open.replace_tag_name(&heading_component());
253 marko_open.open.insert_attr(&format!("level={}", level));
254 }
255 return;
256 }
257
258 // Handle Marko block complete tags (h1-h6) - open and close on same line
259 if let Some(marko_block) = node.cast_mut::<MarkoBlockComplete>() {
260 if let Some(level) = parse_heading_level(marko_block.open.as_ref().tag_name()) {
261 marko_block.open.replace_tag_name(&heading_component());
262 marko_block.open.insert_attr(&format!("level={}", level));
263 // Also update close tag to generic </>
264 marko_block.close_tag = "</>".to_string();
265 }
266 return;
267 }
268
269 // Handle close tags - need to replace </h1> etc with </>
270 if let Some(marko_close) = node.cast_mut::<MarkoClose>() {
271 if let Some(tag_name) = &marko_close.tag_name {
272 if parse_heading_level(tag_name).is_some() {
273 // Replace with generic close tag
274 marko_close.content = "</>".to_string();
275 marko_close.tag_name = None;
276 marko_close.tag_name_len = None;
277 }
278 }
279 }
280}
281
282/// Parse h1-h6 tag names and return the level
283fn parse_heading_level(tag_name: &str) -> Option<u8> {
284 match tag_name {
285 "h1" => Some(1),
286 "h2" => Some(2),
287 "h3" => Some(3),
288 "h4" => Some(4),
289 "h5" => Some(5),
290 "h6" => Some(6),
291 _ => None,
292 }
293}
294
295/// A link component node that renders as `<LinkComponent href="..." title="...">content</>`
296#[derive(Debug)]
297struct LinkComponentNode {
298 href: String,
299 title: Option<String>,
300}
301
302impl NodeValue for LinkComponentNode {
303 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
304 fmt.text_raw(&format!(
305 "<{} href=\"{}\"",
306 link_component(),
307 escape_attr(&self.href)
308 ));
309 if let Some(title) = &self.title {
310 fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title)));
311 }
312 fmt.text_raw(">");
313 fmt.contents(&node.children);
314 fmt.text_raw("</>");
315 }
316}
317
318/// Transform a link node to use the link component.
319fn transform_link(node: &mut Node) {
320 if let Some(link) = node.cast::<markdown_it::plugins::cmark::inline::link::Link>() {
321 let href = link.url.clone();
322 let title = link.title.clone();
323 let children = std::mem::take(&mut node.children);
324
325 let mut new_node = Node::new(LinkComponentNode { href, title });
326 new_node.children = children;
327 new_node.srcmap = node.srcmap;
328
329 *node = new_node;
330 }
331}
332
333/// An image component node that renders as `<ImageComponent src="..." alt="..." title="..." />`
334#[derive(Debug)]
335struct ImageComponentNode {
336 src: String,
337 alt: String,
338 title: Option<String>,
339}
340
341impl NodeValue for ImageComponentNode {
342 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
343 fmt.text_raw(&format!(
344 "<{} src=\"{}\" alt=\"{}\"",
345 image_component(),
346 escape_attr(&self.src),
347 escape_attr(&self.alt)
348 ));
349 if let Some(title) = &self.title {
350 fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title)));
351 }
352 fmt.text_raw(" />");
353 }
354}
355
356/// Transform an image node to use the image component.
357fn transform_image(node: &mut Node) {
358 if let Some(image) = node.cast::<markdown_it::plugins::cmark::inline::image::Image>() {
359 let src = image.url.clone();
360 let alt = node.collect_text();
361 let title = image.title.clone();
362
363 let new_node = Node::new(ImageComponentNode { src, alt, title });
364 *node = new_node;
365 }
366}
367
368/// A blockquote component node that wraps content in `<BlockquoteComponent>content</>`
369#[derive(Debug)]
370struct BlockquoteComponentNode;
371
372impl NodeValue for BlockquoteComponentNode {
373 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
374 fmt.cr();
375 fmt.text_raw(&format!("<{}>", blockquote_component()));
376 fmt.cr();
377 fmt.contents(&node.children);
378 fmt.cr();
379 fmt.text_raw("</>");
380 fmt.cr();
381 }
382}
383
384/// Transform a blockquote node to use the blockquote component.
385fn transform_blockquote(node: &mut Node) {
386 if node
387 .cast::<markdown_it::plugins::cmark::block::blockquote::Blockquote>()
388 .is_some()
389 {
390 let children = std::mem::take(&mut node.children);
391
392 let mut new_node = Node::new(BlockquoteComponentNode);
393 new_node.children = children;
394 new_node.srcmap = node.srcmap;
395
396 *node = new_node;
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn test_generate_imports_heading() {
406 let imports = ComponentImports {
407 heading: Some("./heading.marko".to_string()),
408 ..Default::default()
409 };
410 let result = generate_imports(&imports);
411 assert_eq!(
412 result,
413 "import HeadingComponent__markodown__ from \"./heading.marko\";\n"
414 );
415 }
416
417 #[test]
418 fn test_generate_imports_empty() {
419 let imports = ComponentImports::default();
420 let result = generate_imports(&imports);
421 assert_eq!(result, "");
422 }
423}
src/lib.rs+67-29
......@@ -1,5 +1,7 @@
1pub mod component_transforms;
12pub mod marko;
23pub mod marko_ast;
4pub mod outline;
35pub mod plugin;
46pub mod typescript;
57pub mod wasm;
......@@ -7,11 +9,29 @@ pub mod wasm;
79use oxc_diagnostics::{LabeledSpan, OxcDiagnostic};
810use oxc_span::Span;
911use plugin::tags::{MarkoClose, MarkoOpen};
10use serde::Serialize;
11use serde_json;
12use serde::{Deserialize, Serialize};
1213use std::borrow::Cow;
1314use wasm_bindgen::prelude::wasm_bindgen;
1415
16/// Configuration for replacing built-in elements with custom components.
17/// Each field is an optional import path for the component.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct ComponentImports {
21 /// Replace heading elements (h1-h6) with a custom component.
22 /// The component receives `level=1-6` and all original attributes.
23 pub heading: Option<String>,
24 /// Replace code blocks (```) with a custom component.
25 /// The component receives `language` and `meta` attributes.
26 pub code_block: Option<String>,
27 /// Replace link elements (<a>) with a custom component.
28 pub link: Option<String>,
29 /// Replace image elements (<img>) with a custom component.
30 pub image: Option<String>,
31 /// Replace blockquote elements with a custom component.
32 pub blockquote: Option<String>,
33}
34
1535pub struct Output {
1636 pub text: String,
1737 pub format: OutputFormat,
......@@ -29,7 +49,8 @@ pub fn transform(
2949 source: &str,
3050 force: Option<OutputFormat>,
3151 layout_import: Option<String>,
32 self_path: Option<String>,
52 component_imports: Option<ComponentImports>,
53 self_import: Option<String>,
3354) -> Result<Output, Vec<OxcDiagnostic>> {
3455 // Pre-process to extract preamble (blank lines, imports) and frontmatter
3556 // This is needed because markdown-it skips blank lines before running block rules
......@@ -69,66 +90,83 @@ pub fn transform(
6990 return Err(errors);
7091 }
7192
93 // Determine output format
94 // componentImports forces Marko format since we add imports
7295 let format = force.unwrap_or_else(|| {
73 if has_marko_features(&ast) {
96 if component_imports.is_some() || has_marko_features(&ast) {
7497 OutputFormat::Marko
7598 } else {
7699 OutputFormat::Html
77100 }
78101 });
79102
80 if force.is_some() && format == OutputFormat::Html && has_marko_features(&ast) {
81 return Err(vec![OxcDiagnostic::error(
82 "Cannot output HTML: document contains Marko-specific features",
83 )]);
103 if force.is_some() && format == OutputFormat::Html {
104 if has_marko_features(&ast) {
105 return Err(vec![OxcDiagnostic::error(
106 "Cannot output HTML: document contains Marko-specific features",
107 )]);
108 }
109 if component_imports.is_some() {
110 return Err(vec![OxcDiagnostic::error(
111 "Cannot output HTML: componentImports requires Marko output",
112 )]);
113 }
84114 }
85115
86 // Collect headings for TOC if layout_import is provided
87 let outline_json;
88 let headings = if layout_import.is_some() {
89 let collected = plugin::toc::collect_headings(&ast);
90 outline_json = Some(serde_json::to_string(&collected).unwrap_or_default());
91 collected
116 // Collect headings, inject IDs, and extract content if layout_import is provided
117 let outline_result = if layout_import.is_some() {
118 let result = outline::collect_and_extract(&mut ast, preamble_offset as u32);
119 if !result.errors.is_empty() {
120 return Err(result.errors);
121 }
122 Some(result)
92123 } else {
93 outline_json = None;
94 Vec::new()
124 None
95125 };
96126
127 // Transform components if componentImports is configured
128 // This must happen AFTER outline extraction so headings are still h1-h6 when collected
129 if let Some(ref imports) = component_imports {
130 component_transforms::transform_components(&mut ast, imports);
131 }
132
97133 // Extract statements before rendering - they need to be hoisted above Layout
98134 let extracted_statements = hoist_statements(&mut ast);
99135
100136 let mut text = ast.render();
101137
102 // Inject heading IDs if we collected headings
103 if !headings.is_empty() {
104 text = plugin::toc::inject_heading_ids(&text, &headings);
105 }
106
107 // Build hoisted statements: preamble (frontmatter exports) + extracted statements
138 // Build hoisted content: preamble + component imports + extracted statements + defines
108139 let mut hoisted = String::new();
109140 if let Some(preamble) = preamble_output {
110141 hoisted.push_str(&preamble);
111142 }
143 // Add component imports
144 if let Some(ref imports) = component_imports {
145 hoisted.push_str(&component_transforms::generate_imports(imports));
146 }
112147 hoisted.push_str(&extracted_statements);
113148
114149 // Wrap with Layout component if layout_import is provided
115 // Statements must come before the Layout tag
116 if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) {
117 if let Some(self_path) = self_path {
150 // Statements and defines must come before the Layout tag
151 if let (Some(layout_path), Some(result)) = (layout_import, outline_result) {
152 let outline_array = outline::format_outline_array(&result.headings);
153
154 // Add defines after other hoisted content
155 hoisted.push_str(&result.defines);
156
157 if let Some(self_path) = self_import {
118158 text = format!(
119 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=self__markodown__ outline={}>\n{}</>",
159 "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}<Layout__markodown__ module=self__markodown__ outline={outline_array}>\n{}</>",
120160 layout_path,
121161 self_path,
122162 hoisted,
123 outline,
124163 text
125164 );
126165 } else {
127166 text = format!(
128 "import Layout__markodown__ from \"{}\";\n{}\n<Layout__markodown__ module=null outline={}>\n{}</>",
167 "import Layout__markodown__ from \"{}\";\n{}<Layout__markodown__ module=null outline={outline_array}>\n{}</>",
129168 layout_path,
130169 hoisted,
131 outline,
132170 text
133171 );
134172 }
src/main.rs+45-6
......@@ -1,5 +1,5 @@
11use clap::{Parser, ValueEnum};
2use markodown::{transform, OutputFormat};
2use markodown::{transform, ComponentImports, OutputFormat};
33use oxc_diagnostics::GraphicalReportHandler;
44use std::fs;
55use std::path::PathBuf;
......@@ -22,23 +22,62 @@ struct Cli {
2222
2323 /// Force the output format (auto-detected by default)
2424 #[arg(long)]
25 output_format: Option<CliOutputFormat>,
25 force_format: Option<CliOutputFormat>,
2626
2727 /// Layout component to wrap the output with. This component will recieve
2828 /// the module and a generated table of contents.
2929 #[arg(long)]
30 layout: Option<String>,
30 layout_import: Option<String>,
31
32 /// Replace heading elements (h1-h6) with a custom component
33 #[arg(long)]
34 heading_import: Option<String>,
35
36 /// Replace code blocks with a custom component
37 #[arg(long)]
38 code_block_import: Option<String>,
39
40 /// Replace link elements with a custom component
41 #[arg(long)]
42 link_import: Option<String>,
43
44 /// Replace image elements with a custom component
45 #[arg(long)]
46 image_import: Option<String>,
47
48 /// Replace blockquote elements with a custom component
49 #[arg(long)]
50 blockquote_import: Option<String>,
3151}
3252
3353fn main() {
3454 let cli = Cli::parse();
3555
36 let force = cli.output_format.map(|f| match f {
56 let force = cli.force_format.map(|f| match f {
3757 CliOutputFormat::Html => OutputFormat::Html,
3858 CliOutputFormat::Marko => OutputFormat::Marko,
3959 });
4060
41 let layout_import = cli.layout;
61 let layout_import = cli.layout_import;
62
63 // Build component imports from CLI flags
64 let component_imports = ComponentImports {
65 heading: cli.heading_import,
66 code_block: cli.code_block_import,
67 link: cli.link_import,
68 image: cli.image_import,
69 blockquote: cli.blockquote_import,
70 };
71 let component_imports = if component_imports.heading.is_some()
72 || component_imports.code_block.is_some()
73 || component_imports.link.is_some()
74 || component_imports.image.is_some()
75 || component_imports.blockquote.is_some()
76 {
77 Some(component_imports)
78 } else {
79 None
80 };
4281
4382 let source = match fs::read_to_string(&cli.file) {
4483 Ok(s) => s,
......@@ -48,7 +87,7 @@ fn main() {
4887 }
4988 };
5089
51 match transform(&source, force, layout_import, None) {
90 match transform(&source, force, layout_import, component_imports, None) {
5291 Err(errors) => {
5392 let handler = GraphicalReportHandler::new();
5493 for error in &errors {
src/marko_ast.rs+151
......@@ -142,6 +142,82 @@ impl OpenOwned {
142142 }
143143 }
144144 }
145
146 /// Replace the tag name while preserving all shorthands and attributes.
147 /// For example: `<h3#foo class="bar">` → `<Heading#foo class="bar">`
148 pub fn replace_tag_name(&mut self, new_name: &str) {
149 let old_start = self.literal_tag_name.start as usize;
150 let old_end = self.literal_tag_name.end as usize;
151 let old_len = old_end - old_start;
152 let new_len = new_name.len();
153 let delta = new_len as i32 - old_len as i32;
154
155 // Build new source
156 let mut new_src = String::with_capacity((self.src.len() as i32 + delta) as usize);
157 new_src.push_str(&self.src[..old_start]);
158 new_src.push_str(new_name);
159 new_src.push_str(&self.src[old_end..]);
160
161 // Update spans
162 self.literal_tag_name = Span::new(old_start as u32, (old_start + new_len) as u32);
163 self.shorthand_end = (self.shorthand_end as i32 + delta) as u32;
164
165 // Update id span if present
166 if let AttributeValue::Static { span, is_quoted } = self.id {
167 self.id = AttributeValue::Static {
168 span: Span::new(
169 (span.start as i32 + delta) as u32,
170 (span.end as i32 + delta) as u32,
171 ),
172 is_quoted,
173 };
174 }
175
176 self.src = new_src;
177 }
178
179 /// Insert an attribute after the shorthands (before other attributes).
180 /// For example: `<Heading#foo class="bar">` with `level=3` → `<Heading#foo level=3 class="bar">`
181 pub fn insert_attr(&mut self, attr: &str) {
182 let insert_pos = self.shorthand_end as usize;
183 let insert_str = format!(" {}", attr);
184 let delta = insert_str.len();
185
186 // Build new source
187 let mut new_src = String::with_capacity(self.src.len() + delta);
188 new_src.push_str(&self.src[..insert_pos]);
189 new_src.push_str(&insert_str);
190 new_src.push_str(&self.src[insert_pos..]);
191
192 // Update shorthand_end to point after the new attribute
193 self.shorthand_end += delta as u32;
194
195 // Update id span if it comes after the insertion point
196 if let AttributeValue::Static { span, is_quoted } = self.id {
197 if span.start >= insert_pos as u32 {
198 self.id = AttributeValue::Static {
199 span: Span::new(span.start + delta as u32, span.end + delta as u32),
200 is_quoted,
201 };
202 }
203 }
204
205 self.src = new_src;
206 }
207
208 /// Create a new OpenOwned from a tag name (for synthesizing tags).
209 /// Creates a simple `<tagname>` with no attributes.
210 pub fn from_tag_name(tag_name: &str) -> Self {
211 let src = format!("<{}>", tag_name);
212 let tag_name_span = Span::new(1, 1 + tag_name.len() as u32);
213 OpenOwned {
214 src,
215 literal_tag_name: tag_name_span,
216 self_closing: false,
217 shorthand_end: 1 + tag_name.len() as u32,
218 id: AttributeValue::None,
219 }
220 }
145221}
146222
147223#[derive(Debug, Clone, PartialEq, Eq)]
......@@ -228,3 +304,78 @@ impl<'a> LexState<'a> {
228304 }
229305 }
230306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::marko::parse_open;
312
313 #[test]
314 fn test_replace_tag_name_simple() {
315 let mut open = OpenOwned::from_tag_name("h3");
316 assert_eq!(open.src, "<h3>");
317 open.replace_tag_name("Heading");
318 assert_eq!(open.src, "<Heading>");
319 assert_eq!(open.as_ref().tag_name(), "Heading");
320 }
321
322 #[test]
323 fn test_replace_tag_name_with_shorthand() {
324 let mut open = parse_open("<h3#foo>").unwrap().to_owned();
325 assert_eq!(open.as_ref().tag_name(), "h3");
326 open.replace_tag_name("Heading");
327 assert_eq!(open.src, "<Heading#foo>");
328 assert_eq!(open.as_ref().tag_name(), "Heading");
329 // ID should still be accessible
330 assert_eq!(open.as_ref().id_attr(), Some(("foo", false)));
331 }
332
333 #[test]
334 fn test_replace_tag_name_with_attributes() {
335 let mut open = parse_open("<h3 class=\"section\">").unwrap().to_owned();
336 open.replace_tag_name("HeadingComponent__markodown__");
337 assert_eq!(
338 open.src,
339 "<HeadingComponent__markodown__ class=\"section\">"
340 );
341 }
342
343 #[test]
344 fn test_insert_attr_simple() {
345 let mut open = OpenOwned::from_tag_name("Heading");
346 open.insert_attr("level=3");
347 assert_eq!(open.src, "<Heading level=3>");
348 }
349
350 #[test]
351 fn test_insert_attr_with_shorthand() {
352 let mut open = parse_open("<Heading#foo>").unwrap().to_owned();
353 open.insert_attr("level=3");
354 assert_eq!(open.src, "<Heading#foo level=3>");
355 // ID should still be accessible
356 assert_eq!(open.as_ref().id_attr(), Some(("foo", false)));
357 }
358
359 #[test]
360 fn test_insert_attr_with_existing_attrs() {
361 let mut open = parse_open("<Heading#foo class=\"section\">")
362 .unwrap()
363 .to_owned();
364 open.insert_attr("level=3");
365 assert_eq!(open.src, "<Heading#foo level=3 class=\"section\">");
366 }
367
368 #[test]
369 fn test_combined_replace_and_insert() {
370 let mut open = parse_open("<h3#about class=\"section\">")
371 .unwrap()
372 .to_owned();
373 open.replace_tag_name("HeadingComponent__markodown__");
374 open.insert_attr("level=3");
375 assert_eq!(
376 open.src,
377 "<HeadingComponent__markodown__#about level=3 class=\"section\">"
378 );
379 assert_eq!(open.as_ref().id_attr(), Some(("about", false)));
380 }
381}
src/outline.rs created+574
......@@ -0,0 +1,574 @@
1//! Outline extraction for headings.
2//!
3//! Walks the AST to collect heading information, inject IDs, and extract content
4//! into hoisted `<define/>` blocks for use in the outline.
5
6use markdown_it::{Node, NodeValue, Renderer};
7use oxc_diagnostics::OxcDiagnostic;
8use oxc_span::Span;
9use std::collections::HashSet;
10
11use crate::marko_ast::AttributeValue;
12use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText};
13
14/// A heading entry for the outline, with content reference for hoisting.
15#[derive(Debug, Clone)]
16pub struct HeadingEntry {
17 pub level: u8,
18 pub id: String,
19 /// Plain text content (for display/search)
20 pub text: String,
21 /// Component name for the hoisted content (e.g., "Heading_1__markodown__")
22 pub component_name: String,
23}
24
25/// Result of outline extraction
26pub struct OutlineResult {
27 /// The heading entries for the outline
28 pub headings: Vec<HeadingEntry>,
29 /// The `<define/>` blocks to hoist (as rendered strings)
30 pub defines: String,
31 /// Any errors encountered (e.g., dynamic IDs)
32 pub errors: Vec<OxcDiagnostic>,
33}
34
35/// A node that renders as a component reference: `<Heading_N__markodown__/>`
36#[derive(Debug)]
37struct HeadingContentRef {
38 component_name: String,
39}
40
41impl NodeValue for HeadingContentRef {
42 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
43 fmt.text_raw(&format!("<{}/>\n", self.component_name));
44 }
45}
46
47/// Generate a URL-friendly slug from text, ensuring uniqueness.
48pub fn generate_slug(text: &str, existing_ids: &mut HashSet<String>) -> String {
49 let mut slug: String = text
50 .chars()
51 .map(|c| match c {
52 'a'..='z' | '0'..='9' => c,
53 'A'..='Z' => c.to_ascii_lowercase(),
54 ' ' | '\t' => '-',
55 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
56 _ => '-',
57 })
58 .collect();
59
60 // Collapse multiple dashes and trim
61 slug = slug
62 .split('-')
63 .filter(|s| !s.is_empty())
64 .collect::<Vec<_>>()
65 .join("-");
66
67 if slug.is_empty() {
68 slug = "heading".to_string();
69 }
70
71 // Ensure uniqueness
72 if !existing_ids.contains(&slug) {
73 existing_ids.insert(slug.clone());
74 return slug;
75 }
76
77 let mut counter = 1;
78 loop {
79 let new_slug = format!("{}-{}", slug, counter);
80 if !existing_ids.contains(&new_slug) {
81 existing_ids.insert(new_slug.clone());
82 return new_slug;
83 }
84 counter += 1;
85 }
86}
87
88/// Collect headings from the AST, inject IDs, and extract content into defines.
89pub fn collect_and_extract(node: &mut Node, preamble_offset: u32) -> OutlineResult {
90 let mut headings = Vec::new();
91 let mut errors = Vec::new();
92 let mut defines = String::new();
93 let mut existing_ids = HashSet::new();
94 let mut heading_counter = 0usize;
95
96 collect_recursive(
97 node,
98 &mut headings,
99 &mut errors,
100 &mut defines,
101 &mut existing_ids,
102 &mut heading_counter,
103 preamble_offset,
104 );
105
106 OutlineResult {
107 headings,
108 defines,
109 errors,
110 }
111}
112
113/// Render a node's children to a string.
114fn render_children(node: &Node) -> String {
115 // Create a temporary wrapper to render just the children
116 let mut output = String::new();
117 for child in &node.children {
118 output.push_str(&child.render());
119 }
120 output
121}
122
123fn collect_recursive(
124 node: &mut Node,
125 headings: &mut Vec<HeadingEntry>,
126 errors: &mut Vec<OxcDiagnostic>,
127 defines: &mut String,
128 existing_ids: &mut HashSet<String>,
129 heading_counter: &mut usize,
130 preamble_offset: u32,
131) {
132 let (node_start, _) = node.srcmap.map(|s| s.get_byte_offsets()).unwrap_or((0, 0));
133 let node_start = node_start as u32 + preamble_offset;
134
135 // Check for markdown-it ATX headings (# ## ### etc)
136 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
137 let level = heading.level;
138 let text = node.collect_text();
139 let id = generate_slug(&text, existing_ids);
140
141 // Generate component name
142 *heading_counter += 1;
143 let component_name = format!("Heading_{}__markodown__", heading_counter);
144
145 // Render children before replacing them
146 let rendered_content = render_children(node);
147
148 // Create the define block
149 let trimmed_content = rendered_content.trim();
150 defines.push_str(&format!(
151 "<define/{component_name}>\n{trimmed_content}\n</>\n"
152 ));
153
154 // Replace children with reference node
155 node.children.clear();
156 node.children.push(Node::new(HeadingContentRef {
157 component_name: component_name.clone(),
158 }));
159
160 // Inject id attribute
161 node.attrs.push(("id", id.clone()));
162
163 headings.push(HeadingEntry {
164 level,
165 id,
166 text,
167 component_name,
168 });
169 }
170 // Check for setext headings (underline style)
171 else if let Some(heading) =
172 node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
173 {
174 let level = heading.level;
175 let text = node.collect_text();
176 let id = generate_slug(&text, existing_ids);
177
178 *heading_counter += 1;
179 let component_name = format!("Heading_{}__markodown__", heading_counter);
180
181 let rendered_content = render_children(node);
182
183 let trimmed_content = rendered_content.trim();
184 defines.push_str(&format!(
185 "<define/{component_name}>\n{trimmed_content}\n</>\n"
186 ));
187
188 node.children.clear();
189 node.children.push(Node::new(HeadingContentRef {
190 component_name: component_name.clone(),
191 }));
192
193 node.attrs.push(("id", id.clone()));
194
195 headings.push(HeadingEntry {
196 level,
197 id,
198 text,
199 component_name,
200 });
201 }
202 // Check for setext headings (underline style)
203 else if let Some(heading) =
204 node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
205 {
206 let level = heading.level;
207 let text = node.collect_text();
208 let id = generate_slug(&text, existing_ids);
209
210 *heading_counter += 1;
211 let component_name = format!("Heading_{}__markodown__", heading_counter);
212
213 let rendered_content = render_children(node);
214
215 let trimmed_content = rendered_content.trim();
216 defines.push_str(&format!(
217 "<define/{component_name}>\n{trimmed_content}\n</>\n"
218 ));
219
220 node.children.clear();
221 node.children.push(Node::new(HeadingContentRef {
222 component_name: component_name.clone(),
223 }));
224
225 node.attrs.push(("id", id.clone()));
226
227 headings.push(HeadingEntry {
228 level,
229 id,
230 text,
231 component_name,
232 });
233 }
234 // Check for MarkoOpen tags that are h1-h6
235 else if node.cast::<MarkoOpen>().is_some() {
236 let heading_info = {
237 let open = node.cast::<MarkoOpen>().unwrap();
238 if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) {
239 let text = node
240 .children
241 .iter()
242 .map(|c| c.collect_text())
243 .collect::<Vec<_>>()
244 .join("");
245 let id_info = open.open.id;
246 let tag_span = open.open.as_ref().tag_name_span();
247 let existing_id = match id_info {
248 AttributeValue::Static { span, .. } => {
249 Some(open.open.src[span.start as usize..span.end as usize].to_string())
250 }
251 _ => None,
252 };
253 Some((level, text, id_info, tag_span, existing_id))
254 } else {
255 None
256 }
257 };
258
259 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
260 let id = match id_info {
261 AttributeValue::Static { .. } => {
262 let id_str = existing_id.unwrap();
263 if !existing_ids.contains(&id_str) {
264 existing_ids.insert(id_str.clone());
265 id_str
266 } else {
267 generate_slug(&text, existing_ids)
268 }
269 }
270 AttributeValue::Dynamic => {
271 errors.push(
272 OxcDiagnostic::error(
273 "Dynamic id attribute not supported when outline is enabled",
274 )
275 .with_label(Span::new(
276 node_start + tag_span.start,
277 node_start + tag_span.end,
278 )),
279 );
280 generate_slug(&text, existing_ids)
281 }
282 AttributeValue::None => {
283 let id = generate_slug(&text, existing_ids);
284 let open = node.cast_mut::<MarkoOpen>().unwrap();
285 open.open.insert_id_attr(&id);
286 id
287 }
288 };
289
290 *heading_counter += 1;
291 let component_name = format!("Heading_{}__markodown__", heading_counter);
292
293 let rendered_content = render_children(node);
294
295 let trimmed_content = rendered_content.trim();
296 defines.push_str(&format!(
297 "<define/{component_name}>\n{trimmed_content}\n</>\n"
298 ));
299
300 node.children.clear();
301 node.children.push(Node::new(HeadingContentRef {
302 component_name: component_name.clone(),
303 }));
304
305 headings.push(HeadingEntry {
306 level,
307 id,
308 text,
309 component_name,
310 });
311 }
312 }
313 // Check for MarkoOpenWithText tags that are h1-h6
314 else if node.cast::<MarkoOpenWithText>().is_some() {
315 let heading_info = {
316 let open = node.cast::<MarkoOpenWithText>().unwrap();
317 if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) {
318 let text = node
319 .children
320 .iter()
321 .map(|c| c.collect_text())
322 .collect::<Vec<_>>()
323 .join("");
324 let id_info = open.open.id;
325 let tag_span = open.open.as_ref().tag_name_span();
326 let existing_id = match id_info {
327 AttributeValue::Static { span, .. } => {
328 Some(open.open.src[span.start as usize..span.end as usize].to_string())
329 }
330 _ => None,
331 };
332 Some((level, text, id_info, tag_span, existing_id))
333 } else {
334 None
335 }
336 };
337
338 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
339 let id = match id_info {
340 AttributeValue::Static { .. } => {
341 let id_str = existing_id.unwrap();
342 if !existing_ids.contains(&id_str) {
343 existing_ids.insert(id_str.clone());
344 id_str
345 } else {
346 generate_slug(&text, existing_ids)
347 }
348 }
349 AttributeValue::Dynamic => {
350 errors.push(
351 OxcDiagnostic::error(
352 "Dynamic id attribute not supported when outline is enabled",
353 )
354 .with_label(Span::new(
355 node_start + tag_span.start,
356 node_start + tag_span.end,
357 )),
358 );
359 generate_slug(&text, existing_ids)
360 }
361 AttributeValue::None => {
362 let id = generate_slug(&text, existing_ids);
363 let open = node.cast_mut::<MarkoOpenWithText>().unwrap();
364 open.open.insert_id_attr(&id);
365 id
366 }
367 };
368
369 *heading_counter += 1;
370 let component_name = format!("Heading_{}__markodown__", heading_counter);
371
372 let rendered_content = render_children(node);
373
374 let trimmed_content = rendered_content.trim();
375 defines.push_str(&format!(
376 "<define/{component_name}>\n{trimmed_content}\n</>\n"
377 ));
378
379 node.children.clear();
380 node.children.push(Node::new(HeadingContentRef {
381 component_name: component_name.clone(),
382 }));
383
384 headings.push(HeadingEntry {
385 level,
386 id,
387 text,
388 component_name,
389 });
390 }
391 }
392 // Check for MarkoBlockComplete tags that are h1-h6
393 else if node.cast::<MarkoBlockComplete>().is_some() {
394 let heading_info = {
395 let block = node.cast::<MarkoBlockComplete>().unwrap();
396 if let Some(level) = parse_heading_level(block.open.as_ref().tag_name()) {
397 let text = node
398 .children
399 .iter()
400 .map(|c| c.collect_text())
401 .collect::<Vec<_>>()
402 .join("");
403 let id_info = block.open.id;
404 let tag_span = block.open.as_ref().tag_name_span();
405 let existing_id = match id_info {
406 AttributeValue::Static { span, .. } => {
407 Some(block.open.src[span.start as usize..span.end as usize].to_string())
408 }
409 _ => None,
410 };
411 Some((level, text, id_info, tag_span, existing_id))
412 } else {
413 None
414 }
415 };
416
417 if let Some((level, text, id_info, tag_span, existing_id)) = heading_info {
418 let id = match id_info {
419 AttributeValue::Static { .. } => {
420 let id_str = existing_id.unwrap();
421 if !existing_ids.contains(&id_str) {
422 existing_ids.insert(id_str.clone());
423 id_str
424 } else {
425 generate_slug(&text, existing_ids)
426 }
427 }
428 AttributeValue::Dynamic => {
429 errors.push(
430 OxcDiagnostic::error(
431 "Dynamic id attribute not supported when outline is enabled",
432 )
433 .with_label(Span::new(
434 node_start + tag_span.start,
435 node_start + tag_span.end,
436 )),
437 );
438 generate_slug(&text, existing_ids)
439 }
440 AttributeValue::None => {
441 let id = generate_slug(&text, existing_ids);
442 let block = node.cast_mut::<MarkoBlockComplete>().unwrap();
443 block.open.insert_id_attr(&id);
444 id
445 }
446 };
447
448 *heading_counter += 1;
449 let component_name = format!("Heading_{}__markodown__", heading_counter);
450
451 let rendered_content = render_children(node);
452
453 let trimmed_content = rendered_content.trim();
454 defines.push_str(&format!(
455 "<define/{component_name}>\n{trimmed_content}\n</>\n"
456 ));
457
458 node.children.clear();
459 node.children.push(Node::new(HeadingContentRef {
460 component_name: component_name.clone(),
461 }));
462
463 headings.push(HeadingEntry {
464 level,
465 id,
466 text,
467 component_name,
468 });
469 }
470 }
471
472 // Recurse into children
473 for child in &mut node.children {
474 collect_recursive(
475 child,
476 headings,
477 errors,
478 defines,
479 existing_ids,
480 heading_counter,
481 preamble_offset,
482 );
483 }
484}
485
486fn parse_heading_level(tag_name: &str) -> Option<u8> {
487 match tag_name {
488 "h1" => Some(1),
489 "h2" => Some(2),
490 "h3" => Some(3),
491 "h4" => Some(4),
492 "h5" => Some(5),
493 "h6" => Some(6),
494 _ => None,
495 }
496}
497
498/// Format the outline array for Marko output.
499/// Uses JS object syntax with unquoted component references.
500pub fn format_outline_array(headings: &[HeadingEntry]) -> String {
501 if headings.is_empty() {
502 return "[]".to_string();
503 }
504
505 let entries: Vec<String> = headings
506 .iter()
507 .map(|h| {
508 format!(
509 "{{ level: {}, id: '{}', content: {} }}",
510 h.level, h.id, h.component_name
511 )
512 })
513 .collect();
514
515 format!("[\n {},\n]", entries.join(",\n "))
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_generate_slug_basic() {
524 let mut existing = HashSet::new();
525 assert_eq!(generate_slug("Hello World", &mut existing), "hello-world");
526 assert!(existing.contains("hello-world"));
527 }
528
529 #[test]
530 fn test_generate_slug_special_chars() {
531 let mut existing = HashSet::new();
532 assert_eq!(
533 generate_slug("What's up? (2024)", &mut existing),
534 "what-s-up-2024"
535 );
536 }
537
538 #[test]
539 fn test_generate_slug_duplicates() {
540 let mut existing = HashSet::new();
541 assert_eq!(generate_slug("test", &mut existing), "test");
542 assert_eq!(generate_slug("test", &mut existing), "test-1");
543 assert_eq!(generate_slug("test", &mut existing), "test-2");
544 }
545
546 #[test]
547 fn test_generate_slug_empty() {
548 let mut existing = HashSet::new();
549 assert_eq!(generate_slug("!!!", &mut existing), "heading");
550 }
551
552 #[test]
553 fn test_format_outline_array() {
554 let headings = vec![
555 HeadingEntry {
556 level: 1,
557 id: "hello".to_string(),
558 text: "Hello".to_string(),
559 component_name: "Heading_1__markodown__".to_string(),
560 },
561 HeadingEntry {
562 level: 2,
563 id: "world".to_string(),
564 text: "World".to_string(),
565 component_name: "Heading_2__markodown__".to_string(),
566 },
567 ];
568
569 let result = format_outline_array(&headings);
570 assert!(result.contains("level: 1"));
571 assert!(result.contains("id: 'hello'"));
572 assert!(result.contains("content: Heading_1__markodown__"));
573 }
574}
src/plugin/mod.rs-1
......@@ -5,7 +5,6 @@ pub mod inline_tags;
55pub mod statement;
66pub mod tags;
77pub mod template;
8pub mod toc;
98
109pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult};
1110
src/plugin/tags.rs+3-7
......@@ -118,15 +118,13 @@ impl NodeValue for MarkoInlineTag {
118118/// The content is parsed as inline markdown
119119#[derive(Debug)]
120120pub struct MarkoBlockComplete {
121 pub open_tag: String,
121 pub open: marko_ast::OpenOwned,
122122 pub close_tag: String,
123 pub tag_name: String,
124 pub tag_name_len: usize,
125123}
126124
127125impl NodeValue for MarkoBlockComplete {
128126 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
129 fmt.text_raw(&self.open_tag);
127 fmt.text_raw(&self.open.src);
130128 fmt.contents(&node.children);
131129 fmt.text_raw(&self.close_tag);
132130 fmt.text_raw("\n");
......@@ -235,10 +233,8 @@ impl BlockRule for Rule {
235233 let mapping = vec![(0, text_start)];
236234
237235 let mut node = Node::new(MarkoBlockComplete {
238 open_tag: open.src.to_string(),
236 open: open.to_owned(),
239237 close_tag: close_tag.to_string(),
240 tag_name: open.tag_name().to_string(),
241 tag_name_len: open.tag_name().len(),
242238 });
243239
244240 if !content.is_empty() {
src/plugin/toc.rs deleted-229
......@@ -1,229 +0,0 @@
1use markdown_it::Node;
2use serde::Serialize;
3
4#[derive(Debug, Clone, Serialize)]
5pub struct HeadingEntry {
6 pub level: u8,
7 pub id: String,
8 pub text: String,
9}
10
11pub fn generate_slug(text: &str, existing_ids: &mut std::collections::HashSet<String>) -> String {
12 let mut slug = text
13 .chars()
14 .map(|c| match c {
15 'a'..='z' | '0'..='9' => c.to_ascii_lowercase(),
16 'A'..='Z' => c.to_ascii_lowercase(),
17 ' ' | '\t' => '-',
18 _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
19 _ => '-',
20 })
21 .collect::<String>();
22
23 slug = slug
24 .split('-')
25 .filter(|s| !s.is_empty())
26 .collect::<Vec<_>>()
27 .join("-");
28
29 if slug.is_empty() {
30 slug = "heading".to_string();
31 }
32
33 if !existing_ids.contains(&slug) {
34 existing_ids.insert(slug.clone());
35 return slug;
36 }
37
38 let mut counter = 1;
39 loop {
40 let new_slug = format!("{}-{}", slug, counter);
41 if !existing_ids.contains(&new_slug) {
42 existing_ids.insert(new_slug.clone());
43 return new_slug;
44 }
45 counter += 1;
46 }
47}
48
49pub fn collect_headings(node: &Node) -> Vec<HeadingEntry> {
50 let mut headings = Vec::new();
51 let mut existing_ids = std::collections::HashSet::new();
52
53 collect_headings_recursive(node, &mut headings, &mut existing_ids);
54
55 headings
56}
57
58fn collect_headings_recursive(
59 node: &Node,
60 headings: &mut Vec<HeadingEntry>,
61 existing_ids: &mut std::collections::HashSet<String>,
62) {
63 // Check for markdown-it ATX headings (# ## ### etc)
64 if let Some(heading) = node.cast::<markdown_it::plugins::cmark::block::heading::ATXHeading>() {
65 let text = node.collect_text();
66 let id = generate_slug(&text, existing_ids);
67 headings.push(HeadingEntry {
68 level: heading.level,
69 id,
70 text,
71 });
72 }
73 // Check for setext headings (underline style)
74 else if let Some(heading) =
75 node.cast::<markdown_it::plugins::cmark::block::lheading::SetextHeader>()
76 {
77 let text = node.collect_text();
78 let id = generate_slug(&text, existing_ids);
79 headings.push(HeadingEntry {
80 level: heading.level,
81 id,
82 text,
83 });
84 }
85 // Check for MarkoOpen tags that are h1-h6
86 else if let Some(open) = node.cast::<super::tags::MarkoOpen>() {
87 if let Some(level) = parse_heading_level(&open.open.as_ref().tag_name()) {
88 let text = node
89 .children
90 .iter()
91 .map(|c| c.collect_text())
92 .collect::<Vec<_>>()
93 .join("");
94 let id = if let Some(existing) = open.open.src.strip_prefix('<').and_then(|s| {
95 s.find("id=\"")
96 .map(|pos| {
97 let start = pos + 4;
98 s[start..].split('"').next().map(|s| s.to_string())
99 })
100 .flatten()
101 }) {
102 if !existing_ids.contains(&existing) {
103 existing_ids.insert(existing.clone());
104 existing
105 } else {
106 generate_slug(&text, existing_ids)
107 }
108 } else {
109 generate_slug(&text, existing_ids)
110 };
111 headings.push(HeadingEntry { level, id, text });
112 }
113 }
114 // Check for MarkoOpenWithText tags that are h1-h6
115 else if let Some(open) = node.cast::<super::tags::MarkoOpenWithText>() {
116 if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) {
117 let text = node
118 .children
119 .iter()
120 .map(|c| c.collect_text())
121 .collect::<Vec<_>>()
122 .join("");
123 let id = generate_slug(&text, existing_ids);
124 headings.push(HeadingEntry { level, id, text });
125 }
126 }
127 // Check for MarkoBlockComplete tags that are h1-h6
128 else if let Some(block) = node.cast::<super::tags::MarkoBlockComplete>() {
129 if let Some(level) = parse_heading_level(block.tag_name.as_str()) {
130 let text = node
131 .children
132 .iter()
133 .map(|c| c.collect_text())
134 .collect::<Vec<_>>()
135 .join("");
136 let id = generate_slug(&text, existing_ids);
137 headings.push(HeadingEntry { level, id, text });
138 }
139 }
140
141 for child in &node.children {
142 collect_headings_recursive(child, headings, existing_ids);
143 }
144}
145
146fn parse_heading_level(tag_name: &str) -> Option<u8> {
147 match tag_name {
148 "h1" => Some(1),
149 "h2" => Some(2),
150 "h3" => Some(3),
151 "h4" => Some(4),
152 "h5" => Some(5),
153 "h6" => Some(6),
154 _ => None,
155 }
156}
157
158pub fn inject_heading_ids(text: &str, headings: &[HeadingEntry]) -> String {
159 if headings.is_empty() {
160 return text.to_string();
161 }
162
163 let mut result = String::new();
164 let mut pos = 0;
165 let mut heading_idx = 0;
166 let bytes = text.as_bytes();
167
168 while pos < bytes.len() {
169 if bytes[pos] == b'<' {
170 let remaining = &bytes[pos..];
171 if remaining.starts_with(b"<h1")
172 || remaining.starts_with(b"<h2")
173 || remaining.starts_with(b"<h3")
174 || remaining.starts_with(b"<h4")
175 || remaining.starts_with(b"<h5")
176 || remaining.starts_with(b"<h6")
177 {
178 let level = match remaining[1] {
179 b'1' => 1u8,
180 b'2' => 2,
181 b'3' => 3,
182 b'4' => 4,
183 b'5' => 5,
184 b'6' => 6,
185 _ => {
186 result.push('<');
187 pos += 1;
188 continue;
189 }
190 };
191
192 let close_pos = match remaining[2..].iter().position(|&b| b == b'>') {
193 Some(p) => p + 2,
194 None => {
195 result.push('<');
196 pos += 1;
197 continue;
198 }
199 };
200
201 let tag_end = pos + close_pos;
202 let tag = std::str::from_utf8(&bytes[pos..=tag_end]).unwrap_or("");
203
204 if tag.ends_with("/>") || tag.contains(" ") {
205 result.push_str(tag);
206 pos = tag_end + 1;
207 continue;
208 }
209
210 if heading_idx < headings.len() && headings[heading_idx].level == level {
211 let id = &headings[heading_idx].id;
212 result.push_str(&format!("h{level} id=\"{}\">", id));
213 heading_idx += 1;
214 pos = tag_end + 1;
215 continue;
216 } else {
217 result.push_str(tag);
218 pos = tag_end + 1;
219 continue;
220 }
221 }
222 }
223
224 result.push(bytes[pos] as char);
225 pos += 1;
226 }
227
228 result
229}
src/wasm.rs+17-2
......@@ -1,4 +1,4 @@
1use crate::OutputFormat;
1use crate::{ComponentImports, OutputFormat};
22use oxc_diagnostics::OxcDiagnostic;
33use serde::Serialize;
44use wasm_bindgen::prelude::*;
......@@ -100,9 +100,24 @@ pub fn transform(
100100 src: &str,
101101 force_format: Option<OutputFormat>,
102102 layout_import: Option<String>,
103 component_imports: JsValue,
103104 self_path: Option<String>,
104105) -> JsValue {
105 match crate::transform(src, force_format, layout_import, self_path) {
106 // Deserialize component_imports from JsValue (can be undefined/null)
107 let component_imports: Option<ComponentImports> =
108 if component_imports.is_undefined() || component_imports.is_null() {
109 None
110 } else {
111 serde_wasm_bindgen::from_value(component_imports).ok()
112 };
113
114 match crate::transform(
115 src,
116 force_format,
117 layout_import,
118 component_imports,
119 self_path,
120 ) {
106121 Ok(output) => serde_wasm_bindgen::to_value(&TransformResult {
107122 text: Some(output.text),
108123 success: true,
tests/fixtures.rs+45-3
......@@ -1,4 +1,4 @@
1use markodown::{transform, OutputFormat};
1use markodown::{transform, ComponentImports, OutputFormat};
22use std::fs;
33use std::path::Path;
44
......@@ -7,6 +7,16 @@ use std::path::Path;
77/// Each fixture has an `.mdo` input and either an `.html` expected output
88/// (static mode) or a `.marko` expected output (marko mode), or both.
99fn run_fixture(name: &str) {
10 run_fixture_with_options(name, None, None, None);
11}
12
13/// Run fixture with optional layout, component_imports, and self_path arguments.
14fn run_fixture_with_options(
15 name: &str,
16 layout: Option<&str>,
17 component_imports: Option<ComponentImports>,
18 self_path: Option<&str>,
19) {
1020 let base = Path::new("tests/fixtures").join(name);
1121 let input_path = base.with_extension("mdo");
1222 let html_path = base.with_extension("html");
......@@ -20,7 +30,14 @@ fn run_fixture(name: &str) {
2030 let expected = fs::read_to_string(&html_path)
2131 .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display()));
2232
23 let output = transform(&source, Some(OutputFormat::Html), None, None).unwrap();
33 let output = transform(
34 &source,
35 Some(OutputFormat::Html),
36 layout.map(|s| s.to_string()),
37 component_imports.clone(),
38 self_path.map(|s| s.to_string()),
39 )
40 .unwrap();
2441
2542 assert_eq!(
2643 output.format,
......@@ -40,7 +57,14 @@ fn run_fixture(name: &str) {
4057 let expected = fs::read_to_string(&marko_path)
4158 .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display()));
4259
43 let output = transform(&source, Some(OutputFormat::Marko), None, None).unwrap();
60 let output = transform(
61 &source,
62 Some(OutputFormat::Marko),
63 layout.map(|s| s.to_string()),
64 component_imports.clone(),
65 self_path.map(|s| s.to_string()),
66 )
67 .unwrap();
4468
4569 assert_eq!(
4670 output.format,
......@@ -172,3 +196,21 @@ fn fixture_21_frontmatter_blank_lines() {
172196fn fixture_22_frontmatter_mixed_preamble() {
173197 run_fixture("22-frontmatter-mixed-preamble");
174198}
199
200#[test]
201fn fixture_23_outline_extracting() {
202 run_fixture_with_options("23-outline-extracting", Some("./layout.marko"), None, None);
203}
204
205#[test]
206fn fixture_24_heading_import() {
207 run_fixture_with_options(
208 "24-heading-import",
209 None,
210 Some(ComponentImports {
211 heading: Some("./heading.marko".to_string()),
212 ..Default::default()
213 }),
214 None,
215 );
216}
tests/fixtures/23-outline-extracting.marko+18-17
......@@ -1,27 +1,28 @@
1<define/Header_1__markodown__>
1import Layout__markodown__ from "./layout.marko";
2<define/Heading_1__markodown__>
23good morning
34</>
4<define/Header_2__markodown__>
5<define/Heading_2__markodown__>
56good night
67</>
7<define/Header_3__markodown__>
8<define/Heading_3__markodown__>
89<strong>snow</strong> time
910</>
11<define/Heading_4__markodown__>
1012rain <strong>time</strong>
11<define/Header_4__markodown__>
1213</>
13<Layout__markodown__ module=self__markodown__ outline=[
14 { level: 1, id: 'good-morning', content: Header_1__markodown__ },
15 { level: 2, id: 'good-night', content: Header_2__markodown__ },
16 { level: 3, id: 'snow-time', content: Header_3__markodown__ },
17 { level: 4, id: 'id', content: Header_4__markodown__ },
14<Layout__markodown__ module=null outline=[
15 { level: 1, id: 'good-morning', content: Heading_1__markodown__ },
16 { level: 2, id: 'good-night', content: Heading_2__markodown__ },
17 { level: 3, id: 'snow-time', content: Heading_3__markodown__ },
18 { level: 4, id: 'id', content: Heading_4__markodown__ },
1819]>
19<h1#good-morning><Header_1__markodown__/></h1>
20<p>content 1</p>
21<h2#good-night><Header_2__markodown__></h2>
22<p>content 2</p>
23<h3#snow-time><Header_3__markodown__></h3>
24<p>content 3</p>
25<h4#id><Header_4__markodown__></>
26<p>content 4</p>
20<h1#good-morning><Heading_1__markodown__/>
21</h1>
22<h2 id="good-night"><Heading_2__markodown__/>
23</h2>
24<h3 id="snow-time"><Heading_3__markodown__/>
25</h3>
26<h4#id><Heading_4__markodown__/>
27</>
2728</>
tests/fixtures/24-heading-import.marko created+5
......@@ -0,0 +1,5 @@
1import HeadingComponent__markodown__ from "./heading.marko";
2<HeadingComponent__markodown__ level=1>Hello World</>
3<HeadingComponent__markodown__#about level=2 class="section">About Us</>
4<HeadingComponent__markodown__ level=3>Details here</>
5<HeadingComponent__markodown__ level=4>Simple heading</>
tests/fixtures/24-heading-import.mdo created+7
......@@ -0,0 +1,7 @@
1# Hello World
2
3<h2#about class="section">About Us</>
4
5### Details here
6
7<h4>Simple heading</h4>