| author | |
| committer | |
| log | c796d5ef4aa87f2db690d0090873c0c94c3aae04 |
| tree | 3ae258caf772b940728eb0db848f7eff91ceeef7 |
| parent | cbd481161f7342c8238e2147f45e6048ad32ff4b |
| signature |
16 files changed, 1487 insertions(+), 340 deletions(-)
README.md+105-45| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 | # Markodown |
| 2 | 2 | |
| 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 | ||
| 3 | 6 | This is a weird markup language that combines features of [Markdown] and |
| 4 | 7 | [Marko]. You can think of this as an alternative universe to MDX. Since Marko |
| 5 | 8 | components are really easy to write, it makes this a great tool for writing |
| ... | ... | @@ -9,12 +12,15 @@ leveraging the existing ecosystem. |
| 9 | 12 | [Markdown]: https://en.wikipedia.org/wiki/Markdown |
| 10 | 13 | [Marko]: https://markojs.com/ |
| 11 | 14 | |
| 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) | |
| 18 | 24 | |
| 19 | 25 | Here's a glance at how things look. Complete example documents in <./examples> |
| 20 | 26 | |
| ... | ... | @@ -65,42 +71,42 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}. |
| 65 | 71 | </blog-layout> |
| 66 | 72 | ```` |
| 67 | 73 | |
| 68 | ## Syntax Reference | |
| 74 | ## Install | |
| 69 | 75 | |
| 70 | ### Paragraphs | |
| 71 | ||
| 72 | All text blocks with spaces around them will be wrapped in a paragraph, like | |
| 73 | markdown does. | |
| 76 | Markdown is distributed on [NPM](http://npmjs.com/markodown) and | |
| 77 | [JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs. | |
| 74 | 78 | |
| 79 | ```sh | |
| 80 | npm i markodown | |
| 81 | npx jsr add @clo/markodown | |
| 75 | 82 | ``` |
| 76 | <div>not wrapped</div> | |
| 77 | <div> | |
| 78 | not wrapped | |
| 79 | </div> | |
| 80 | 83 | |
| 81 | <div> | |
| 84 | The compiler can be directly used from `transform`, and there are also plugins | |
| 85 | for Rollup/Rolldown/Vite and esbuild. For example, configure Markdown with Marko | |
| 86 | Run: | |
| 82 | 87 | |
| 83 | this paragraph gets wrapped in a `<p>` tag | |
| 84 | ||
| 85 | </div> | |
| 86 | ``` | |
| 87 | ||
| 88 | Note 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 | |
| 89 | import marko from "@marko/run/vite"; | |
| 90 | import markodown from "markodown"; | |
| 91 | import { defineConfig } from "vite"; | |
| 92 | ||
| 93 | export 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 | }); | |
| 98 | 103 | ``` |
| 99 | 104 | |
| 100 | ### Components | |
| 105 | ## Components | |
| 101 | 106 | |
| 102 | All Marko features, such as [tag resolution], [attribute tags], [class | |
| 103 | shorthands], and template expressions. | |
| 107 | All Marko features, such as [tag resolution], [attribute tags], | |
| 108 | [class shorthands], and template expressions. This makes it so much easier to | |
| 109 | add complex content to your pages. | |
| 104 | 110 | |
| 105 | 111 | ``` |
| 106 | 112 | ## cool video |
| ... | ... | @@ -109,28 +115,49 @@ shorthands], and template expressions. |
| 109 | 115 | <@header>**music video**: in the summer</> |
| 110 | 116 | </clover-video> |
| 111 | 117 | |
| 112 | <div.footer> | |
| 113 | (c) ${new Date().getFullYear()} | |
| 118 | <footer.copyright-info> | |
| 119 | made with love... (c) ${new Date().getFullYear()} | |
| 114 | 120 | </div> |
| 115 | 121 | ``` |
| 116 | 122 | |
| 117 | 123 | [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 | |
| 118 | 126 | |
| 119 | ### Comments | |
| 127 | ## Outline / Table of Contents | |
| 120 | 128 | |
| 121 | Line, Block, and HTML comments work like they do in Marko/JavaScript. | |
| 129 | You can use Markodown to write blogs and long documents, then extract a table of | |
| 130 | contents. 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 | ||
| 140 | Using the basic heading tags is awesome, because you can very easily customize | |
| 141 | the generated permalinks for each heading, and still use Markdown within the | |
| 142 | heading titles. | |
| 122 | 143 | |
| 123 | 144 | ``` |
| 124 | # My Blog | |
| 145 | # my blog post | |
| 125 | 146 | |
| 126 | Text that is complete. | |
| 147 | <h2#markdown>about `markdown`</> | |
| 127 | 148 | |
| 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 | ... | |
| 131 | 158 | ``` |
| 132 | 159 | |
| 133 | ### Frontmatter | |
| 160 | ## Frontmatter | |
| 134 | 161 | |
| 135 | 162 | All frontmatter fields are converted into exports. For example, a framework that |
| 136 | 163 | reads the `meta` export for Open Graph can be easily satisfied with frontmatter. |
| ... | ... | @@ -150,7 +177,40 @@ meta: |
| 150 | 177 | # ${meta.title} |
| 151 | 178 | ``` |
| 152 | 179 | |
| 153 | ### Static Statements | |
| 180 | ## Comments | |
| 181 | ||
| 182 | Line, Block, and HTML comments work like they do in Marko/JavaScript. | |
| 183 | ||
| 184 | ``` | |
| 185 | # My Blog | |
| 186 | ||
| 187 | Text that is complete. | |
| 188 | ||
| 189 | // ## An unfinished section of the blog | |
| 190 | // | |
| 191 | // TODO: we gotta finish it! | |
| 192 | ``` | |
| 193 | ||
| 194 | ## Paragraph Detection | |
| 195 | ||
| 196 | Like Markdown, you can place content between components, but you can also place | |
| 197 | inline markdown anywhere between tags. Effectively, this means that text gets | |
| 198 | wrapped in `<p>` tags if there is a blank line above and below it. | |
| 199 | ||
| 200 | ``` | |
| 201 | <div>not wrapped</div> | |
| 202 | <div> | |
| 203 | not wrapped either | |
| 204 | </div> | |
| 205 | ||
| 206 | <div> | |
| 207 | ||
| 208 | this paragraph gets wrapped in a `<p>` tag! | |
| 209 | ||
| 210 | </div> | |
| 211 | ``` | |
| 212 | ||
| 213 | ## Static Statements | |
| 154 | 214 | |
| 155 | 215 | Define module-level functions and variables. |
| 156 | 216 |
lib/jsr.json+10-1| ... | ... | @@ -1,4 +1,13 @@ |
| 1 | 1 | { |
| 2 | 2 | "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 | } | |
| 4 | 13 | } |
lib/mod.ts+17| ... | ... | @@ -5,6 +5,20 @@ wasm.initSync({ module: bytes }); |
| 5 | 5 | |
| 6 | 6 | type Transformed = Success | Failure; |
| 7 | 7 | |
| 8 | /** Configuration for replacing built-in elements with custom components. */ | |
| 9 | export 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 | ||
| 8 | 22 | export interface TransformOptions { |
| 9 | 23 | source: string; |
| 10 | 24 | /** |
| ... | ... | @@ -16,6 +30,8 @@ export interface TransformOptions { |
| 16 | 30 | format?: Array<"marko" | "html">; |
| 17 | 31 | /** Wraps the component in another component. Enables Table of Contents generation */ |
| 18 | 32 | layoutImport?: string; |
| 33 | /** Replace built-in elements with custom components */ | |
| 34 | componentImports?: ComponentImports; | |
| 19 | 35 | } |
| 20 | 36 | |
| 21 | 37 | export type OutputFormat = "marko" | "html"; |
| ... | ... | @@ -69,5 +85,6 @@ export function transform(options: TransformOptions): Transformed { |
| 69 | 85 | options.source, |
| 70 | 86 | forceFormat, |
| 71 | 87 | options?.layoutImport, |
| 88 | options?.componentImports, | |
| 72 | 89 | ); |
| 73 | 90 | } |
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 | ||
| 6 | use markdown_it::{Node, NodeValue, Renderer}; | |
| 7 | ||
| 8 | use crate::marko_ast::OpenOwned; | |
| 9 | use crate::plugin::tags::{MarkoBlockComplete, MarkoClose, MarkoOpen, MarkoOpenWithText}; | |
| 10 | use crate::ComponentImports; | |
| 11 | ||
| 12 | /// Component name suffix to avoid collisions | |
| 13 | const SUFFIX: &str = "__markodown__"; | |
| 14 | ||
| 15 | /// Heading component name | |
| 16 | fn heading_component() -> String { | |
| 17 | format!("HeadingComponent{}", SUFFIX) | |
| 18 | } | |
| 19 | ||
| 20 | /// Code block component name | |
| 21 | fn code_block_component() -> String { | |
| 22 | format!("CodeBlockComponent{}", SUFFIX) | |
| 23 | } | |
| 24 | ||
| 25 | /// Link component name | |
| 26 | fn link_component() -> String { | |
| 27 | format!("LinkComponent{}", SUFFIX) | |
| 28 | } | |
| 29 | ||
| 30 | /// Image component name | |
| 31 | fn image_component() -> String { | |
| 32 | format!("ImageComponent{}", SUFFIX) | |
| 33 | } | |
| 34 | ||
| 35 | /// Blockquote component name | |
| 36 | fn blockquote_component() -> String { | |
| 37 | format!("BlockquoteComponent{}", SUFFIX) | |
| 38 | } | |
| 39 | ||
| 40 | /// Generate import statements for all configured component imports | |
| 41 | pub 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. | |
| 84 | pub 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)] | |
| 114 | struct 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 | ||
| 123 | impl 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 | |
| 144 | fn 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. | |
| 151 | fn 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. | |
| 195 | fn 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 | |
| 283 | fn 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)] | |
| 297 | struct LinkComponentNode { | |
| 298 | href: String, | |
| 299 | title: Option<String>, | |
| 300 | } | |
| 301 | ||
| 302 | impl 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. | |
| 319 | fn 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)] | |
| 335 | struct ImageComponentNode { | |
| 336 | src: String, | |
| 337 | alt: String, | |
| 338 | title: Option<String>, | |
| 339 | } | |
| 340 | ||
| 341 | impl 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. | |
| 357 | fn 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)] | |
| 370 | struct BlockquoteComponentNode; | |
| 371 | ||
| 372 | impl 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. | |
| 385 | fn 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)] | |
| 401 | mod 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 @@ |
| 1 | pub mod component_transforms; | |
| 1 | 2 | pub mod marko; |
| 2 | 3 | pub mod marko_ast; |
| 4 | pub mod outline; | |
| 3 | 5 | pub mod plugin; |
| 4 | 6 | pub mod typescript; |
| 5 | 7 | pub mod wasm; |
| ... | ... | @@ -7,11 +9,29 @@ pub mod wasm; |
| 7 | 9 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; |
| 8 | 10 | use oxc_span::Span; |
| 9 | 11 | use plugin::tags::{MarkoClose, MarkoOpen}; |
| 10 | use serde::Serialize; | |
| 11 | use serde_json; | |
| 12 | use serde::{Deserialize, Serialize}; | |
| 12 | 13 | use std::borrow::Cow; |
| 13 | 14 | use wasm_bindgen::prelude::wasm_bindgen; |
| 14 | 15 | |
| 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")] | |
| 20 | pub 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 | ||
| 15 | 35 | pub struct Output { |
| 16 | 36 | pub text: String, |
| 17 | 37 | pub format: OutputFormat, |
| ... | ... | @@ -29,7 +49,8 @@ pub fn transform( |
| 29 | 49 | source: &str, |
| 30 | 50 | force: Option<OutputFormat>, |
| 31 | 51 | layout_import: Option<String>, |
| 32 | self_path: Option<String>, | |
| 52 | component_imports: Option<ComponentImports>, | |
| 53 | self_import: Option<String>, | |
| 33 | 54 | ) -> Result<Output, Vec<OxcDiagnostic>> { |
| 34 | 55 | // Pre-process to extract preamble (blank lines, imports) and frontmatter |
| 35 | 56 | // This is needed because markdown-it skips blank lines before running block rules |
| ... | ... | @@ -69,66 +90,83 @@ pub fn transform( |
| 69 | 90 | return Err(errors); |
| 70 | 91 | } |
| 71 | 92 | |
| 93 | // Determine output format | |
| 94 | // componentImports forces Marko format since we add imports | |
| 72 | 95 | let format = force.unwrap_or_else(|| { |
| 73 | if has_marko_features(&ast) { | |
| 96 | if component_imports.is_some() || has_marko_features(&ast) { | |
| 74 | 97 | OutputFormat::Marko |
| 75 | 98 | } else { |
| 76 | 99 | OutputFormat::Html |
| 77 | 100 | } |
| 78 | 101 | }); |
| 79 | 102 | |
| 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 | } | |
| 84 | 114 | } |
| 85 | 115 | |
| 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) | |
| 92 | 123 | } else { |
| 93 | outline_json = None; | |
| 94 | Vec::new() | |
| 124 | None | |
| 95 | 125 | }; |
| 96 | 126 | |
| 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 | ||
| 97 | 133 | // Extract statements before rendering - they need to be hoisted above Layout |
| 98 | 134 | let extracted_statements = hoist_statements(&mut ast); |
| 99 | 135 | |
| 100 | 136 | let mut text = ast.render(); |
| 101 | 137 | |
| 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 | |
| 108 | 139 | let mut hoisted = String::new(); |
| 109 | 140 | if let Some(preamble) = preamble_output { |
| 110 | 141 | hoisted.push_str(&preamble); |
| 111 | 142 | } |
| 143 | // Add component imports | |
| 144 | if let Some(ref imports) = component_imports { | |
| 145 | hoisted.push_str(&component_transforms::generate_imports(imports)); | |
| 146 | } | |
| 112 | 147 | hoisted.push_str(&extracted_statements); |
| 113 | 148 | |
| 114 | 149 | // 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 { | |
| 118 | 158 | 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{}</>", | |
| 120 | 160 | layout_path, |
| 121 | 161 | self_path, |
| 122 | 162 | hoisted, |
| 123 | outline, | |
| 124 | 163 | text |
| 125 | 164 | ); |
| 126 | 165 | } else { |
| 127 | 166 | 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{}</>", | |
| 129 | 168 | layout_path, |
| 130 | 169 | hoisted, |
| 131 | outline, | |
| 132 | 170 | text |
| 133 | 171 | ); |
| 134 | 172 | } |
src/main.rs+45-6| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | use clap::{Parser, ValueEnum}; |
| 2 | use markodown::{transform, OutputFormat}; | |
| 2 | use markodown::{transform, ComponentImports, OutputFormat}; | |
| 3 | 3 | use oxc_diagnostics::GraphicalReportHandler; |
| 4 | 4 | use std::fs; |
| 5 | 5 | use std::path::PathBuf; |
| ... | ... | @@ -22,23 +22,62 @@ struct Cli { |
| 22 | 22 | |
| 23 | 23 | /// Force the output format (auto-detected by default) |
| 24 | 24 | #[arg(long)] |
| 25 | output_format: Option<CliOutputFormat>, | |
| 25 | force_format: Option<CliOutputFormat>, | |
| 26 | 26 | |
| 27 | 27 | /// Layout component to wrap the output with. This component will recieve |
| 28 | 28 | /// the module and a generated table of contents. |
| 29 | 29 | #[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>, | |
| 31 | 51 | } |
| 32 | 52 | |
| 33 | 53 | fn main() { |
| 34 | 54 | let cli = Cli::parse(); |
| 35 | 55 | |
| 36 | let force = cli.output_format.map(|f| match f { | |
| 56 | let force = cli.force_format.map(|f| match f { | |
| 37 | 57 | CliOutputFormat::Html => OutputFormat::Html, |
| 38 | 58 | CliOutputFormat::Marko => OutputFormat::Marko, |
| 39 | 59 | }); |
| 40 | 60 | |
| 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 | }; | |
| 42 | 81 | |
| 43 | 82 | let source = match fs::read_to_string(&cli.file) { |
| 44 | 83 | Ok(s) => s, |
| ... | ... | @@ -48,7 +87,7 @@ fn main() { |
| 48 | 87 | } |
| 49 | 88 | }; |
| 50 | 89 | |
| 51 | match transform(&source, force, layout_import, None) { | |
| 90 | match transform(&source, force, layout_import, component_imports, None) { | |
| 52 | 91 | Err(errors) => { |
| 53 | 92 | let handler = GraphicalReportHandler::new(); |
| 54 | 93 | for error in &errors { |
src/marko_ast.rs+151| ... | ... | @@ -142,6 +142,82 @@ impl OpenOwned { |
| 142 | 142 | } |
| 143 | 143 | } |
| 144 | 144 | } |
| 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 | } | |
| 145 | 221 | } |
| 146 | 222 | |
| 147 | 223 | #[derive(Debug, Clone, PartialEq, Eq)] |
| ... | ... | @@ -228,3 +304,78 @@ impl<'a> LexState<'a> { |
| 228 | 304 | } |
| 229 | 305 | } |
| 230 | 306 | } |
| 307 | ||
| 308 | #[cfg(test)] | |
| 309 | mod 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 | ||
| 6 | use markdown_it::{Node, NodeValue, Renderer}; | |
| 7 | use oxc_diagnostics::OxcDiagnostic; | |
| 8 | use oxc_span::Span; | |
| 9 | use std::collections::HashSet; | |
| 10 | ||
| 11 | use crate::marko_ast::AttributeValue; | |
| 12 | use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText}; | |
| 13 | ||
| 14 | /// A heading entry for the outline, with content reference for hoisting. | |
| 15 | #[derive(Debug, Clone)] | |
| 16 | pub 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 | |
| 26 | pub 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)] | |
| 37 | struct HeadingContentRef { | |
| 38 | component_name: String, | |
| 39 | } | |
| 40 | ||
| 41 | impl 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. | |
| 48 | pub 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. | |
| 89 | pub 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. | |
| 114 | fn 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 | ||
| 123 | fn 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 | ||
| 486 | fn 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. | |
| 500 | pub 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)] | |
| 519 | mod 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; |
| 5 | 5 | pub mod statement; |
| 6 | 6 | pub mod tags; |
| 7 | 7 | pub mod template; |
| 8 | pub mod toc; | |
| 9 | 8 | |
| 10 | 9 | pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; |
| 11 | 10 |
src/plugin/tags.rs+3-7| ... | ... | @@ -118,15 +118,13 @@ impl NodeValue for MarkoInlineTag { |
| 118 | 118 | /// The content is parsed as inline markdown |
| 119 | 119 | #[derive(Debug)] |
| 120 | 120 | pub struct MarkoBlockComplete { |
| 121 | pub open_tag: String, | |
| 121 | pub open: marko_ast::OpenOwned, | |
| 122 | 122 | pub close_tag: String, |
| 123 | pub tag_name: String, | |
| 124 | pub tag_name_len: usize, | |
| 125 | 123 | } |
| 126 | 124 | |
| 127 | 125 | impl NodeValue for MarkoBlockComplete { |
| 128 | 126 | fn render(&self, node: &Node, fmt: &mut dyn Renderer) { |
| 129 | fmt.text_raw(&self.open_tag); | |
| 127 | fmt.text_raw(&self.open.src); | |
| 130 | 128 | fmt.contents(&node.children); |
| 131 | 129 | fmt.text_raw(&self.close_tag); |
| 132 | 130 | fmt.text_raw("\n"); |
| ... | ... | @@ -235,10 +233,8 @@ impl BlockRule for Rule { |
| 235 | 233 | let mapping = vec![(0, text_start)]; |
| 236 | 234 | |
| 237 | 235 | let mut node = Node::new(MarkoBlockComplete { |
| 238 | open_tag: open.src.to_string(), | |
| 236 | open: open.to_owned(), | |
| 239 | 237 | close_tag: close_tag.to_string(), |
| 240 | tag_name: open.tag_name().to_string(), | |
| 241 | tag_name_len: open.tag_name().len(), | |
| 242 | 238 | }); |
| 243 | 239 | |
| 244 | 240 | if !content.is_empty() { |
src/plugin/toc.rs deleted-229| ... | ... | @@ -1,229 +0,0 @@ |
| 1 | use markdown_it::Node; | |
| 2 | use serde::Serialize; | |
| 3 | ||
| 4 | #[derive(Debug, Clone, Serialize)] | |
| 5 | pub struct HeadingEntry { | |
| 6 | pub level: u8, | |
| 7 | pub id: String, | |
| 8 | pub text: String, | |
| 9 | } | |
| 10 | ||
| 11 | pub 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 | ||
| 49 | pub 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 | ||
| 58 | fn 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 | ||
| 146 | fn 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 | ||
| 158 | pub 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 @@ |
| 1 | use crate::OutputFormat; | |
| 1 | use crate::{ComponentImports, OutputFormat}; | |
| 2 | 2 | use oxc_diagnostics::OxcDiagnostic; |
| 3 | 3 | use serde::Serialize; |
| 4 | 4 | use wasm_bindgen::prelude::*; |
| ... | ... | @@ -100,9 +100,24 @@ pub fn transform( |
| 100 | 100 | src: &str, |
| 101 | 101 | force_format: Option<OutputFormat>, |
| 102 | 102 | layout_import: Option<String>, |
| 103 | component_imports: JsValue, | |
| 103 | 104 | self_path: Option<String>, |
| 104 | 105 | ) -> 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 | ) { | |
| 106 | 121 | Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { |
| 107 | 122 | text: Some(output.text), |
| 108 | 123 | success: true, |
tests/fixtures.rs+45-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use markodown::{transform, OutputFormat}; | |
| 1 | use markodown::{transform, ComponentImports, OutputFormat}; | |
| 2 | 2 | use std::fs; |
| 3 | 3 | use std::path::Path; |
| 4 | 4 | |
| ... | ... | @@ -7,6 +7,16 @@ use std::path::Path; |
| 7 | 7 | /// Each fixture has an `.mdo` input and either an `.html` expected output |
| 8 | 8 | /// (static mode) or a `.marko` expected output (marko mode), or both. |
| 9 | 9 | fn 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. | |
| 14 | fn run_fixture_with_options( | |
| 15 | name: &str, | |
| 16 | layout: Option<&str>, | |
| 17 | component_imports: Option<ComponentImports>, | |
| 18 | self_path: Option<&str>, | |
| 19 | ) { | |
| 10 | 20 | let base = Path::new("tests/fixtures").join(name); |
| 11 | 21 | let input_path = base.with_extension("mdo"); |
| 12 | 22 | let html_path = base.with_extension("html"); |
| ... | ... | @@ -20,7 +30,14 @@ fn run_fixture(name: &str) { |
| 20 | 30 | let expected = fs::read_to_string(&html_path) |
| 21 | 31 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display())); |
| 22 | 32 | |
| 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(); | |
| 24 | 41 | |
| 25 | 42 | assert_eq!( |
| 26 | 43 | output.format, |
| ... | ... | @@ -40,7 +57,14 @@ fn run_fixture(name: &str) { |
| 40 | 57 | let expected = fs::read_to_string(&marko_path) |
| 41 | 58 | .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display())); |
| 42 | 59 | |
| 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(); | |
| 44 | 68 | |
| 45 | 69 | assert_eq!( |
| 46 | 70 | output.format, |
| ... | ... | @@ -172,3 +196,21 @@ fn fixture_21_frontmatter_blank_lines() { |
| 172 | 196 | fn fixture_22_frontmatter_mixed_preamble() { |
| 173 | 197 | run_fixture("22-frontmatter-mixed-preamble"); |
| 174 | 198 | } |
| 199 | ||
| 200 | #[test] | |
| 201 | fn fixture_23_outline_extracting() { | |
| 202 | run_fixture_with_options("23-outline-extracting", Some("./layout.marko"), None, None); | |
| 203 | } | |
| 204 | ||
| 205 | #[test] | |
| 206 | fn 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__> | |
| 1 | import Layout__markodown__ from "./layout.marko"; | |
| 2 | <define/Heading_1__markodown__> | |
| 2 | 3 | good morning |
| 3 | 4 | </> |
| 4 | <define/Header_2__markodown__> | |
| 5 | <define/Heading_2__markodown__> | |
| 5 | 6 | good night |
| 6 | 7 | </> |
| 7 | <define/Header_3__markodown__> | |
| 8 | <define/Heading_3__markodown__> | |
| 8 | 9 | <strong>snow</strong> time |
| 9 | 10 | </> |
| 11 | <define/Heading_4__markodown__> | |
| 10 | 12 | rain <strong>time</strong> |
| 11 | <define/Header_4__markodown__> | |
| 12 | 13 | </> |
| 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__ }, | |
| 18 | 19 | ]> |
| 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 | </> | |
| 27 | 28 | </> |
tests/fixtures/24-heading-import.marko created+5| ... | ... | @@ -0,0 +1,5 @@ |
| 1 | import 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> |