From f6677db2d5545e8d8b2ebc12ee2bdef114192798 Mon Sep 17 00:00:00 2001
From: clover caruso
Date: Mon, 16 Feb 2026 00:51:41 -0800
Subject: [PATCH] feat: clover extensions
closes #1
---
ARCHITECTURE.md | 3 +-
examples/marko-run/src/md/example.mdo | 4 +-
examples/marko-run/src/routes/+layout.marko | 3 -
.../marko-run/src/tags/markdown-layout.marko | 11 +-
examples/marko-run/src/tags/photo-grid.marko | 2 +-
lib/clover.test.ts | 322 ++++++++++++++++++
lib/esbuild.ts | 3 +-
lib/mod.test.ts | 8 +-
lib/mod.ts | 82 ++++-
lib/rollup.ts | 7 +-
src/component_transforms.rs | 2 +-
src/lib.rs | 28 +-
src/main.rs | 14 +-
src/plugin/clover.rs | 306 +++++++++++++++++
src/plugin/mod.rs | 34 +-
src/wasm.rs | 14 +-
tests/fixtures.rs | 4 +
tests/fixtures/23-outline-extracting.marko | 1 +
18 files changed, 801 insertions(+), 47 deletions(-)
create mode 100644 lib/clover.test.ts
create mode 100644 src/plugin/clover.rs
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6f19e4c724016c186f97f648b9ca723127baa718..c780f326f73b383b9984d4332bdb7ee55ccd9437 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -33,7 +33,8 @@ these rules are configured in `plugin/mod.rs` in `add_all`.
## `lib/`: javascript package
published to jsr and then to npm. this is a thin wrapper on top of wasm-bindgen,
-with plugins for the web ecosystem!
+with plugins for the web ecosystem! you can build the wasm code with
+`sh wasm.sh`.
- `@clo/markodown` - primary import (`lib/mod.ts`)
- `@clo/markodown/rollup.ts` - rollup or vite plugin (`lib/rollup.ts`)
diff --git a/examples/marko-run/src/md/example.mdo b/examples/marko-run/src/md/example.mdo
index 52c8c2af67656b0afc78d7114d784a8ac81cfa96..f3d0bff0d939918549bca72d255d23e2af5bc56d 100644
--- a/examples/marko-run/src/md/example.mdo
+++ b/examples/marko-run/src/md/example.mdo
@@ -14,7 +14,7 @@ function doMyFavoriteThing() {
}
```
-## photos of my favorite people
+## photos of my *favorite people*
i love using Marko components because they're extremely concise to write.
a lot less brace hell for non-string attributes, and more treats!
@@ -28,6 +28,8 @@ a lot less brace hell for non-string attributes, and more treats!
<@img src="IMG_4838.jpeg" w=2 />
<@img src="IMG_4839.jpeg" w=2 align="top" />
<@img src="IMG_4833.jpeg" h=2 />
+ <@img src="IMG_4832.jpeg" />
+ <@img src="IMG_4833.jpeg" />
>
## in conclusion
diff --git a/examples/marko-run/src/routes/+layout.marko b/examples/marko-run/src/routes/+layout.marko
index 51271ca871ff7f6f5a4aeb74fd6ea96db90a3647..8a419ddb6358a9307168293c768b1bee5dc0cc2d 100644
--- a/examples/marko-run/src/routes/+layout.marko
+++ b/examples/marko-run/src/routes/+layout.marko
@@ -21,9 +21,6 @@
color: #fff;
background: #15151e;
}
- code {
- color: #fc0;
- }
a {
color: #09c;
}
diff --git a/examples/marko-run/src/tags/markdown-layout.marko b/examples/marko-run/src/tags/markdown-layout.marko
index 1ff7b10a1d9311ae61cf310f8e752b3b1dbfe433..12a2b4afdaf29c5086c75eab81a369f6711f7493 100644
--- a/examples/marko-run/src/tags/markdown-layout.marko
+++ b/examples/marko-run/src/tags/markdown-layout.marko
@@ -1,7 +1,7 @@
export interface Header {
level: 1 | 2 | 3 | 4 | 5 | 6;
id: string;
- html: string;
+ content: Marko.Body;
}
export interface Input {
@@ -34,7 +34,7 @@ export interface Input {
>
- ${JSON.stringify(header)}
+ <${header.content}/>
@@ -48,7 +48,7 @@ export interface Input {
}
.markdown-layout > h1 {
- margin: 0 0 2rem 0;
+ margin: 0;
font-size: 2.5rem;
font-weight: 700;
}
@@ -110,4 +110,9 @@ export interface Input {
position: static;
}
}
+
+ .markdown-layout pre {
+ background-color: black;
+ padding: 1rem;
+ }
\ No newline at end of file
diff --git a/examples/marko-run/src/tags/photo-grid.marko b/examples/marko-run/src/tags/photo-grid.marko
index fdbab98b76e27e3e2770e7db4dd8c276b98049df..f9b0d9b81fa80fdbb82bff4013921299dcc7e04e 100644
--- a/examples/marko-run/src/tags/photo-grid.marko
+++ b/examples/marko-run/src/tags/photo-grid.marko
@@ -35,7 +35,7 @@ export interface Input {
if (x >= cols) {
x = 0;
- y += h;
+ y += 1;
}
}
return result;
diff --git a/lib/clover.test.ts b/lib/clover.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..310f02b6077d060f61cdb520a67f674459c3cfdd
--- /dev/null
+++ b/lib/clover.test.ts
@@ -0,0 +1,322 @@
+import { describe, it, expect } from "vitest";
+import { transform, CloverQuestionExtensions } from "./mod.ts";
+
+const cloverExtensions: CloverQuestionExtensions = {
+ question: "q-block",
+ artifactRef: "artifact-ref",
+ questionRef: "question-ref",
+ labelledRedaction: "redacted",
+};
+
+describe("clover extensions", () => {
+ describe("question blocks", () => {
+ it("transforms q: prefix to question element", () => {
+ const result = transform({
+ source: "q: what is your favorite color?",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("");
+ expect(result.text).toContain("what is your favorite color?");
+ expect(result.text).toContain("");
+ }
+ });
+
+ it("adds br between consecutive question lines", () => {
+ const result = transform({
+ source: "q: first question\nq: second question",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("
");
+ expect(result.text).toContain("first question");
+ expect(result.text).toContain("second question");
+ }
+ });
+
+ it("does not add br for non-consecutive questions", () => {
+ const result = transform({
+ source: "q: first question\n\nsome text\n\nq: second question",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).not.toContain("
");
+ }
+ });
+
+ it("parses inline markdown in questions", () => {
+ const result = transform({
+ source: "q: what about **bold** text?",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("bold");
+ }
+ });
+ });
+
+ describe("@html blocks", () => {
+ it("passes through raw HTML", () => {
+ const result = transform({
+ source: '@html content',
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ 'content'
+ );
+ }
+ });
+
+ it("does not wrap @html in paragraph", () => {
+ const result = transform({
+ source: "@html raw
",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).not.toContain("");
+ expect(result.text).toContain("
raw
");
+ }
+ });
+ });
+
+ describe("artifact refs", () => {
+ it("transforms @slug to artifact ref element", () => {
+ const result = transform({
+ source: "check out @its-snowing for details",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ "its-snowing"
+ );
+ }
+ });
+
+ it("handles multiple artifact refs", () => {
+ const result = transform({
+ source: "see @first-one and @second-one",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("first-one");
+ expect(result.text).toContain(
+ "second-one"
+ );
+ }
+ });
+
+ it("only matches lowercase letters, digits, and hyphens", () => {
+ const result = transform({
+ source: "@valid-slug123 but @INVALID stays",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ "valid-slug123"
+ );
+ expect(result.text).toContain("@INVALID");
+ expect(result.text).not.toContain("INVALID");
+ }
+ });
+
+ it("does not match @html as artifact ref", () => {
+ const result = transform({
+ source: "@html test
",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).not.toContain("html");
+ }
+ });
+ });
+
+ describe("question refs", () => {
+ it("transforms 10-digit refs", () => {
+ const result = transform({
+ source: "see #2602142011 for more",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ "2602142011"
+ );
+ }
+ });
+
+ it("transforms 12-digit refs", () => {
+ const result = transform({
+ source: "see #260214201112 for more",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ "260214201112"
+ );
+ }
+ });
+
+ it("does not match other digit counts", () => {
+ const result = transform({
+ source: "#123456789 and #1234567890123",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ // 9 digits - not matched
+ expect(result.text).toContain("#123456789");
+ expect(result.text).not.toContain("123456789");
+ // 13 digits - not matched
+ expect(result.text).toContain("#1234567890123");
+ }
+ });
+
+ it("handles multiple question refs", () => {
+ const result = transform({
+ source: "compare #2602142011 with #2602142012",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain(
+ "2602142011"
+ );
+ expect(result.text).toContain(
+ "2602142012"
+ );
+ }
+ });
+ });
+
+ describe("labelled redactions", () => {
+ it("transforms ##label## to redaction element", () => {
+ const result = transform({
+ source: "the ##secret name## is hidden",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("secret name");
+ }
+ });
+
+ it("handles hyphens and underscores in labels", () => {
+ const result = transform({
+ source: "##my-secret_label##",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("my-secret_label");
+ }
+ });
+
+ it("does not match empty labels", () => {
+ const result = transform({
+ source: "text #### more",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).not.toContain("");
+ // #### in the middle of text stays as-is
+ expect(result.text).toContain("####");
+ }
+ });
+
+ it("handles multiple redactions", () => {
+ const result = transform({
+ source: "##first## and ##second##",
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("first");
+ expect(result.text).toContain("second");
+ }
+ });
+ });
+
+ describe("combined usage", () => {
+ it("handles all extensions in one document", () => {
+ const result = transform({
+ source: `q: what is @some-artifact about?
+q: also see #2602142011
+
+The ##secret## is revealed.
+
+@html
`,
+ markdownOnly: true,
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("");
+ expect(result.text).toContain("some-artifact");
+ expect(result.text).toContain("2602142011");
+ expect(result.text).toContain("secret");
+ expect(result.text).toContain('
');
+ expect(result.text).toContain("
"); // between consecutive q: lines
+ }
+ });
+
+ it("works with markdownOnly: false (marko mode)", () => {
+ const result = transform({
+ source: "q: question with @artifact",
+ cloverExtensions,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("");
+ expect(result.text).toContain("artifact");
+ }
+ });
+ });
+
+ describe("without cloverExtensions", () => {
+ it("does not parse clover syntax when extensions not provided", () => {
+ const result = transform({
+ source: "q: not a question\n@not-artifact\n#2602142011\n##not-redacted##",
+ markdownOnly: true,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.text).toContain("q: not a question");
+ expect(result.text).not.toContain("");
+ expect(result.text).not.toContain("");
+ expect(result.text).not.toContain("");
+ expect(result.text).not.toContain("");
+ }
+ });
+ });
+});
diff --git a/lib/esbuild.ts b/lib/esbuild.ts
index 46f2a48f5ce884f0478b9fc905b5d3ba90574dcf..51b81f4825b9c2c1099b8cd805863c7f2ed74587 100644
--- a/lib/esbuild.ts
+++ b/lib/esbuild.ts
@@ -44,6 +44,7 @@ export default function esbuildPlugin(
const result = markodown.transform({
...options,
source: await fs.readFile(file, "utf-8"),
+ selfImport: "./" + path.basename(file),
});
if (!result.success) {
return {
@@ -68,7 +69,7 @@ export default function esbuildPlugin(
// TODO: source maps!
// TODO: parse marko errors
- const { code, meta } = await marko.compile(
+ const { code, meta } = await compile(
result.text,
file + ".marko",
options?.markoOptions ?? {},
diff --git a/lib/mod.test.ts b/lib/mod.test.ts
index 5d86603c2a00eac30d22772439a4a1ff16759d8d..d88f6fff135d0cc89409ebc6531965bef6dfb3c4 100644
--- a/lib/mod.test.ts
+++ b/lib/mod.test.ts
@@ -39,7 +39,7 @@ describe("transform", () => {
const error = result.errors[0];
expect(error.message).toContain("Unclosed tag");
expect(error.line).toBe(1);
- expect(error.column).toBe(2); // after '<'
+ expect(error.column).toBe(1); // at '<'
}
});
@@ -117,9 +117,9 @@ describe("transform", () => {
if (!result.success) {
const error = result.errors[0];
expect(error.labels.length).toBe(2);
- // "div" has width 3
- expect(error.labels[0].width).toBe(3);
- // "span" has width 4
+ // ";
/** Wraps the component in another component. Enables Table of Contents generation */
layoutImport?: string;
+ /** Import path for the module itself, passed to the layout as `module` */
+ selfImport?: string;
/** Replace built-in elements with custom components */
componentImports?: ComponentImports;
+ /**
+ * When true, disables all Marko extensions, turning this into a pure
+ * Markdown parser. Marko tags, template expressions, statements, and
+ * comments will be treated as plain text. Defaults to HTML output.
+ * @default false
+ */
+ markdownOnly?: boolean;
+ /**
+ * These extensions are special-cased so that Clover can re-use this on
+ * her website without
+ */
+ cloverExtensions?: CloverQuestionExtensions;
+}
+
+/** Replace Built In Elements */
+export interface ComponentImports {
+ /** Replace (h1-h6) with this import. Receives attribute `level: number`. */
+ heading?: string;
+ /** Replace `pre > code` with this import. */
+ codeBlock?: string;
+ /** Replace markdown links with this import. */
+ link?: string;
+ /** Replace images with this import. */
+ image?: string;
+ /** Replace blockquotes with this import. */
+ blockquote?: string;
+}
+
+/**
+ * Clover's question extensions are syntax features used on the years of backlog
+ * from https://paperclover.net/q+a. It was easier to re-implement these than
+ * convert everything into Marko. Besides, there are some extra things that
+ * make it so these must emit HTML and not Marko, so these components all
+ * emit custom HTML elements instead of imported components.
+ *
+ * Also includes `@html
` block syntax for raw HTML passthrough.
+ */
+export interface CloverQuestionExtensions {
+ /**
+ * Element name for question blocks. Not an import path.
+ * `q: ...inline...` -> `...`
+ *
+ * `q: ...inline...\nq: ...inline...`
+ * ^ this inserts a `
` between them since theyre stuck together.
+ */
+ question: string;
+ /**
+ * Element name for artifact ref. Not an import path.
+ * `@its-snowing` -> `its-snowing`
+ */
+ artifactRef: string;
+ /**
+ * Element name for question ref. Not an import path.
+ * `#2602142011` -> `2602142011`
+ *
+ * Question refs are 10 or 12 numbers in a row.
+ */
+ questionRef: string;
+ /**
+ * Element name for Labelled redactions.
+ * `##name##` -> `name`
+ */
+ labelledRedaction: string;
}
export type OutputFormat = "marko" | "html";
@@ -86,5 +137,8 @@ export function transform(options: TransformOptions): Transformed {
forceFormat,
options?.layoutImport,
options?.componentImports,
+ options?.selfImport,
+ options?.markdownOnly,
+ options?.cloverExtensions,
);
}
diff --git a/lib/rollup.ts b/lib/rollup.ts
index f439332b4abfb166102b4c99551beead51bac7a3..b9b7b761499baa95332090d98902ed8b186f9151 100644
--- a/lib/rollup.ts
+++ b/lib/rollup.ts
@@ -1,6 +1,7 @@
import * as markodown from "./mod";
import * as marko from "@marko/compiler";
import type * as rollup from "rollup";
+import * as path from "node:path";
export type RollupPluginOptions =
& Omit
@@ -15,7 +16,7 @@ export type RollupPluginOptions =
export default function rollupPlugin(
options: RollupPluginOptions = {},
): rollup.Plugin {
- const { compileSync } = options.marko ?? marko;
+ const { compile } = options.marko ?? marko;
return {
name: "@clo/markodown/rollup.ts",
load: {
@@ -26,6 +27,7 @@ export default function rollupPlugin(
const result = markodown.transform({
...options,
source,
+ selfImport: "./" + path.basename(id),
});
if (!result.success) {
@@ -45,7 +47,8 @@ export default function rollupPlugin(
console.log(result);
// TODO: source maps!
- const { code, meta } = compileSync(
+ // TODO: parse marko errors
+ const { code, meta } = await compile(
result.text,
id + ".marko",
{
diff --git a/src/component_transforms.rs b/src/component_transforms.rs
index e9e669587eac7ba7b6a25f309612e2388fd0c437..a3ac27f2e8beac591c237fcd497fb9d67931f0e7 100644
--- a/src/component_transforms.rs
+++ b/src/component_transforms.rs
@@ -129,7 +129,7 @@ impl NodeValue for CodeBlockComponentNode {
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(&format!(" {}", meta));
}
fmt.text_raw(">\n");
diff --git a/src/lib.rs b/src/lib.rs
index 4c044cd353b220c4debfe73c12808531759163c7..92f594995249a102a76a3f7e0d40f45f2bb75b33 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32,6 +32,21 @@ pub struct ComponentImports {
pub blockquote: Option,
}
+/// Clover's question extensions for paperclover.net/q+a backlog.
+/// These emit custom HTML elements (not Marko components).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CloverExtensions {
+ /// Element name for question blocks. `q: text` -> `text`
+ pub question: String,
+ /// Element name for artifact refs. `@slug` -> `slug`
+ pub artifact_ref: String,
+ /// Element name for question refs. `#2602142011` -> `2602142011`
+ pub question_ref: String,
+ /// Element name for labelled redactions. `##name##` -> `name`
+ pub labelled_redaction: String,
+}
+
pub struct Output {
pub text: String,
pub format: OutputFormat,
@@ -51,6 +66,8 @@ pub fn transform(
layout_import: Option,
component_imports: Option,
self_import: Option,
+ markdown_only: bool,
+ clover_extensions: Option,
) -> Result