From d25d757a902f98e69e9178395f7857907d37d030 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 16 Feb 2026 21:49:05 -0800 Subject: [PATCH] chore: ok it might be usable now --- README.md | 90 +- examples/hoj.mdo | 1 + examples/slop.mdo | 182 +++++ src/component_transforms.rs | 141 ++++ src/lib.rs | 768 +++++++++++++++++- src/plugin/frontmatter.rs | 133 ++- src/plugin/statement.rs | 10 +- tests/fixtures.rs | 47 ++ tests/fixtures/23-outline-extracting.marko | 20 +- tests/fixtures/27-frontmatter-layout.marko | 20 + tests/fixtures/27-frontmatter-layout.mdo | 8 + tests/fixtures/28-outline-self-import.marko | 25 + tests/fixtures/28-outline-self-import.mdo | 5 + tests/fixtures/29-layout-all-components.marko | 31 + tests/fixtures/29-layout-all-components.mdo | 9 + .../30-layout-selective-components.marko | 19 + .../30-layout-selective-components.mdo | 3 + .../31-import-with-markdown-content.marko | 4 + .../31-import-with-markdown-content.mdo | 5 + wtf.mdo | 5 - 20 files changed, 1481 insertions(+), 45 deletions(-) create mode 100644 examples/slop.mdo create mode 100644 tests/fixtures/27-frontmatter-layout.marko create mode 100644 tests/fixtures/27-frontmatter-layout.mdo create mode 100644 tests/fixtures/28-outline-self-import.marko create mode 100644 tests/fixtures/28-outline-self-import.mdo create mode 100644 tests/fixtures/29-layout-all-components.marko create mode 100644 tests/fixtures/29-layout-all-components.mdo create mode 100644 tests/fixtures/30-layout-selective-components.marko create mode 100644 tests/fixtures/30-layout-selective-components.mdo create mode 100644 tests/fixtures/31-import-with-markdown-content.marko create mode 100644 tests/fixtures/31-import-with-markdown-content.mdo delete mode 100644 wtf.mdo diff --git a/README.md b/README.md index e48779eaa6550f45c86c57ba1a100c89a83c8e65..5ea905d5f68264d8fac898859d36c14fe94e050b 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ leveraging the existing ecosystem. > **CONTENTS**: > -> - [Install](#install) -> - [Components](#components) -> - [Outline / Table of Contents](#outline-table-of-contents) -> - [Frontmatter](#frontmatter) -> - [Comments](#comments) -> - [Paragraph Detection](#paragraph-detection) -> - [Static Statements](#static-statements) +> - [Usage](#usage) +> - [Components](#components) +> - [Outline / Table of Contents](#outline-table-of-contents) +> - [Frontmatter](#frontmatter) +> - [Comments](#comments) +> - [Paragraph Detection](#paragraph-detection) +> - [Static Statements](#static-statements) +> - [Config](#config) Here's a glance at how things look. Complete example documents in <./examples> @@ -71,14 +72,15 @@ i love being alive. ${'<3'} from ${new Date().getFullYear()}. ```` -## Install +## Usage Markodown is distributed on [NPM](https://npmjs.com/package/@paperclover/markodown) and [JSR](https://jsr.io/@clo/markodown). The compiler runs anywhere JS+WASM runs. ```sh -npm i @paperclover/markodown +# alias install +npm i @clo/markodown@npm:@paperclover/markodown # or npx jsr add @clo/markodown ``` @@ -89,7 +91,7 @@ Marko Run: ```ts import marko from "@marko/run/vite"; -import markodown from "markodown"; +import markodown from "@clo/markodown"; import { defineConfig } from "vite"; export default defineConfig({ @@ -105,7 +107,7 @@ export default defineConfig({ }); ``` -## Components +### Components All Marko features are supported, such as [tag resolution], [attribute tags], [class shorthands], and template expressions. This makes it so much easier to @@ -127,7 +129,7 @@ add complex content to your pages. [attribute tags]: https://markojs.com/docs/reference/language#attribute-tags [class shorthands]: https://markojs.com/docs/reference/language#shorthand-class-and-id -## Outline / Table of Contents +### Outline / Table of Contents You can use Markodown to write blogs and long documents, then extract a table of contents. This is done with two mechanisms. @@ -160,7 +162,7 @@ heading titles. ... ``` -## 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. @@ -180,7 +182,7 @@ meta: # ${meta.title} ``` -## Comments +### Comments Line, Block, and HTML comments work like they do in Marko/JavaScript. @@ -194,7 +196,7 @@ Text that is complete. // TODO: we gotta finish it! ``` -## Paragraph Detection +### 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 @@ -213,7 +215,7 @@ this paragraph gets wrapped in a `

` tag! ``` -## Static Statements +### Static Statements You can define module-level functions and variables, [same as you can in Marko](https://markojs.com/docs/reference/language#statements). @@ -239,3 +241,59 @@ ${"server"} components are a bad idea. (template literal) Though you can say import as long as it's not the first item. ``` + +## Config + +You can configure Markodown globally via arguments to the `transform` function. + +### Frontmatter Layout Configuration + +If frontmatter defines a `layout` property, is acts as a component import that +wraps the page. (This can also be configured globally with the `layoutImport` +property to `transform`). + +``` +--- +title: my amazing post +layout: ../layout.marko +--- + +## my document + +yap yap +``` + +In `layout.marko`, you can customize extensively how the document is formatted. + +```marko +import { Heading } from "@clo/markodown"; + +export interface Input { + content: Marko.Body; + + /** Markdown scans for headings (h1..h6) */ + outline: Heading[]; + /** This is the namespace import of the main document. + * You can reflect frontmatter, or do whatever with this. */ + module: Record; +} + +

+

${input.module.title ?? "Blog Post"}

+ + +<${input.content} /> +
+ +// Additionally, built-in components can be altered. +import CustomHeader from "./custom-header.marko"; +export const components = { + heading: CustomHeader, + // link, image, codeBlock, blockquote +}; +``` diff --git a/examples/hoj.mdo b/examples/hoj.mdo index 7bf40468f791a90f6a3a64b2f9f1e211c8263c03..566aea21c0d231c75c4616b097625f08d6a5988d 100644 --- a/examples/hoj.mdo +++ b/examples/hoj.mdo @@ -93,6 +93,7 @@ download for [all of the project files][files].

## mentions on the q&a + diff --git a/examples/slop.mdo b/examples/slop.mdo new file mode 100644 index 0000000000000000000000000000000000000000..1ae284909044668d59eea83f09fc387b9c4acb55 --- /dev/null +++ b/examples/slop.mdo @@ -0,0 +1,182 @@ +--- +// this file is entirely ai generated and is probably pure slop. +// i just think its funny. + +meta: + title: "IFRA 2026 Technical Digest" + description: >- + The authoritative racing-form analysis for interdimensional + ferret competitors, ratified by the council of seven. +theme: + bg: "#1a0033" + fg: "#f0e6ff" + primary: "#c084fc" + accent: "#f472b6" +--- +import "./ferret-digest.css"; +import RaceCard from "./race-card.marko"; +import Timeline from "./timeline.marko"; + +static const season = 2026; +static const topSpeed = 4.2; +server console.log("digest rendered for season", season); +client console.log("welcome to the digest, organic reader"); + +// m/s — disputed by the Pleiades bureau +// editorial: stats are unofficial until ratified by the IFRA tribunal + +
+ + +# IFRA ${season} Technical Digest + +*compiled by the editorial board · interdimensional ferret racing association* + +--- + +Welcome to the **${season} IFRA Technical Digest** — the most exhaustive +breakdown of ferret racing statistics ever assembled across 7 confirmed +dimensions and 3 disputed ones. All figures are current as of cycle 14. + +> "Speed is not a property of the ferret. +> Speed is a property of the *relationship between the ferret and the void.*" +> +> — Commissioner Bryndal, IFRA opening address, 2019 + +## Race Categories + +There are three primary formats, each demanding distinct physiological +and metaphysical qualities from the competitor: + +1. **Sprint** — 20 m, pure acceleration, zero dimensional portals allowed +2. **Obstacle Course** — 60 m, includes hedge mazes and one (1) sentient fog bank +3. **Cross-Dimensional Marathon** — distance undefined; time is non-linear here + +All competitors must register at `≤450g`. Biometric exemptions are handled +on a case-by-case basis by the tribunal of weights and measures. + +--- + +## Current Standings + + + +The season-${season} leaderboard is live. Top performers this cycle: + + + +### ${i + 1}. ${ferret.name} (${ferret.origin}) + +Personal best: **${ferret.pb}s** · Current form: *${ferret.form}* + + + + + + + + +Standings are **unavailable** pending tribunal ratification of the +Mirror-7 portal incident. Check back next cycle. + + + +--- + +## Speed Records by Dimension + +| Dimension | Record Holder | Time (s) | Portals Used | +|-----------|--------------|----------|-------------| +| Earth-Prime | Biscuit | 1.8 | 0 | +| Mirror-7 | Biscuit (reflected) | 1.7 | 2 | +| Dimension Ω | Crumble | 2.1 | 1 | +| The Beige Zone | unknown | — | unknown | + +The all-time record of **${topSpeed}m/s** was set during the 2022 Orion-6 +sprint, shortly after Biscuit consumed a small sandwich. The record remains +under review due to [unclear nutritional regulations][rule-12b]. + +[rule-12b]: /rules#12b + +--- + +## Track Conditions: Cycle 14 + + + +Conditions are nominal. Live telemetry excerpt from the Orion-6 sensor array: + +```json +{ + "track": "Orion-6", + "wind_ms": 0.03, + "humidity": 0.42, + "dimensional_stability": "mostly stable", + "sentient_fog_banks": 1, + "fog_bank_mood": "brooding" +} +``` + +Fog bank disposition is elevated — handlers should avoid direct eye contact. + + + + +Telemetry data is **temporarily offline** due to a dimensional calibration +event. Estimated restoration: 2–4 cycles. + + + +--- + +## Inline Controls + +Submit a competitor correction: + + + +Toggle dark mode: + +--- + +## Historical Timeline + + +<@event year=2019> +First interdimensional portal installed at the Orion-6 track. +Seventeen ferrets immediately ran directly into it. + +<@event year=2021> +The Beige Zone is discovered. Initial reports describe it as "vaguely unsettling". + +<@event year=2022> +Biscuit sets the all-time speed record. Sandwich still unaccounted for. + +<@event year=2025> +Mirror-7 portal incident. Details classified pending tribunal review. + +<@event year=season current=true> +Season ${season} underway. You are reading this in real time. + + + +--- + +## References & Further Reading + +- IFRA official records: +- Ferret aerodynamics paper: [On the Mustelid Boundary Layer][paper] +- Dimensional portal specifications: [IFRA Engineering Manual, Vol. 3][eng] +- Biscuit fan site: + +[paper]: https://arxiv.void/abs/2026.00042 +[eng]: /docs/engineering-manual-vol3 + +--- + + + +// document ends — next update: cycle 15 + +
diff --git a/src/component_transforms.rs b/src/component_transforms.rs index 91cd7bd126aeb7e723b2f750def1cb4161b94960..0397b6f2e5d0f7fa79d7949d52a31daa07b32c90 100644 --- a/src/component_transforms.rs +++ b/src/component_transforms.rs @@ -80,6 +80,139 @@ pub fn generate_imports(imports: &ComponentImports) -> String { result } +/// Which non-heading markdown element types are present in the document. +/// Used to emit only the necessary layout-component boilerplate. +#[derive(Debug, Default, Clone, Copy)] +pub struct UsedElements { + pub code_block: bool, + pub link: bool, + pub image: bool, + pub blockquote: bool, +} + +/// Scan the AST and return which element types are present. +pub fn detect_used_elements(node: &Node) -> UsedElements { + let mut used = UsedElements::default(); + detect_recursive(node, &mut used); + used +} + +fn detect_recursive(node: &Node, used: &mut UsedElements) { + if node + .cast::() + .is_some() + || node + .cast::() + .is_some() + { + used.code_block = true; + } + if node + .cast::() + .is_some() + { + used.link = true; + } + if node + .cast::() + .is_some() + { + used.image = true; + } + if node + .cast::() + .is_some() + { + used.blockquote = true; + } + for child in &node.children { + detect_recursive(child, used); + } +} + +/// Generate the Marko boilerplate that wires up layout-sourced components. +/// +/// Always emits the heading fallback `` + ``. +/// Conditionally emits entries for code block, link, image, and blockquote +/// based on which element types are actually present in the document. +/// +/// Fallbacks: +/// - heading: a `` that renders `` dynamically +/// - code block: a `` that renders `
`
+/// - link: the string `'a'` (Marko resolves string dynamic tags to HTML elements)
+/// - image: the string `'img'`
+/// - blockquote: the string `'blockquote'`
+pub fn generate_layout_boilerplate(used: &UsedElements) -> String {
+    let mut out = String::new();
+
+    // Heading — always present when a layout is active
+    out.push_str(concat!(
+        "\n",
+        "  <${'h' + level} ...attrs><${content}>\n",
+        "\n",
+        "\n",
+    ));
+
+    // Code block — fallback renders 

+    if used.code_block {
+        out.push_str(concat!(
+            "\n",
+            "  
<${content}/>
\n", + "\n", + "\n", + )); + } + + // Link — fallback is the HTML element name string 'a' + if used.link { + out.push_str("\n"); + } + + // Image — fallback is the HTML element name string 'img' + if used.image { + out.push_str("\n"); + } + + // Blockquote — fallback is the HTML element name string 'blockquote' + if used.blockquote { + out.push_str("\n"); + } + + out +} + +/// Transform heading elements (h1-h6) to use `HeadingComponent__markodown__`. +/// Called when a layout is active, so the layout can supply a heading component +/// via its `components.heading` export (with a built-in fallback). +/// Does not emit an import — the component is resolved at runtime via ``. +pub fn transform_headings(node: &mut Node) { + for child in &mut node.children { + transform_headings(child); + } + transform_heading(node); +} + +/// Transform non-heading elements present in `used` to use their layout-sourced +/// components. Must be called after outline extraction and heading transforms, +/// and only for element types not already handled by explicit `componentImports`. +pub fn transform_layout_components(node: &mut Node, used: &UsedElements) { + for child in &mut node.children { + transform_layout_components(child, used); + } + if used.code_block { + transform_code_block(node); + } + if used.link { + transform_link(node); + } + if used.image { + transform_image(node); + } + if used.blockquote { + transform_blockquote(node); + } +} + /// Transform all elements according to the component imports configuration. pub fn transform_components(node: &mut Node, imports: &ComponentImports) { // Process children first (bottom-up traversal) @@ -227,6 +360,10 @@ fn transform_heading(node: &mut Node) { // Create a new MarkoBlockComplete to replace this node let mut open = OpenOwned::from_tag_name(&heading_component()); + // Transfer id injected by outline extraction (via node.attrs) into the tag + if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") { + open.insert_id_attr(id); + } open.insert_attr(&format!("level={level}")); // We need to take ownership of children @@ -250,6 +387,10 @@ fn transform_heading(node: &mut Node) { let level = heading.level; let mut open = OpenOwned::from_tag_name(&heading_component()); + // Transfer id injected by outline extraction (via node.attrs) into the tag + if let Some((_, id)) = node.attrs.iter().find(|(k, _)| *k == "id") { + open.insert_id_attr(id); + } open.insert_attr(&format!("level={level}")); let children = std::mem::take(&mut node.children); diff --git a/src/lib.rs b/src/lib.rs index d8779b0fd740f083f73b4ebf9b1247f527b13c96..10f8d9efd583d61305bcaa23c13cee1c0a73c784 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,16 +71,24 @@ pub fn transform( ) -> Result> { // Pre-process to extract preamble (blank lines, imports) and frontmatter // This is needed because markdown-it skips blank lines before running block rules - let (preamble_output, remaining_source, preamble_offset) = + let (preamble_output, remaining_source, preamble_offset, frontmatter_layout) = match plugin::extract_preamble_and_frontmatter(source) { Ok(Some(result)) => { let offset = result.bytes_consumed; - (Some(result.output), &source[offset..], offset) + ( + Some(result.output), + &source[offset..], + offset, + result.layout_import, + ) } - Ok(None) => (None, source, 0), + Ok(None) => (None, source, 0, None), Err(e) => return Err(vec![e]), }; + // Frontmatter `layout` overrides the layout_import option + let layout_import = frontmatter_layout.or(layout_import); + let md = &mut markdown_it::MarkdownIt::new(); plugin::add_all(md, markdown_only, &clover_extensions); @@ -144,6 +152,38 @@ pub fn transform( None }; + // When a layout is active, source heading, code block, link, image, and blockquote + // components from the layout's `components` export (with built-in fallbacks). + // Detect which element types are actually present so we only emit necessary boilerplate. + // This must happen AFTER outline extraction so headings are still h1-h6 when collected. + let layout_used = if layout_import.is_some() { + let detected = component_transforms::detect_used_elements(&ast); + // Only take over types not already handled by an explicit componentImports entry + let used = component_transforms::UsedElements { + code_block: detected.code_block + && component_imports + .as_ref() + .map_or(true, |i| i.code_block.is_none()), + link: detected.link + && component_imports + .as_ref() + .map_or(true, |i| i.link.is_none()), + image: detected.image + && component_imports + .as_ref() + .map_or(true, |i| i.image.is_none()), + blockquote: detected.blockquote + && component_imports + .as_ref() + .map_or(true, |i| i.blockquote.is_none()), + }; + component_transforms::transform_headings(&mut ast); + component_transforms::transform_layout_components(&mut ast, &used); + Some(used) + } else { + 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 { @@ -152,12 +192,14 @@ pub fn transform( // Escape code blocks for Marko output to prevent ${...} from being interpreted as template expressions // - Block code (fenced/indented): only escaped if no custom code_block component is configured + // and not already transformed by the layout component path // - Inline code: always escaped since there's no custom component option for it if format == OutputFormat::Marko { - let escape_block = component_imports - .as_ref() - .map(|i| i.code_block.is_none()) - .unwrap_or(true); + let layout_took_code_block = layout_used.as_ref().map_or(false, |u| u.code_block); + let escape_block = !layout_took_code_block + && component_imports + .as_ref() + .map_or(true, |i| i.code_block.is_none()); component_transforms::escape_code_blocks_for_marko(&mut ast, escape_block); } @@ -179,19 +221,24 @@ pub fn transform( // Wrap with Layout component if layout_import is provided // Statements and defines must come before the Layout tag - if let (Some(layout_path), Some(result)) = (layout_import, outline_result) { + if let (Some(layout_path), Some(result), Some(used)) = + (layout_import, outline_result, layout_used) + { let outline_array = outline::format_outline_array(&result.headings); - // Add defines after other hoisted content + // Add heading defines after other hoisted content (statements etc.) hoisted.push_str(&result.defines); + // Boilerplate for sourcing components from the layout's exports, with fallbacks. + let boilerplate = component_transforms::generate_layout_boilerplate(&used); + if let Some(self_path) = self_import { text = format!( - "import Layout__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{hoisted}\n\n{text}" + "import Layout__markodown__ from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{boilerplate}\n{hoisted}\n\n{text}" ); } else { text = format!( - "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}\n\n{text}" + "import Layout__markodown__ from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\n{boilerplate}\n{hoisted}\n\n{text}" ); } } else { @@ -367,3 +414,702 @@ fn validate_marko_tags( pub fn err>>(str: T, offset: u32, length: usize) -> OxcDiagnostic { OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn run(source: &str) -> String { + transform(source, None, None, None, None, false, None) + .expect("transform failed") + .text + } + + fn run_with_layout(source: &str, layout: &str) -> String { + transform( + source, + None, + Some(layout.to_string()), + None, + None, + false, + None, + ) + .expect("transform failed") + .text + } + + fn run_with_layout_and_self(source: &str, layout: &str, self_path: &str) -> String { + transform( + source, + None, + Some(layout.to_string()), + None, + Some(self_path.to_string()), + false, + None, + ) + .expect("transform failed") + .text + } + + // ------------------------------------------------------------------------- + // Heading component boilerplate (layout active) + // ------------------------------------------------------------------------- + + #[test] + fn layout_emits_module_import() { + let out = run_with_layout("# Hello", "./layout.marko"); + assert!( + out.contains("import * as LayoutModule__markodown__ from \"./layout.marko\";"), + "missing LayoutModule import" + ); + } + + #[test] + fn layout_emits_heading_fallback_define() { + let out = run_with_layout("# Hello", "./layout.marko"); + assert!( + out.contains(""), + "missing HeadingComponent const" + ); + } + + #[test] + fn layout_boilerplate_before_layout_tag() { + let out = run_with_layout("# Hello", "./layout.marko"); + let boilerplate_pos = out + .find("HeadingComponentFallback__markodown__") + .expect("boilerplate not found"); + let layout_tag_pos = out + .find(" should not appear in the body (only inside the fallback define) + // the fallback define uses ${'h' + level}, not literal

+ assert!( + !out.contains("

") && !out.contains("Some heading", "./l.marko"); + assert!( + out.contains("#custom-id"), + "explicit id should be preserved on HeadingComponent tag" + ); + assert!(out.contains("level=2"), "level attr should be added"); + } + + #[test] + fn heading_content_hoisted_into_define() { + let out = run_with_layout("# **bold** heading", "./l.marko"); + assert!( + out.contains(""), + "heading content should be hoisted into a define" + ); + assert!( + out.contains("bold"), + "rendered content should appear in the define block" + ); + } + + #[test] + fn heading_content_ref_used_in_outline_array() { + let out = run_with_layout("# Hello", "./l.marko"); + assert!( + out.contains("content: Heading_1__markodown__"), + "outline array should reference the define component" + ); + } + + // ------------------------------------------------------------------------- + // Setext headings (underline style) with layout + // ------------------------------------------------------------------------- + + #[test] + fn setext_h1_wrapped_in_component() { + // Setext h1: underlined with === + let out = run_with_layout("Title\n=====", "./l.marko"); + assert!( + out.contains("level=1"), + "setext h1 should get level=1 on HeadingComponent" + ); + assert!( + out.contains("\ncontent\n

) with layout + // ------------------------------------------------------------------------- + + #[test] + fn multiline_marko_heading_with_layout() { + //

with content on next line (MarkoOpen + MarkoClose in AST) + let out = run_with_layout("

\nsome content\n

", "./l.marko"); + assert!( + out.contains("level=3"), + "multi-line

should get level=3" + ); + assert!( + out.contains(" should be renamed to HeadingComponent" + ); + // The named close tag

should become — not leave a stray + assert!( + !out.contains(""), + "named close tag should be converted to " + ); + } + + // ------------------------------------------------------------------------- + // Duplicate heading IDs through layout + // ------------------------------------------------------------------------- + + #[test] + fn duplicate_heading_ids_deduplicated_in_outline() { + let out = run_with_layout("## Intro\n\n## Intro\n\n## Intro", "./l.marko"); + assert!( + out.contains("id: 'intro'"), + "first occurrence uses base slug" + ); + assert!( + out.contains("id: 'intro-1'"), + "second occurrence gets -1 suffix" + ); + assert!( + out.contains("id: 'intro-2'"), + "third occurrence gets -2 suffix" + ); + } + + #[test] + fn duplicate_heading_ids_deduplicated_on_component_tags() { + let out = run_with_layout("## Intro\n\n## Intro", "./l.marko"); + assert!( + out.contains("#intro ") || out.contains("#intro level"), + "first heading should have #intro" + ); + assert!( + out.contains("#intro-1"), + "second heading should have #intro-1" + ); + } + + // ------------------------------------------------------------------------- + // Non-string `layout` value is silently ignored + // ------------------------------------------------------------------------- + + #[test] + fn non_string_layout_value_does_not_activate_layout() { + // layout: 42 should be ignored (not a string path) + let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext"; + let out = run(source); + assert!( + !out.contains("Layout__markodown__"), + "non-string layout value must not activate layout wrapping" + ); + } + + #[test] + fn non_string_layout_value_not_exported() { + // The `layout` key should be consumed and not appear as an export, + // even when it's a non-string type + let source = "---\nlayout: 42\ntitle: Hi\n---\n\ntext"; + let out = run(source); + assert!( + !out.contains("export const layout"), + "layout key must never be exported regardless of its value type" + ); + assert!( + out.contains("export const title"), + "other fields should still export" + ); + } + + // ------------------------------------------------------------------------- + // Frontmatter with only the layout key (no other exports) + // ------------------------------------------------------------------------- + + #[test] + fn layout_only_frontmatter_produces_no_extra_exports() { + let source = "---\nlayout: ./l.marko\n---\n\ntext"; + let out = run(source); + assert!( + !out.contains("export const"), + "layout-only frontmatter should produce no export statements" + ); + assert!( + out.contains("Layout__markodown__"), + "layout wrapping should still activate" + ); + } + + // ------------------------------------------------------------------------- + // layout + componentImports.heading conflict + // ------------------------------------------------------------------------- + + #[test] + fn layout_and_component_imports_heading_both_define_same_name() { + // When both layoutImport and componentImports.heading are set, they both + // try to define `HeadingComponent__markodown__` — one via and one + // via import. This pins the current behavior so any change is deliberate. + let out = transform( + "# Hello", + None, + Some("./l.marko".to_string()), + Some(ComponentImports { + heading: Some("./h.marko".to_string()), + ..Default::default() + }), + None, + false, + None, + ) + .unwrap() + .text; + // Both definitions appear — this is a known conflict. + // The from the layout boilerplate wins at runtime in Marko + // because it appears before the import in the rendered output. + assert!( + out.contains("HeadingComponent__markodown__"), + "HeadingComponent name must appear" + ); + assert!( + out.contains("import HeadingComponent__markodown__ from \"./h.marko\""), + "explicit heading import should appear" + ); + assert!( + out.contains(""), + "fenced code block should be replaced with CodeBlockComponent" + ); + } + + #[test] + fn layout_code_block_without_language() { + let out = run_with_layout("```\nsome code\n```", "./l.marko"); + assert!( + out.contains(""), + "code block with no language should still use CodeBlockComponent (no language attr)" + ); + } + + #[test] + fn layout_code_block_content_escaped_as_template_literal() { + // Code content with ${...} must be escaped so Marko doesn't interpret it + let out = run_with_layout("```\n${danger}\n```", "./l.marko"); + assert!( + out.contains("\\${"), + "template expression in code content must be escaped" + ); + } + + #[test] + fn no_code_block_omits_code_block_boilerplate() { + let out = run_with_layout("# Hello\n\nJust prose.", "./l.marko"); + assert!( + !out.contains("CodeBlockComponent"), + "code block boilerplate must be absent when no code block in document" + ); + } + + // ------------------------------------------------------------------------- + // Layout link component + // ------------------------------------------------------------------------- + + #[test] + fn layout_link_emits_const_with_string_fallback() { + let out = run_with_layout("[text](https://example.com)", "./l.marko"); + assert!( + out.contains("LayoutModule__markodown__.components?.link ?? 'a'"), + "link const must use 'a' as fallback" + ); + // no needed for link — the string 'a' is the fallback + assert!( + !out.contains("LinkComponentFallback"), + "link must not emit a fallback define" + ); + } + + #[test] + fn layout_link_uses_component_in_body() { + let out = run_with_layout("[click here](https://example.com)", "./l.marko"); + assert!( + out.contains(" some quote", "./l.marko"); + assert!( + out.contains("LayoutModule__markodown__.components?.blockquote ?? 'blockquote'"), + "blockquote const must use 'blockquote' as fallback" + ); + assert!( + !out.contains("BlockquoteComponentFallback"), + "blockquote must not emit a fallback define" + ); + } + + #[test] + fn layout_blockquote_uses_component_in_body() { + let out = run_with_layout("> some quote", "./l.marko"); + assert!( + out.contains(" String { } } -/// Convert YAML mapping to JavaScript export statements -fn yaml_to_exports(value: &serde_yml::Value) -> Result { +/// Convert YAML mapping to JavaScript export statements. +/// Keys listed in `skip_keys` are omitted from the output. +fn yaml_to_exports(value: &serde_yml::Value, skip_keys: &[&str]) -> Result { let map = match value { serde_yml::Value::Mapping(m) => m, _ => { @@ -52,6 +53,10 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result { _ => return Err(format!("Frontmatter keys must be strings, got: {key:?}")), }; + if skip_keys.contains(&key_str.as_str()) { + continue; + } + if matches!(val, serde_yml::Value::Tagged(_)) { return Err("YAML tags are not supported in markodown frontmatter".to_string()); } @@ -65,12 +70,25 @@ fn yaml_to_exports(value: &serde_yml::Value) -> Result { Ok(exports.join("\n")) } +/// Extract the `layout` string value from a YAML mapping, if present. +fn extract_layout_key(value: &serde_yml::Value) -> Option { + let map = value.as_mapping()?; + let key = serde_yml::Value::String("layout".to_string()); + match map.get(&key)? { + serde_yml::Value::String(s) => Some(s.clone()), + _ => None, + } +} + /// Result of extracting preamble (imports/blank lines) and frontmatter from source pub struct PreambleResult { /// The generated output (imports + exports from frontmatter) pub output: String, /// Number of bytes consumed from the source pub bytes_consumed: usize, + /// The `layout` key from frontmatter, if present. This overrides the + /// `layoutImport` transform option for this document. + pub layout_import: Option, } /// Extract leading blank lines, import statements, and frontmatter from source. @@ -115,8 +133,13 @@ pub fn extract_preamble_and_frontmatter( // Parse YAML and convert to exports let yaml = strip_js_comments(&yaml_lines.join("\n")); - let exports = match serde_yml::from_str::(&yaml) { - Ok(value) => yaml_to_exports(&value).map_err(OxcDiagnostic::error)?, + let (exports, layout_import) = match serde_yml::from_str::(&yaml) { + Ok(value) => { + // Extract the `layout` key before converting to exports + let layout = extract_layout_key(&value); + let exports = yaml_to_exports(&value, &["layout"]).map_err(OxcDiagnostic::error)?; + (exports, layout) + } Err(e) => { let offset = e .location() @@ -160,9 +183,109 @@ pub fn extract_preamble_and_frontmatter( Ok(Some(PreambleResult { output, bytes_consumed, + layout_import, })) } +#[cfg(test)] +mod tests { + use super::extract_preamble_and_frontmatter; + use crate::transform; + + #[test] + fn test_frontmatter_layout_extracted_as_layout_import() { + let source = "---\nlayout: ./my-layout.marko\ntitle: Hello\n---\n\n# Hi"; + let result = extract_preamble_and_frontmatter(source) + .expect("no error") + .expect("some result"); + assert_eq!( + result.layout_import.as_deref(), + Some("./my-layout.marko"), + "layout key should be extracted" + ); + // `layout` must not appear as an export + assert!( + !result.output.contains("export const layout"), + "layout should not be exported" + ); + // other keys are still exported + assert!( + result.output.contains("export const title"), + "title should still be exported" + ); + } + + #[test] + fn test_frontmatter_without_layout_gives_none() { + let source = "---\ntitle: Hello\n---\n\n# Hi"; + let result = extract_preamble_and_frontmatter(source) + .expect("no error") + .expect("some result"); + assert!( + result.layout_import.is_none(), + "no layout_import when key absent" + ); + } + + #[test] + fn test_frontmatter_layout_overrides_option() { + let source = "---\nlayout: ./frontmatter-layout.marko\n---\n\n# Hello"; + let output = transform( + source, + None, + Some("./option-layout.marko".to_string()), + None, + None, + false, + None, + ) + .expect("transform succeeded"); + // frontmatter layout wins over the option + assert!( + output.text.contains("frontmatter-layout.marko"), + "frontmatter layout should be used" + ); + assert!( + !output.text.contains("option-layout.marko"), + "option layout should be overridden" + ); + } + + #[test] + fn test_frontmatter_layout_used_when_no_option() { + let source = "---\nlayout: ./my-layout.marko\n---\n\n# Hello"; + let output = + transform(source, None, None, None, None, false, None).expect("transform succeeded"); + assert!( + output.text.contains("my-layout.marko"), + "frontmatter layout should be applied" + ); + assert!( + output.text.contains("Layout__markodown__"), + "layout wrapper should be emitted" + ); + } + + #[test] + fn test_option_layout_used_when_no_frontmatter_layout() { + let source = "---\ntitle: Hello\n---\n\n# Hello"; + let output = transform( + source, + None, + Some("./option-layout.marko".to_string()), + None, + None, + false, + None, + ) + .expect("transform succeeded"); + assert!( + output.text.contains("option-layout.marko"), + "option layout should still work" + ); + } +} + impl BlockRule for Rule { fn run(state: &mut BlockState) -> Option<(Node, usize)> { // Only at the start of the document (no preamble case - handled by preprocessing) @@ -194,7 +317,7 @@ impl BlockRule for Rule { // Parse YAML and convert to exports let yaml = strip_js_comments(&lines.join("\n")); let exports = match serde_yml::from_str::(&yaml) { - Ok(value) => match yaml_to_exports(&value) { + Ok(value) => match yaml_to_exports(&value, &["layout"]) { Ok(js) => js, Err(msg) => { return Some(( diff --git a/src/plugin/statement.rs b/src/plugin/statement.rs index bc4c781470f478fda76f9fb2a2dd30cf91b349a4..142cd27466454cc1e26449abf89c4b8f3d56fecf 100644 --- a/src/plugin/statement.rs +++ b/src/plugin/statement.rs @@ -40,8 +40,16 @@ impl BlockRule for Rule { let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..]; + // Statements never span blank lines. Limit the source passed to the TypeScript + // parser to avoid it trying to parse subsequent Markdown/Marko content as TS. + let limited_end = unbounded_src + .find("\n\n") + .map(|p| p + 1) // include the newline that ends the statement + .unwrap_or(unbounded_src.len()); + let limited_src = &unbounded_src[..limited_end]; + let statement_end = - match scan_first_statement_forbid_trailing(&unbounded_src[keyword_trim as usize..]) { + match scan_first_statement_forbid_trailing(&limited_src[keyword_trim as usize..]) { Ok(ok) => ok, Err(err) => { return Some(( diff --git a/tests/fixtures.rs b/tests/fixtures.rs index a8fc30c3a29bf169638ed63f7496956bc6a647fd..03713eac613d5f0bd8bf38a4e4f331740f5504cf 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -236,3 +236,50 @@ fn fixture_25_code_block_import() { fn fixture_26_code_block_import() { run_fixture_with_options("26-code-block-import", None, None, None); } + +#[test] +fn fixture_27_frontmatter_layout() { + // layout key in frontmatter activates layout wrapping without a layoutImport option + run_fixture("27-frontmatter-layout"); +} + +#[test] +fn fixture_28_outline_self_import() { + // layout + selfImport produces module=self__markodown__ and the extra import + run_fixture_with_options( + "28-outline-self-import", + Some("./layout.marko"), + None, + Some("./self.marko"), + ); +} + +#[test] +fn fixture_29_layout_all_components() { + // layout emits boilerplate for all element types present in the document + run_fixture_with_options( + "29-layout-all-components", + Some("./layout.marko"), + None, + None, + ); +} + +#[test] +fn fixture_30_layout_selective_components() { + // only element types actually used in the document get boilerplate — others are omitted + run_fixture_with_options( + "30-layout-selective-components", + Some("./layout.marko"), + None, + None, + ); +} + +#[test] +fn fixture_31_import_with_markdown_content() { + // import statement followed (after blank line) by Marko tags with Markdown inline content + // regression: statement rule was passing unbounded source to OXC which tried to parse + // markdown link syntax [text](url) as a TypeScript array expression + run_fixture("31-import-with-markdown-content"); +} diff --git a/tests/fixtures/23-outline-extracting.marko b/tests/fixtures/23-outline-extracting.marko index af3a3a51e6cf2b2c4d34dee376b51de9e8488c60..8d815d7efec851621fe5d1f0e579daf29b4854a8 100644 --- a/tests/fixtures/23-outline-extracting.marko +++ b/tests/fixtures/23-outline-extracting.marko @@ -1,4 +1,10 @@ import Layout__markodown__ from "./layout.marko"; +import * as LayoutModule__markodown__ from "./layout.marko"; + + <${'h' + level} ...attrs><${content}> + + + good morning @@ -18,12 +24,12 @@ rain time { level: 3, id: 'snow-time', content: Heading_3__markodown__ }, { level: 4, id: 'id', content: Heading_4__markodown__ }, ]> - - -

-

-

-

- + + + + + + + diff --git a/tests/fixtures/27-frontmatter-layout.marko b/tests/fixtures/27-frontmatter-layout.marko new file mode 100644 index 0000000000000000000000000000000000000000..6b3ba25886b7f52964dd800bfbf6372c01721675 --- /dev/null +++ b/tests/fixtures/27-frontmatter-layout.marko @@ -0,0 +1,20 @@ +import Layout__markodown__ from "./page-layout.marko"; +import * as LayoutModule__markodown__ from "./page-layout.marko"; + + <${'h' + level} ...attrs><${content}> + + + +export const title = "Hello World"; + + +${title} + + + + + +

Some content here.

+ diff --git a/tests/fixtures/27-frontmatter-layout.mdo b/tests/fixtures/27-frontmatter-layout.mdo new file mode 100644 index 0000000000000000000000000000000000000000..99b9a11fe092f1d19cf2bb08da3887f0a3e89e49 --- /dev/null +++ b/tests/fixtures/27-frontmatter-layout.mdo @@ -0,0 +1,8 @@ +--- +layout: ./page-layout.marko +title: Hello World +--- + +# ${title} + +Some content here. diff --git a/tests/fixtures/28-outline-self-import.marko b/tests/fixtures/28-outline-self-import.marko new file mode 100644 index 0000000000000000000000000000000000000000..be52fe1be55dce493aaa9134340b7a66bcb6a7d8 --- /dev/null +++ b/tests/fixtures/28-outline-self-import.marko @@ -0,0 +1,25 @@ +import Layout__markodown__ from "./layout.marko"; +import * as LayoutModule__markodown__ from "./layout.marko"; +import * as self__markodown__ from "./self.marko"; + + <${'h' + level} ...attrs><${content}> + + + + +Introduction + + +Details + + + + + +

Some text.

+ + + diff --git a/tests/fixtures/28-outline-self-import.mdo b/tests/fixtures/28-outline-self-import.mdo new file mode 100644 index 0000000000000000000000000000000000000000..de742d0a703d6b2beea2a31937e2e45fabbab298 --- /dev/null +++ b/tests/fixtures/28-outline-self-import.mdo @@ -0,0 +1,5 @@ +## Introduction + +Some text. + +### Details diff --git a/tests/fixtures/29-layout-all-components.marko b/tests/fixtures/29-layout-all-components.marko new file mode 100644 index 0000000000000000000000000000000000000000..e820151fa1d0a8ab0bc759022f91ec40ae780de4 --- /dev/null +++ b/tests/fixtures/29-layout-all-components.marko @@ -0,0 +1,31 @@ +import Layout__markodown__ from "./layout.marko"; +import * as LayoutModule__markodown__ from "./layout.marko"; + + <${'h' + level} ...attrs><${content}> + + + +
<${content}/>
+ + + + + + + +Hello + + + + + +

A link and an .

+ +

A blockquote.

+ + +${"const x = 1;\n" } + + diff --git a/tests/fixtures/29-layout-all-components.mdo b/tests/fixtures/29-layout-all-components.mdo new file mode 100644 index 0000000000000000000000000000000000000000..a161677092a0a283c50d020102eda59c355b2971 --- /dev/null +++ b/tests/fixtures/29-layout-all-components.mdo @@ -0,0 +1,9 @@ +# Hello + +A [link](https://example.com) and an ![image](./photo.png "caption"). + +> A blockquote. + +```ts +const x = 1; +``` diff --git a/tests/fixtures/30-layout-selective-components.marko b/tests/fixtures/30-layout-selective-components.marko new file mode 100644 index 0000000000000000000000000000000000000000..e2a380c902b695f20203117ccb6c662b5e99f4cb --- /dev/null +++ b/tests/fixtures/30-layout-selective-components.marko @@ -0,0 +1,19 @@ +import Layout__markodown__ from "./layout.marko"; +import * as LayoutModule__markodown__ from "./layout.marko"; + + <${'h' + level} ...attrs><${content}> + + + + + +Title + + + + + +

A link.

+ diff --git a/tests/fixtures/30-layout-selective-components.mdo b/tests/fixtures/30-layout-selective-components.mdo new file mode 100644 index 0000000000000000000000000000000000000000..bf74cb504ef8309bdb6e0f1a98400c8ceb5042ad --- /dev/null +++ b/tests/fixtures/30-layout-selective-components.mdo @@ -0,0 +1,3 @@ +# Title + +A [link](https://example.com). diff --git a/tests/fixtures/31-import-with-markdown-content.marko b/tests/fixtures/31-import-with-markdown-content.marko new file mode 100644 index 0000000000000000000000000000000000000000..129d6dac70c8e06c03848b84c1b01590744295f4 --- /dev/null +++ b/tests/fixtures/31-import-with-markdown-content.marko @@ -0,0 +1,4 @@ +import "./style.css"; +
+ +
diff --git a/tests/fixtures/31-import-with-markdown-content.mdo b/tests/fixtures/31-import-with-markdown-content.mdo new file mode 100644 index 0000000000000000000000000000000000000000..fccdebcb4862c1ca78c373edf6d8b03052fbe812 --- /dev/null +++ b/tests/fixtures/31-import-with-markdown-content.mdo @@ -0,0 +1,5 @@ +import "./style.css"; + +
+ +
diff --git a/wtf.mdo b/wtf.mdo deleted file mode 100644 index 4e5e26e1e31c7b32f46a34d5e6563f1f1aa6f73f..0000000000000000000000000000000000000000 --- a/wtf.mdo +++ /dev/null @@ -1,5 +0,0 @@ -i thought this bug was fixed - -

aaa mmmm - -wtf!! -- 2.54.0