From c796d5ef4aa87f2db690d0090873c0c94c3aae04 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 15 Feb 2026 23:27:12 -0800 Subject: [PATCH] feat: better outline generation --- README.md | 152 ++++-- lib/jsr.json | 11 +- lib/mod.ts | 17 + src/component_transforms.rs | 423 +++++++++++++++ src/lib.rs | 96 ++-- src/main.rs | 51 +- src/marko_ast.rs | 151 ++++++ src/outline.rs | 574 +++++++++++++++++++++ src/plugin/mod.rs | 1 - src/plugin/tags.rs | 10 +- src/plugin/toc.rs | 229 -------- src/wasm.rs | 19 +- tests/fixtures.rs | 48 +- tests/fixtures/23-outline-extracting.marko | 35 +- tests/fixtures/24-heading-import.marko | 5 + tests/fixtures/24-heading-import.mdo | 7 + 16 files changed, 1488 insertions(+), 341 deletions(-) create mode 100644 src/component_transforms.rs create mode 100644 src/outline.rs delete mode 100644 src/plugin/toc.rs create mode 100644 tests/fixtures/24-heading-import.marko create mode 100644 tests/fixtures/24-heading-import.mdo diff --git a/README.md b/README.md index 7d4f0b77393fb7ce523dc9db153978bb212216d4..d923cdf0a7220eb6e63f7461f6c759ade803633e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Markodown +> STATUS: Markodown is not yet in use at paperclover.net. However, the API is +> complete and the library is functional. Give it a try! + This is a weird markup language that combines features of [Markdown] and [Marko]. You can think of this as an alternative universe to MDX. Since Marko components are really easy to write, it makes this a great tool for writing @@ -9,12 +12,15 @@ leveraging the existing ecosystem. [Markdown]: https://en.wikipedia.org/wiki/Markdown [Marko]: https://markojs.com/ -> STATUS: Functional, but many components have not gone through battle testing. -> There are likely edge cases where syntax breaks. Some portions of the overall -> glue is generated by AI without full audits. The Marko tag parser is hand -> written according to the documentation, but that document is not a -> specification and there are likely implementation differences. Treat with -> caution. +> **CONTENTS**: +> +> - [Install](#install) +> - [Components](#components) +> - [Outline / Table of Contents](#outline-table-of-contents) +> - [Frontmatter](#outline-table-of-contents) +> - [Comments](#comments) +> - [Paragraph Detection](#paragraph-detection) +> - [Static Statements](#static-statements) Here's a glance at how things look. Complete example documents in <./examples> @@ -65,42 +71,42 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}. ```` -## Syntax Reference +## Install -### Paragraphs - -All text blocks with spaces around them will be wrapped in a paragraph, like -markdown does. +Markdown is distributed on [NPM](http://npmjs.com/markodown) and +[JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs. +```sh +npm i markodown +npx jsr add @clo/markodown ``` -
not wrapped
-
-not wrapped -
- -
-this paragraph gets wrapped in a `

` tag - -

-``` - -Note that all text is still processed for other block types - -``` -
[back to home](/)
-// all other - +The compiler can be directly used from `transform`, and there are also plugins +for Rollup/Rolldown/Vite and esbuild. For example, configure Markdown with Marko +Run: + +```ts +import marko from "@marko/run/vite"; +import markodown from "markodown"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + marko(), + markodown({ + // optionally wrap all markdown files in a layout, this component is + // given a list of headers to construct a table of contents. + layoutImport: "../tags/markdown-layout.marko", + }), + ], +}); ``` -### Components +## Components -All Marko features, such as [tag resolution], [attribute tags], [class -shorthands], and template expressions. +All Marko features, such as [tag resolution], [attribute tags], +[class shorthands], and template expressions. This makes it so much easier to +add complex content to your pages. ``` ## cool video @@ -109,28 +115,49 @@ shorthands], and template expressions. <@header>**music video**: in the summer - - (c) ${new Date().getFullYear()} + + made with love... (c) ${new Date().getFullYear()} ``` [tag resolution]: https://markojs.com/docs/reference/custom-tag#relative-custom-tags +[attribute tags]: https://markojs.com/docs/reference/language#attribute-tags +[class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id -### Comments +## Outline / Table of Contents -Line, Block, and HTML comments work like they do in Marko/JavaScript. +You can use Markodown to write blogs and long documents, then extract a table of +contents. This is done with two mechanisms. + +- `layoutImport` which wraps the entire document in a component, which is given + three attributes. + - `content`: the rendered content. + - `module`: the module namespace for the compiled Markodown file. + - `outline`: an array of `Heading` objects. +- `componentImports`, which can let you customize the rendering of the headers + themselves. + +Using the basic heading tags is awesome, because you can very easily customize +the generated permalinks for each heading, and still use Markdown within the +heading titles. ``` -# My Blog +# my blog post + +about `markdown` + +... + +about `marko` + +... -Text that is complete. +some extra details -// ## An unfinished section of the blog -// -// TODO: we gotta add this! +... ``` -### Frontmatter +## Frontmatter All frontmatter fields are converted into exports. For example, a framework that reads the `meta` export for Open Graph can be easily satisfied with frontmatter. @@ -150,7 +177,40 @@ meta: # ${meta.title} ``` -### Static Statements +## Comments + +Line, Block, and HTML comments work like they do in Marko/JavaScript. + +``` +# My Blog + +Text that is complete. + +// ## An unfinished section of the blog +// +// TODO: we gotta finish it! +``` + +## Paragraph Detection + +Like Markdown, you can place content between components, but you can also place +inline markdown anywhere between tags. Effectively, this means that text gets +wrapped in `

` tags if there is a blank line above and below it. + +``` +

not wrapped
+
+not wrapped either +
+ +
+ +this paragraph gets wrapped in a `

` tag! + +

+``` + +## Static Statements Define module-level functions and variables. diff --git a/lib/jsr.json b/lib/jsr.json index f44015e16f4642edf95d1842c5d4be3f01aded83..ecc81a8e31589d4dfda41e468bbdf9e7c0a97548 100644 --- a/lib/jsr.json +++ b/lib/jsr.json @@ -1,4 +1,13 @@ { "name": "@clo/markodown", - "version": "1.0.0-rc.1" + "version": "1.0.0-rc.1", + "license": "MIT", + "exports": { + ".": "./mod.ts", + "./esbuild.ts": "./esbuild.ts", + "./rollup.ts": "./rollup.ts" + }, + "imports": { + "@marko/compiler": "@marko/compiler@^5.39.55" + } } diff --git a/lib/mod.ts b/lib/mod.ts index 2fec80a1b51f046ab61a3848e8242278968f8c8f..70f3d38761c23a96c3928e132b2df4488e0f7c81 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -5,6 +5,20 @@ wasm.initSync({ module: bytes }); type Transformed = Success | Failure; +/** Configuration for replacing built-in elements with custom components. */ +export interface ComponentImports { + /** Replace heading elements (h1-h6) with a custom component. Receives `level=1-6`. */ + heading?: string; + /** Replace code blocks with a custom component. Receives `language` and `meta`. */ + codeBlock?: string; + /** Replace link elements with a custom component. */ + link?: string; + /** Replace image elements with a custom component. */ + image?: string; + /** Replace blockquote elements with a custom component. */ + blockquote?: string; +} + export interface TransformOptions { source: string; /** @@ -16,6 +30,8 @@ export interface TransformOptions { format?: Array<"marko" | "html">; /** Wraps the component in another component. Enables Table of Contents generation */ layoutImport?: string; + /** Replace built-in elements with custom components */ + componentImports?: ComponentImports; } export type OutputFormat = "marko" | "html"; @@ -69,5 +85,6 @@ export function transform(options: TransformOptions): Transformed { options.source, forceFormat, options?.layoutImport, + options?.componentImports, ); } diff --git a/src/component_transforms.rs b/src/component_transforms.rs new file mode 100644 index 0000000000000000000000000000000000000000..799638c52d978e7193e82ac442ae5b698d0171d3 --- /dev/null +++ b/src/component_transforms.rs @@ -0,0 +1,423 @@ +//! Component transformations for replacing built-in elements with custom components. +//! +//! This module walks the AST and replaces heading, code block, link, image, and +//! blockquote elements with calls to custom components specified in ComponentImports. + +use markdown_it::{Node, NodeValue, Renderer}; + +use crate::marko_ast::OpenOwned; +use crate::plugin::tags::{MarkoBlockComplete, MarkoClose, MarkoOpen, MarkoOpenWithText}; +use crate::ComponentImports; + +/// Component name suffix to avoid collisions +const SUFFIX: &str = "__markodown__"; + +/// Heading component name +fn heading_component() -> String { + format!("HeadingComponent{}", SUFFIX) +} + +/// Code block component name +fn code_block_component() -> String { + format!("CodeBlockComponent{}", SUFFIX) +} + +/// Link component name +fn link_component() -> String { + format!("LinkComponent{}", SUFFIX) +} + +/// Image component name +fn image_component() -> String { + format!("ImageComponent{}", SUFFIX) +} + +/// Blockquote component name +fn blockquote_component() -> String { + format!("BlockquoteComponent{}", SUFFIX) +} + +/// Generate import statements for all configured component imports +pub fn generate_imports(imports: &ComponentImports) -> String { + let mut result = String::new(); + + if let Some(path) = &imports.heading { + result.push_str(&format!( + "import {} from \"{}\";\n", + heading_component(), + path + )); + } + + if let Some(path) = &imports.code_block { + result.push_str(&format!( + "import {} from \"{}\";\n", + code_block_component(), + path + )); + } + + if let Some(path) = &imports.link { + result.push_str(&format!("import {} from \"{}\";\n", link_component(), path)); + } + + if let Some(path) = &imports.image { + result.push_str(&format!( + "import {} from \"{}\";\n", + image_component(), + path + )); + } + + if let Some(path) = &imports.blockquote { + result.push_str(&format!( + "import {} from \"{}\";\n", + blockquote_component(), + path + )); + } + + result +} + +/// Transform all elements according to the component imports configuration. +pub fn transform_components(node: &mut Node, imports: &ComponentImports) { + // Process children first (bottom-up traversal) + for child in &mut node.children { + transform_components(child, imports); + } + + // Transform this node if applicable + if imports.heading.is_some() { + transform_heading(node); + } + + if imports.code_block.is_some() { + transform_code_block(node); + } + + if imports.link.is_some() { + transform_link(node); + } + + if imports.image.is_some() { + transform_image(node); + } + + if imports.blockquote.is_some() { + transform_blockquote(node); + } +} + +/// A code block component node that renders as `content` +#[derive(Debug)] +struct CodeBlockComponentNode { + /// Language identifier (e.g., "ts", "rust") + language: Option, + /// Additional meta info after language + meta: Option, + /// The code content + content: String, +} + +impl NodeValue for CodeBlockComponentNode { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.cr(); + fmt.text_raw(&format!("<{}", code_block_component())); + + if let Some(lang) = &self.language { + fmt.text_raw(&format!(" language=\"{}\"", escape_attr(lang))); + } + if let Some(meta) = &self.meta { + fmt.text_raw(&format!(" meta=\"{}\"", escape_attr(meta))); + } + + fmt.text_raw(">\n"); + // Escape content for Marko - need to wrap in a text node or use raw content + // Use a template literal to preserve the content exactly + fmt.text_raw(&format!("${{{:?}}}", self.content)); + fmt.text_raw("\n\n"); + } +} + +/// Escape a string for use in an attribute value +fn escape_attr(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +/// Transform a code block node to use the code block component. +fn transform_code_block(node: &mut Node) { + // Handle fenced code blocks (``` or ~~~) + if let Some(fence) = node.cast::() { + let info = &fence.info; + let mut parts = info.split_whitespace(); + let language = parts + .next() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let meta = { + let rest: String = parts.collect::>().join(" "); + if rest.is_empty() { + None + } else { + Some(rest) + } + }; + let content = fence.content.clone(); + + let new_node = Node::new(CodeBlockComponentNode { + language, + meta, + content, + }); + + *node = new_node; + return; + } + + // Handle indented code blocks (4 spaces) + if let Some(code) = node.cast::() { + let content = code.content.clone(); + + let new_node = Node::new(CodeBlockComponentNode { + language: None, + meta: None, + content, + }); + + *node = new_node; + } +} + +/// Transform a heading node (h1-h6) to use the heading component. +fn transform_heading(node: &mut Node) { + // Handle markdown ATX headings (# ## ### etc) + if let Some(heading) = node.cast::() { + let level = heading.level; + + // Create a new MarkoBlockComplete to replace this node + let mut open = OpenOwned::from_tag_name(&heading_component()); + open.insert_attr(&format!("level={}", level)); + + // We need to take ownership of children + let children = std::mem::take(&mut node.children); + + // Create the new node + let mut new_node = Node::new(MarkoBlockComplete { + open, + close_tag: "".to_string(), + }); + new_node.children = children; + new_node.srcmap = node.srcmap; + + *node = new_node; + return; + } + + // Handle setext headings (underline style) + if let Some(heading) = node.cast::() + { + let level = heading.level; + + let mut open = OpenOwned::from_tag_name(&heading_component()); + open.insert_attr(&format!("level={}", level)); + + let children = std::mem::take(&mut node.children); + + let mut new_node = Node::new(MarkoBlockComplete { + open, + close_tag: "".to_string(), + }); + new_node.children = children; + new_node.srcmap = node.srcmap; + + *node = new_node; + return; + } + + // Handle Marko open tags (h1-h6) - these have separate close tags + if let Some(marko_open) = node.cast_mut::() { + if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) { + marko_open.open.replace_tag_name(&heading_component()); + marko_open.open.insert_attr(&format!("level={}", level)); + } + return; + } + + // Handle Marko open with text tags (h1-h6) + if let Some(marko_open) = node.cast_mut::() { + if let Some(level) = parse_heading_level(marko_open.open.as_ref().tag_name()) { + marko_open.open.replace_tag_name(&heading_component()); + marko_open.open.insert_attr(&format!("level={}", level)); + } + return; + } + + // Handle Marko block complete tags (h1-h6) - open and close on same line + if let Some(marko_block) = node.cast_mut::() { + if let Some(level) = parse_heading_level(marko_block.open.as_ref().tag_name()) { + marko_block.open.replace_tag_name(&heading_component()); + marko_block.open.insert_attr(&format!("level={}", level)); + // Also update close tag to generic + marko_block.close_tag = "".to_string(); + } + return; + } + + // Handle close tags - need to replace etc with + if let Some(marko_close) = node.cast_mut::() { + if let Some(tag_name) = &marko_close.tag_name { + if parse_heading_level(tag_name).is_some() { + // Replace with generic close tag + marko_close.content = "".to_string(); + marko_close.tag_name = None; + marko_close.tag_name_len = None; + } + } + } +} + +/// Parse h1-h6 tag names and return the level +fn parse_heading_level(tag_name: &str) -> Option { + match tag_name { + "h1" => Some(1), + "h2" => Some(2), + "h3" => Some(3), + "h4" => Some(4), + "h5" => Some(5), + "h6" => Some(6), + _ => None, + } +} + +/// A link component node that renders as `content` +#[derive(Debug)] +struct LinkComponentNode { + href: String, + title: Option, +} + +impl NodeValue for LinkComponentNode { + fn render(&self, node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!( + "<{} href=\"{}\"", + link_component(), + escape_attr(&self.href) + )); + if let Some(title) = &self.title { + fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title))); + } + fmt.text_raw(">"); + fmt.contents(&node.children); + fmt.text_raw(""); + } +} + +/// Transform a link node to use the link component. +fn transform_link(node: &mut Node) { + if let Some(link) = node.cast::() { + let href = link.url.clone(); + let title = link.title.clone(); + let children = std::mem::take(&mut node.children); + + let mut new_node = Node::new(LinkComponentNode { href, title }); + new_node.children = children; + new_node.srcmap = node.srcmap; + + *node = new_node; + } +} + +/// An image component node that renders as `` +#[derive(Debug)] +struct ImageComponentNode { + src: String, + alt: String, + title: Option, +} + +impl NodeValue for ImageComponentNode { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!( + "<{} src=\"{}\" alt=\"{}\"", + image_component(), + escape_attr(&self.src), + escape_attr(&self.alt) + )); + if let Some(title) = &self.title { + fmt.text_raw(&format!(" title=\"{}\"", escape_attr(title))); + } + fmt.text_raw(" />"); + } +} + +/// Transform an image node to use the image component. +fn transform_image(node: &mut Node) { + if let Some(image) = node.cast::() { + let src = image.url.clone(); + let alt = node.collect_text(); + let title = image.title.clone(); + + let new_node = Node::new(ImageComponentNode { src, alt, title }); + *node = new_node; + } +} + +/// A blockquote component node that wraps content in `content` +#[derive(Debug)] +struct BlockquoteComponentNode; + +impl NodeValue for BlockquoteComponentNode { + fn render(&self, node: &Node, fmt: &mut dyn Renderer) { + fmt.cr(); + fmt.text_raw(&format!("<{}>", blockquote_component())); + fmt.cr(); + fmt.contents(&node.children); + fmt.cr(); + fmt.text_raw(""); + fmt.cr(); + } +} + +/// Transform a blockquote node to use the blockquote component. +fn transform_blockquote(node: &mut Node) { + if node + .cast::() + .is_some() + { + let children = std::mem::take(&mut node.children); + + let mut new_node = Node::new(BlockquoteComponentNode); + new_node.children = children; + new_node.srcmap = node.srcmap; + + *node = new_node; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_imports_heading() { + let imports = ComponentImports { + heading: Some("./heading.marko".to_string()), + ..Default::default() + }; + let result = generate_imports(&imports); + assert_eq!( + result, + "import HeadingComponent__markodown__ from \"./heading.marko\";\n" + ); + } + + #[test] + fn test_generate_imports_empty() { + let imports = ComponentImports::default(); + let result = generate_imports(&imports); + assert_eq!(result, ""); + } +} diff --git a/src/lib.rs b/src/lib.rs index b9f2e4d4d3da77da648eccbbeec4ac0328543e2c..b098780017c7fc74fe87c146b48fc5a4b2e14915 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ +pub mod component_transforms; pub mod marko; pub mod marko_ast; +pub mod outline; pub mod plugin; pub mod typescript; pub mod wasm; @@ -7,11 +9,29 @@ pub mod wasm; use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; use oxc_span::Span; use plugin::tags::{MarkoClose, MarkoOpen}; -use serde::Serialize; -use serde_json; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; use wasm_bindgen::prelude::wasm_bindgen; +/// Configuration for replacing built-in elements with custom components. +/// Each field is an optional import path for the component. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentImports { + /// Replace heading elements (h1-h6) with a custom component. + /// The component receives `level=1-6` and all original attributes. + pub heading: Option, + /// Replace code blocks (```) with a custom component. + /// The component receives `language` and `meta` attributes. + pub code_block: Option, + /// Replace link elements () with a custom component. + pub link: Option, + /// Replace image elements () with a custom component. + pub image: Option, + /// Replace blockquote elements with a custom component. + pub blockquote: Option, +} + pub struct Output { pub text: String, pub format: OutputFormat, @@ -29,7 +49,8 @@ pub fn transform( source: &str, force: Option, layout_import: Option, - self_path: Option, + component_imports: Option, + self_import: Option, ) -> Result> { // Pre-process to extract preamble (blank lines, imports) and frontmatter // This is needed because markdown-it skips blank lines before running block rules @@ -69,66 +90,83 @@ pub fn transform( return Err(errors); } + // Determine output format + // componentImports forces Marko format since we add imports let format = force.unwrap_or_else(|| { - if has_marko_features(&ast) { + if component_imports.is_some() || has_marko_features(&ast) { OutputFormat::Marko } else { OutputFormat::Html } }); - if force.is_some() && format == OutputFormat::Html && has_marko_features(&ast) { - return Err(vec![OxcDiagnostic::error( - "Cannot output HTML: document contains Marko-specific features", - )]); + if force.is_some() && format == OutputFormat::Html { + if has_marko_features(&ast) { + return Err(vec![OxcDiagnostic::error( + "Cannot output HTML: document contains Marko-specific features", + )]); + } + if component_imports.is_some() { + return Err(vec![OxcDiagnostic::error( + "Cannot output HTML: componentImports requires Marko output", + )]); + } } - // Collect headings for TOC if layout_import is provided - let outline_json; - let headings = if layout_import.is_some() { - let collected = plugin::toc::collect_headings(&ast); - outline_json = Some(serde_json::to_string(&collected).unwrap_or_default()); - collected + // Collect headings, inject IDs, and extract content if layout_import is provided + let outline_result = if layout_import.is_some() { + let result = outline::collect_and_extract(&mut ast, preamble_offset as u32); + if !result.errors.is_empty() { + return Err(result.errors); + } + Some(result) } else { - outline_json = None; - Vec::new() + None }; + // Transform components if componentImports is configured + // This must happen AFTER outline extraction so headings are still h1-h6 when collected + if let Some(ref imports) = component_imports { + component_transforms::transform_components(&mut ast, imports); + } + // Extract statements before rendering - they need to be hoisted above Layout let extracted_statements = hoist_statements(&mut ast); let mut text = ast.render(); - // Inject heading IDs if we collected headings - if !headings.is_empty() { - text = plugin::toc::inject_heading_ids(&text, &headings); - } - - // Build hoisted statements: preamble (frontmatter exports) + extracted statements + // Build hoisted content: preamble + component imports + extracted statements + defines let mut hoisted = String::new(); if let Some(preamble) = preamble_output { hoisted.push_str(&preamble); } + // Add component imports + if let Some(ref imports) = component_imports { + hoisted.push_str(&component_transforms::generate_imports(imports)); + } hoisted.push_str(&extracted_statements); // Wrap with Layout component if layout_import is provided - // Statements must come before the Layout tag - if let (Some(layout_path), Some(outline)) = (layout_import, outline_json) { - if let Some(self_path) = self_path { + // Statements and defines must come before the Layout tag + if let (Some(layout_path), Some(result)) = (layout_import, outline_result) { + let outline_array = outline::format_outline_array(&result.headings); + + // Add defines after other hoisted content + hoisted.push_str(&result.defines); + + if let Some(self_path) = self_import { text = format!( - "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}\n\n{}", + "import Layout__markodown__ from \"{}\";import * as self__markodown__ from \"{}\";\n{}\n{}", layout_path, self_path, hoisted, - outline, text ); } else { text = format!( - "import Layout__markodown__ from \"{}\";\n{}\n\n{}", + "import Layout__markodown__ from \"{}\";\n{}\n{}", layout_path, hoisted, - outline, text ); } diff --git a/src/main.rs b/src/main.rs index 921b407f86d72a51f021a582ec4ac323996d7dca..f358cc4a37d1df467af167ab91c21a36de2ab358 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use clap::{Parser, ValueEnum}; -use markodown::{transform, OutputFormat}; +use markodown::{transform, ComponentImports, OutputFormat}; use oxc_diagnostics::GraphicalReportHandler; use std::fs; use std::path::PathBuf; @@ -22,23 +22,62 @@ struct Cli { /// Force the output format (auto-detected by default) #[arg(long)] - output_format: Option, + force_format: Option, /// Layout component to wrap the output with. This component will recieve /// the module and a generated table of contents. #[arg(long)] - layout: Option, + layout_import: Option, + + /// Replace heading elements (h1-h6) with a custom component + #[arg(long)] + heading_import: Option, + + /// Replace code blocks with a custom component + #[arg(long)] + code_block_import: Option, + + /// Replace link elements with a custom component + #[arg(long)] + link_import: Option, + + /// Replace image elements with a custom component + #[arg(long)] + image_import: Option, + + /// Replace blockquote elements with a custom component + #[arg(long)] + blockquote_import: Option, } fn main() { let cli = Cli::parse(); - let force = cli.output_format.map(|f| match f { + let force = cli.force_format.map(|f| match f { CliOutputFormat::Html => OutputFormat::Html, CliOutputFormat::Marko => OutputFormat::Marko, }); - let layout_import = cli.layout; + let layout_import = cli.layout_import; + + // Build component imports from CLI flags + let component_imports = ComponentImports { + heading: cli.heading_import, + code_block: cli.code_block_import, + link: cli.link_import, + image: cli.image_import, + blockquote: cli.blockquote_import, + }; + let component_imports = if component_imports.heading.is_some() + || component_imports.code_block.is_some() + || component_imports.link.is_some() + || component_imports.image.is_some() + || component_imports.blockquote.is_some() + { + Some(component_imports) + } else { + None + }; let source = match fs::read_to_string(&cli.file) { Ok(s) => s, @@ -48,7 +87,7 @@ fn main() { } }; - match transform(&source, force, layout_import, None) { + match transform(&source, force, layout_import, component_imports, None) { Err(errors) => { let handler = GraphicalReportHandler::new(); for error in &errors { diff --git a/src/marko_ast.rs b/src/marko_ast.rs index 704862db5753ba92c42aeef1d0d7333a281c0f5c..7b54554fdfebe0afa5d18c004734b8e72902a178 100644 --- a/src/marko_ast.rs +++ b/src/marko_ast.rs @@ -142,6 +142,82 @@ impl OpenOwned { } } } + + /// Replace the tag name while preserving all shorthands and attributes. + /// For example: `` → `` + pub fn replace_tag_name(&mut self, new_name: &str) { + let old_start = self.literal_tag_name.start as usize; + let old_end = self.literal_tag_name.end as usize; + let old_len = old_end - old_start; + let new_len = new_name.len(); + let delta = new_len as i32 - old_len as i32; + + // Build new source + let mut new_src = String::with_capacity((self.src.len() as i32 + delta) as usize); + new_src.push_str(&self.src[..old_start]); + new_src.push_str(new_name); + new_src.push_str(&self.src[old_end..]); + + // Update spans + self.literal_tag_name = Span::new(old_start as u32, (old_start + new_len) as u32); + self.shorthand_end = (self.shorthand_end as i32 + delta) as u32; + + // Update id span if present + if let AttributeValue::Static { span, is_quoted } = self.id { + self.id = AttributeValue::Static { + span: Span::new( + (span.start as i32 + delta) as u32, + (span.end as i32 + delta) as u32, + ), + is_quoted, + }; + } + + self.src = new_src; + } + + /// Insert an attribute after the shorthands (before other attributes). + /// For example: `` with `level=3` → `` + pub fn insert_attr(&mut self, attr: &str) { + let insert_pos = self.shorthand_end as usize; + let insert_str = format!(" {}", attr); + let delta = insert_str.len(); + + // Build new source + let mut new_src = String::with_capacity(self.src.len() + delta); + new_src.push_str(&self.src[..insert_pos]); + new_src.push_str(&insert_str); + new_src.push_str(&self.src[insert_pos..]); + + // Update shorthand_end to point after the new attribute + self.shorthand_end += delta as u32; + + // Update id span if it comes after the insertion point + if let AttributeValue::Static { span, is_quoted } = self.id { + if span.start >= insert_pos as u32 { + self.id = AttributeValue::Static { + span: Span::new(span.start + delta as u32, span.end + delta as u32), + is_quoted, + }; + } + } + + self.src = new_src; + } + + /// Create a new OpenOwned from a tag name (for synthesizing tags). + /// Creates a simple `` with no attributes. + pub fn from_tag_name(tag_name: &str) -> Self { + let src = format!("<{}>", tag_name); + let tag_name_span = Span::new(1, 1 + tag_name.len() as u32); + OpenOwned { + src, + literal_tag_name: tag_name_span, + self_closing: false, + shorthand_end: 1 + tag_name.len() as u32, + id: AttributeValue::None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -228,3 +304,78 @@ impl<'a> LexState<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::marko::parse_open; + + #[test] + fn test_replace_tag_name_simple() { + let mut open = OpenOwned::from_tag_name("h3"); + assert_eq!(open.src, "

"); + open.replace_tag_name("Heading"); + assert_eq!(open.src, ""); + assert_eq!(open.as_ref().tag_name(), "Heading"); + } + + #[test] + fn test_replace_tag_name_with_shorthand() { + let mut open = parse_open("").unwrap().to_owned(); + assert_eq!(open.as_ref().tag_name(), "h3"); + open.replace_tag_name("Heading"); + assert_eq!(open.src, ""); + assert_eq!(open.as_ref().tag_name(), "Heading"); + // ID should still be accessible + assert_eq!(open.as_ref().id_attr(), Some(("foo", false))); + } + + #[test] + fn test_replace_tag_name_with_attributes() { + let mut open = parse_open("

").unwrap().to_owned(); + open.replace_tag_name("HeadingComponent__markodown__"); + assert_eq!( + open.src, + "" + ); + } + + #[test] + fn test_insert_attr_simple() { + let mut open = OpenOwned::from_tag_name("Heading"); + open.insert_attr("level=3"); + assert_eq!(open.src, ""); + } + + #[test] + fn test_insert_attr_with_shorthand() { + let mut open = parse_open("").unwrap().to_owned(); + open.insert_attr("level=3"); + assert_eq!(open.src, ""); + // ID should still be accessible + assert_eq!(open.as_ref().id_attr(), Some(("foo", false))); + } + + #[test] + fn test_insert_attr_with_existing_attrs() { + let mut open = parse_open("") + .unwrap() + .to_owned(); + open.insert_attr("level=3"); + assert_eq!(open.src, ""); + } + + #[test] + fn test_combined_replace_and_insert() { + let mut open = parse_open("") + .unwrap() + .to_owned(); + open.replace_tag_name("HeadingComponent__markodown__"); + open.insert_attr("level=3"); + assert_eq!( + open.src, + "" + ); + assert_eq!(open.as_ref().id_attr(), Some(("about", false))); + } +} diff --git a/src/outline.rs b/src/outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe5bd14affc82eb5671489669157c09c91107380 --- /dev/null +++ b/src/outline.rs @@ -0,0 +1,574 @@ +//! Outline extraction for headings. +//! +//! Walks the AST to collect heading information, inject IDs, and extract content +//! into hoisted `` blocks for use in the outline. + +use markdown_it::{Node, NodeValue, Renderer}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_span::Span; +use std::collections::HashSet; + +use crate::marko_ast::AttributeValue; +use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText}; + +/// A heading entry for the outline, with content reference for hoisting. +#[derive(Debug, Clone)] +pub struct HeadingEntry { + pub level: u8, + pub id: String, + /// Plain text content (for display/search) + pub text: String, + /// Component name for the hoisted content (e.g., "Heading_1__markodown__") + pub component_name: String, +} + +/// Result of outline extraction +pub struct OutlineResult { + /// The heading entries for the outline + pub headings: Vec, + /// The `` blocks to hoist (as rendered strings) + pub defines: String, + /// Any errors encountered (e.g., dynamic IDs) + pub errors: Vec, +} + +/// A node that renders as a component reference: `` +#[derive(Debug)] +struct HeadingContentRef { + component_name: String, +} + +impl NodeValue for HeadingContentRef { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!("<{}/>\n", self.component_name)); + } +} + +/// Generate a URL-friendly slug from text, ensuring uniqueness. +pub fn generate_slug(text: &str, existing_ids: &mut HashSet) -> String { + let mut slug: String = text + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' => c, + 'A'..='Z' => c.to_ascii_lowercase(), + ' ' | '\t' => '-', + _ if c.is_alphanumeric() => c.to_ascii_lowercase(), + _ => '-', + }) + .collect(); + + // Collapse multiple dashes and trim + slug = slug + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + if slug.is_empty() { + slug = "heading".to_string(); + } + + // Ensure uniqueness + if !existing_ids.contains(&slug) { + existing_ids.insert(slug.clone()); + return slug; + } + + let mut counter = 1; + loop { + let new_slug = format!("{}-{}", slug, counter); + if !existing_ids.contains(&new_slug) { + existing_ids.insert(new_slug.clone()); + return new_slug; + } + counter += 1; + } +} + +/// Collect headings from the AST, inject IDs, and extract content into defines. +pub fn collect_and_extract(node: &mut Node, preamble_offset: u32) -> OutlineResult { + let mut headings = Vec::new(); + let mut errors = Vec::new(); + let mut defines = String::new(); + let mut existing_ids = HashSet::new(); + let mut heading_counter = 0usize; + + collect_recursive( + node, + &mut headings, + &mut errors, + &mut defines, + &mut existing_ids, + &mut heading_counter, + preamble_offset, + ); + + OutlineResult { + headings, + defines, + errors, + } +} + +/// Render a node's children to a string. +fn render_children(node: &Node) -> String { + // Create a temporary wrapper to render just the children + let mut output = String::new(); + for child in &node.children { + output.push_str(&child.render()); + } + output +} + +fn collect_recursive( + node: &mut Node, + headings: &mut Vec, + errors: &mut Vec, + defines: &mut String, + existing_ids: &mut HashSet, + heading_counter: &mut usize, + preamble_offset: u32, +) { + let (node_start, _) = node.srcmap.map(|s| s.get_byte_offsets()).unwrap_or((0, 0)); + let node_start = node_start as u32 + preamble_offset; + + // Check for markdown-it ATX headings (# ## ### etc) + if let Some(heading) = node.cast::() { + let level = heading.level; + let text = node.collect_text(); + let id = generate_slug(&text, existing_ids); + + // Generate component name + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + // Render children before replacing them + let rendered_content = render_children(node); + + // Create the define block + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + // Replace children with reference node + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + // Inject id attribute + node.attrs.push(("id", id.clone())); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + // Check for setext headings (underline style) + else if let Some(heading) = + node.cast::() + { + let level = heading.level; + let text = node.collect_text(); + let id = generate_slug(&text, existing_ids); + + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + let rendered_content = render_children(node); + + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + node.attrs.push(("id", id.clone())); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + // Check for setext headings (underline style) + else if let Some(heading) = + node.cast::() + { + let level = heading.level; + let text = node.collect_text(); + let id = generate_slug(&text, existing_ids); + + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + let rendered_content = render_children(node); + + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + node.attrs.push(("id", id.clone())); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + // Check for MarkoOpen tags that are h1-h6 + else if node.cast::().is_some() { + let heading_info = { + let open = node.cast::().unwrap(); + if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id_info = open.open.id; + let tag_span = open.open.as_ref().tag_name_span(); + let existing_id = match id_info { + AttributeValue::Static { span, .. } => { + Some(open.open.src[span.start as usize..span.end as usize].to_string()) + } + _ => None, + }; + Some((level, text, id_info, tag_span, existing_id)) + } else { + None + } + }; + + if let Some((level, text, id_info, tag_span, existing_id)) = heading_info { + let id = match id_info { + AttributeValue::Static { .. } => { + let id_str = existing_id.unwrap(); + if !existing_ids.contains(&id_str) { + existing_ids.insert(id_str.clone()); + id_str + } else { + generate_slug(&text, existing_ids) + } + } + AttributeValue::Dynamic => { + errors.push( + OxcDiagnostic::error( + "Dynamic id attribute not supported when outline is enabled", + ) + .with_label(Span::new( + node_start + tag_span.start, + node_start + tag_span.end, + )), + ); + generate_slug(&text, existing_ids) + } + AttributeValue::None => { + let id = generate_slug(&text, existing_ids); + let open = node.cast_mut::().unwrap(); + open.open.insert_id_attr(&id); + id + } + }; + + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + let rendered_content = render_children(node); + + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + } + // Check for MarkoOpenWithText tags that are h1-h6 + else if node.cast::().is_some() { + let heading_info = { + let open = node.cast::().unwrap(); + if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id_info = open.open.id; + let tag_span = open.open.as_ref().tag_name_span(); + let existing_id = match id_info { + AttributeValue::Static { span, .. } => { + Some(open.open.src[span.start as usize..span.end as usize].to_string()) + } + _ => None, + }; + Some((level, text, id_info, tag_span, existing_id)) + } else { + None + } + }; + + if let Some((level, text, id_info, tag_span, existing_id)) = heading_info { + let id = match id_info { + AttributeValue::Static { .. } => { + let id_str = existing_id.unwrap(); + if !existing_ids.contains(&id_str) { + existing_ids.insert(id_str.clone()); + id_str + } else { + generate_slug(&text, existing_ids) + } + } + AttributeValue::Dynamic => { + errors.push( + OxcDiagnostic::error( + "Dynamic id attribute not supported when outline is enabled", + ) + .with_label(Span::new( + node_start + tag_span.start, + node_start + tag_span.end, + )), + ); + generate_slug(&text, existing_ids) + } + AttributeValue::None => { + let id = generate_slug(&text, existing_ids); + let open = node.cast_mut::().unwrap(); + open.open.insert_id_attr(&id); + id + } + }; + + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + let rendered_content = render_children(node); + + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + } + // Check for MarkoBlockComplete tags that are h1-h6 + else if node.cast::().is_some() { + let heading_info = { + let block = node.cast::().unwrap(); + if let Some(level) = parse_heading_level(block.open.as_ref().tag_name()) { + let text = node + .children + .iter() + .map(|c| c.collect_text()) + .collect::>() + .join(""); + let id_info = block.open.id; + let tag_span = block.open.as_ref().tag_name_span(); + let existing_id = match id_info { + AttributeValue::Static { span, .. } => { + Some(block.open.src[span.start as usize..span.end as usize].to_string()) + } + _ => None, + }; + Some((level, text, id_info, tag_span, existing_id)) + } else { + None + } + }; + + if let Some((level, text, id_info, tag_span, existing_id)) = heading_info { + let id = match id_info { + AttributeValue::Static { .. } => { + let id_str = existing_id.unwrap(); + if !existing_ids.contains(&id_str) { + existing_ids.insert(id_str.clone()); + id_str + } else { + generate_slug(&text, existing_ids) + } + } + AttributeValue::Dynamic => { + errors.push( + OxcDiagnostic::error( + "Dynamic id attribute not supported when outline is enabled", + ) + .with_label(Span::new( + node_start + tag_span.start, + node_start + tag_span.end, + )), + ); + generate_slug(&text, existing_ids) + } + AttributeValue::None => { + let id = generate_slug(&text, existing_ids); + let block = node.cast_mut::().unwrap(); + block.open.insert_id_attr(&id); + id + } + }; + + *heading_counter += 1; + let component_name = format!("Heading_{}__markodown__", heading_counter); + + let rendered_content = render_children(node); + + let trimmed_content = rendered_content.trim(); + defines.push_str(&format!( + "\n{trimmed_content}\n\n" + )); + + node.children.clear(); + node.children.push(Node::new(HeadingContentRef { + component_name: component_name.clone(), + })); + + headings.push(HeadingEntry { + level, + id, + text, + component_name, + }); + } + } + + // Recurse into children + for child in &mut node.children { + collect_recursive( + child, + headings, + errors, + defines, + existing_ids, + heading_counter, + preamble_offset, + ); + } +} + +fn parse_heading_level(tag_name: &str) -> Option { + match tag_name { + "h1" => Some(1), + "h2" => Some(2), + "h3" => Some(3), + "h4" => Some(4), + "h5" => Some(5), + "h6" => Some(6), + _ => None, + } +} + +/// Format the outline array for Marko output. +/// Uses JS object syntax with unquoted component references. +pub fn format_outline_array(headings: &[HeadingEntry]) -> String { + if headings.is_empty() { + return "[]".to_string(); + } + + let entries: Vec = headings + .iter() + .map(|h| { + format!( + "{{ level: {}, id: '{}', content: {} }}", + h.level, h.id, h.component_name + ) + }) + .collect(); + + format!("[\n {},\n]", entries.join(",\n ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_slug_basic() { + let mut existing = HashSet::new(); + assert_eq!(generate_slug("Hello World", &mut existing), "hello-world"); + assert!(existing.contains("hello-world")); + } + + #[test] + fn test_generate_slug_special_chars() { + let mut existing = HashSet::new(); + assert_eq!( + generate_slug("What's up? (2024)", &mut existing), + "what-s-up-2024" + ); + } + + #[test] + fn test_generate_slug_duplicates() { + let mut existing = HashSet::new(); + assert_eq!(generate_slug("test", &mut existing), "test"); + assert_eq!(generate_slug("test", &mut existing), "test-1"); + assert_eq!(generate_slug("test", &mut existing), "test-2"); + } + + #[test] + fn test_generate_slug_empty() { + let mut existing = HashSet::new(); + assert_eq!(generate_slug("!!!", &mut existing), "heading"); + } + + #[test] + fn test_format_outline_array() { + let headings = vec![ + HeadingEntry { + level: 1, + id: "hello".to_string(), + text: "Hello".to_string(), + component_name: "Heading_1__markodown__".to_string(), + }, + HeadingEntry { + level: 2, + id: "world".to_string(), + text: "World".to_string(), + component_name: "Heading_2__markodown__".to_string(), + }, + ]; + + let result = format_outline_array(&headings); + assert!(result.contains("level: 1")); + assert!(result.contains("id: 'hello'")); + assert!(result.contains("content: Heading_1__markodown__")); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 8dee0257d179f2e472908f4315eea094eff810b2..7494368208e4b2034fdcad1ea3326899ecff1c92 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -5,7 +5,6 @@ pub mod inline_tags; pub mod statement; pub mod tags; pub mod template; -pub mod toc; pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; diff --git a/src/plugin/tags.rs b/src/plugin/tags.rs index d9a3a97660d94876c14ce23cd7b955bcfcbbca00..8084d006892dba24977240d7ddb88fa581c5f5e3 100644 --- a/src/plugin/tags.rs +++ b/src/plugin/tags.rs @@ -118,15 +118,13 @@ impl NodeValue for MarkoInlineTag { /// The content is parsed as inline markdown #[derive(Debug)] pub struct MarkoBlockComplete { - pub open_tag: String, + pub open: marko_ast::OpenOwned, pub close_tag: String, - pub tag_name: String, - pub tag_name_len: usize, } impl NodeValue for MarkoBlockComplete { fn render(&self, node: &Node, fmt: &mut dyn Renderer) { - fmt.text_raw(&self.open_tag); + fmt.text_raw(&self.open.src); fmt.contents(&node.children); fmt.text_raw(&self.close_tag); fmt.text_raw("\n"); @@ -235,10 +233,8 @@ impl BlockRule for Rule { let mapping = vec![(0, text_start)]; let mut node = Node::new(MarkoBlockComplete { - open_tag: open.src.to_string(), + open: open.to_owned(), close_tag: close_tag.to_string(), - tag_name: open.tag_name().to_string(), - tag_name_len: open.tag_name().len(), }); if !content.is_empty() { diff --git a/src/plugin/toc.rs b/src/plugin/toc.rs deleted file mode 100644 index 48445654752db889e1a1331f0df1fb3e9466897c..0000000000000000000000000000000000000000 --- a/src/plugin/toc.rs +++ /dev/null @@ -1,229 +0,0 @@ -use markdown_it::Node; -use serde::Serialize; - -#[derive(Debug, Clone, Serialize)] -pub struct HeadingEntry { - pub level: u8, - pub id: String, - pub text: String, -} - -pub fn generate_slug(text: &str, existing_ids: &mut std::collections::HashSet) -> String { - let mut slug = text - .chars() - .map(|c| match c { - 'a'..='z' | '0'..='9' => c.to_ascii_lowercase(), - 'A'..='Z' => c.to_ascii_lowercase(), - ' ' | '\t' => '-', - _ if c.is_alphanumeric() => c.to_ascii_lowercase(), - _ => '-', - }) - .collect::(); - - slug = slug - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - - if slug.is_empty() { - slug = "heading".to_string(); - } - - if !existing_ids.contains(&slug) { - existing_ids.insert(slug.clone()); - return slug; - } - - let mut counter = 1; - loop { - let new_slug = format!("{}-{}", slug, counter); - if !existing_ids.contains(&new_slug) { - existing_ids.insert(new_slug.clone()); - return new_slug; - } - counter += 1; - } -} - -pub fn collect_headings(node: &Node) -> Vec { - let mut headings = Vec::new(); - let mut existing_ids = std::collections::HashSet::new(); - - collect_headings_recursive(node, &mut headings, &mut existing_ids); - - headings -} - -fn collect_headings_recursive( - node: &Node, - headings: &mut Vec, - existing_ids: &mut std::collections::HashSet, -) { - // Check for markdown-it ATX headings (# ## ### etc) - if let Some(heading) = node.cast::() { - let text = node.collect_text(); - let id = generate_slug(&text, existing_ids); - headings.push(HeadingEntry { - level: heading.level, - id, - text, - }); - } - // Check for setext headings (underline style) - else if let Some(heading) = - node.cast::() - { - let text = node.collect_text(); - let id = generate_slug(&text, existing_ids); - headings.push(HeadingEntry { - level: heading.level, - id, - text, - }); - } - // Check for MarkoOpen tags that are h1-h6 - else if let Some(open) = node.cast::() { - if let Some(level) = parse_heading_level(&open.open.as_ref().tag_name()) { - let text = node - .children - .iter() - .map(|c| c.collect_text()) - .collect::>() - .join(""); - let id = if let Some(existing) = open.open.src.strip_prefix('<').and_then(|s| { - s.find("id=\"") - .map(|pos| { - let start = pos + 4; - s[start..].split('"').next().map(|s| s.to_string()) - }) - .flatten() - }) { - if !existing_ids.contains(&existing) { - existing_ids.insert(existing.clone()); - existing - } else { - generate_slug(&text, existing_ids) - } - } else { - generate_slug(&text, existing_ids) - }; - headings.push(HeadingEntry { level, id, text }); - } - } - // Check for MarkoOpenWithText tags that are h1-h6 - else if let Some(open) = node.cast::() { - if let Some(level) = parse_heading_level(open.open.as_ref().tag_name()) { - let text = node - .children - .iter() - .map(|c| c.collect_text()) - .collect::>() - .join(""); - let id = generate_slug(&text, existing_ids); - headings.push(HeadingEntry { level, id, text }); - } - } - // Check for MarkoBlockComplete tags that are h1-h6 - else if let Some(block) = node.cast::() { - if let Some(level) = parse_heading_level(block.tag_name.as_str()) { - let text = node - .children - .iter() - .map(|c| c.collect_text()) - .collect::>() - .join(""); - let id = generate_slug(&text, existing_ids); - headings.push(HeadingEntry { level, id, text }); - } - } - - for child in &node.children { - collect_headings_recursive(child, headings, existing_ids); - } -} - -fn parse_heading_level(tag_name: &str) -> Option { - match tag_name { - "h1" => Some(1), - "h2" => Some(2), - "h3" => Some(3), - "h4" => Some(4), - "h5" => Some(5), - "h6" => Some(6), - _ => None, - } -} - -pub fn inject_heading_ids(text: &str, headings: &[HeadingEntry]) -> String { - if headings.is_empty() { - return text.to_string(); - } - - let mut result = String::new(); - let mut pos = 0; - let mut heading_idx = 0; - let bytes = text.as_bytes(); - - while pos < bytes.len() { - if bytes[pos] == b'<' { - let remaining = &bytes[pos..]; - if remaining.starts_with(b" 1u8, - b'2' => 2, - b'3' => 3, - b'4' => 4, - b'5' => 5, - b'6' => 6, - _ => { - result.push('<'); - pos += 1; - continue; - } - }; - - let close_pos = match remaining[2..].iter().position(|&b| b == b'>') { - Some(p) => p + 2, - None => { - result.push('<'); - pos += 1; - continue; - } - }; - - let tag_end = pos + close_pos; - let tag = std::str::from_utf8(&bytes[pos..=tag_end]).unwrap_or(""); - - if tag.ends_with("/>") || tag.contains(" ") { - result.push_str(tag); - pos = tag_end + 1; - continue; - } - - if heading_idx < headings.len() && headings[heading_idx].level == level { - let id = &headings[heading_idx].id; - result.push_str(&format!("h{level} id=\"{}\">", id)); - heading_idx += 1; - pos = tag_end + 1; - continue; - } else { - result.push_str(tag); - pos = tag_end + 1; - continue; - } - } - } - - result.push(bytes[pos] as char); - pos += 1; - } - - result -} diff --git a/src/wasm.rs b/src/wasm.rs index 939ef888dad6bf95a1564df717067072fdd3fce3..66de423d8fb7f9d8db1e26b153ad0d2d1ffa241e 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -1,4 +1,4 @@ -use crate::OutputFormat; +use crate::{ComponentImports, OutputFormat}; use oxc_diagnostics::OxcDiagnostic; use serde::Serialize; use wasm_bindgen::prelude::*; @@ -100,9 +100,24 @@ pub fn transform( src: &str, force_format: Option, layout_import: Option, + component_imports: JsValue, self_path: Option, ) -> JsValue { - match crate::transform(src, force_format, layout_import, self_path) { + // Deserialize component_imports from JsValue (can be undefined/null) + let component_imports: Option = + if component_imports.is_undefined() || component_imports.is_null() { + None + } else { + serde_wasm_bindgen::from_value(component_imports).ok() + }; + + match crate::transform( + src, + force_format, + layout_import, + component_imports, + self_path, + ) { Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { text: Some(output.text), success: true, diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 50fcc6a432c55795f90ce0237786948f30f60849..b832e79f1bb94c3cd5b095934181ca984493719e 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -1,4 +1,4 @@ -use markodown::{transform, OutputFormat}; +use markodown::{transform, ComponentImports, OutputFormat}; use std::fs; use std::path::Path; @@ -7,6 +7,16 @@ use std::path::Path; /// Each fixture has an `.mdo` input and either an `.html` expected output /// (static mode) or a `.marko` expected output (marko mode), or both. fn run_fixture(name: &str) { + run_fixture_with_options(name, None, None, None); +} + +/// Run fixture with optional layout, component_imports, and self_path arguments. +fn run_fixture_with_options( + name: &str, + layout: Option<&str>, + component_imports: Option, + self_path: Option<&str>, +) { let base = Path::new("tests/fixtures").join(name); let input_path = base.with_extension("mdo"); let html_path = base.with_extension("html"); @@ -20,7 +30,14 @@ fn run_fixture(name: &str) { let expected = fs::read_to_string(&html_path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", html_path.display())); - let output = transform(&source, Some(OutputFormat::Html), None, None).unwrap(); + let output = transform( + &source, + Some(OutputFormat::Html), + layout.map(|s| s.to_string()), + component_imports.clone(), + self_path.map(|s| s.to_string()), + ) + .unwrap(); assert_eq!( output.format, @@ -40,7 +57,14 @@ fn run_fixture(name: &str) { let expected = fs::read_to_string(&marko_path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", marko_path.display())); - let output = transform(&source, Some(OutputFormat::Marko), None, None).unwrap(); + let output = transform( + &source, + Some(OutputFormat::Marko), + layout.map(|s| s.to_string()), + component_imports.clone(), + self_path.map(|s| s.to_string()), + ) + .unwrap(); assert_eq!( output.format, @@ -172,3 +196,21 @@ fn fixture_21_frontmatter_blank_lines() { fn fixture_22_frontmatter_mixed_preamble() { run_fixture("22-frontmatter-mixed-preamble"); } + +#[test] +fn fixture_23_outline_extracting() { + run_fixture_with_options("23-outline-extracting", Some("./layout.marko"), None, None); +} + +#[test] +fn fixture_24_heading_import() { + run_fixture_with_options( + "24-heading-import", + None, + Some(ComponentImports { + heading: Some("./heading.marko".to_string()), + ..Default::default() + }), + None, + ); +} diff --git a/tests/fixtures/23-outline-extracting.marko b/tests/fixtures/23-outline-extracting.marko index 4defab92d3ca6daf4a0423bb6a06d4d07766727c..2736aa794e967adf1a9268c42d231ecfa4670c2d 100644 --- a/tests/fixtures/23-outline-extracting.marko +++ b/tests/fixtures/23-outline-extracting.marko @@ -1,27 +1,28 @@ - +import Layout__markodown__ from "./layout.marko"; + good morning - + good night - + snow time + rain time - - -

-

content 1

-

-

content 2

- -

content 3

- -

content 4

+ + +

+

+

+

+ + diff --git a/tests/fixtures/24-heading-import.marko b/tests/fixtures/24-heading-import.marko new file mode 100644 index 0000000000000000000000000000000000000000..7cbdc3f41742160fe2b95128abb5da137c62d7ec --- /dev/null +++ b/tests/fixtures/24-heading-import.marko @@ -0,0 +1,5 @@ +import HeadingComponent__markodown__ from "./heading.marko"; +Hello World +About Us +Details here +Simple heading diff --git a/tests/fixtures/24-heading-import.mdo b/tests/fixtures/24-heading-import.mdo new file mode 100644 index 0000000000000000000000000000000000000000..5173323abdc4cabc79b49a50c18839a8e384f4c5 --- /dev/null +++ b/tests/fixtures/24-heading-import.mdo @@ -0,0 +1,7 @@ +# Hello World + +About Us + +### Details here + +

Simple heading

-- 2.54.0