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