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