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 { + 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> { // Pre-process to extract preamble (blank lines, imports) and frontmatter // This is needed because markdown-it skips blank lines before running block rules @@ -66,7 +83,7 @@ pub fn transform( let md = &mut markdown_it::MarkdownIt::new(); - plugin::add_all(md); + plugin::add_all(md, markdown_only, &clover_extensions); markdown_it::plugins::cmark::add(md); markdown_it::plugins::extra::add(md); @@ -92,8 +109,11 @@ pub fn transform( // Determine output format // componentImports forces Marko format since we add imports + // markdown_only mode defaults to HTML since no Marko features are parsed let format = force.unwrap_or_else(|| { - if component_imports.is_some() || has_marko_features(&ast) { + if markdown_only { + OutputFormat::Html + } else if component_imports.is_some() || has_marko_features(&ast) { OutputFormat::Marko } else { OutputFormat::Html @@ -156,11 +176,11 @@ pub fn transform( if let Some(self_path) = self_import { text = format!( - "import Layout__markodown__ from \"{layout_path}\";import * as self__markodown__ from \"{self_path}\";\n{hoisted}\n{text}" + "import Layout__markodown__ from \"{layout_path}\";\nimport * as self__markodown__ from \"{self_path}\";\n{hoisted}\n\n{text}" ); } else { text = format!( - "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}\n{text}" + "import Layout__markodown__ from \"{layout_path}\";\n{hoisted}\n\n{text}" ); } } else { diff --git a/src/main.rs b/src/main.rs index f358cc4a37d1df467af167ab91c21a36de2ab358..0f81c7262b568cf8b60a9ed3630f6a7b97deb798 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,10 @@ struct Cli { /// Replace blockquote elements with a custom component #[arg(long)] blockquote_import: Option, + + /// Disable all Marko extensions, treating input as pure Markdown + #[arg(long)] + markdown_only: bool, } fn main() { @@ -87,7 +91,15 @@ fn main() { } }; - match transform(&source, force, layout_import, component_imports, None) { + match transform( + &source, + force, + layout_import, + component_imports, + None, + cli.markdown_only, + None, // clover_extensions not exposed via CLI + ) { Err(errors) => { let handler = GraphicalReportHandler::new(); for error in &errors { diff --git a/src/plugin/clover.rs b/src/plugin/clover.rs new file mode 100644 index 0000000000000000000000000000000000000000..32e7d6771dd720de31a3dd4e5e1ec1c8f2ca6b40 --- /dev/null +++ b/src/plugin/clover.rs @@ -0,0 +1,306 @@ +//! Clover's Q+A extensions for paperclover.net +//! +//! Block rules: +//! - `q: text` -> `text` (consecutive q: lines get
    between) +//! - `@html ` -> raw HTML passthrough +//! +//! Inline rules: +//! - `@slug` -> `slug` +//! - `#2602142011` -> `2602142011` +//! - `##label##` -> `label` + +use crate::CloverExtensions; +use markdown_it::parser::block::{BlockRule, BlockState}; +use markdown_it::parser::extset::MarkdownItExt; +use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState}; +use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; + +/// Stored in MarkdownIt.ext for runtime access +#[derive(Debug, Clone)] +pub struct CloverConfig(pub CloverExtensions); + +impl MarkdownItExt for CloverConfig {} + +/// Register clover extension rules +pub fn add(md: &mut MarkdownIt, config: CloverExtensions) { + md.ext.insert(CloverConfig(config)); + md.block.add_rule::(); + md.block.add_rule::(); + md.inline.add_rule::(); + md.inline.add_rule::(); + md.inline.add_rule::(); +} + +// ============================================================================= +// Question Block: `q: inline text` +// ============================================================================= + +#[derive(Debug)] +pub struct QuestionBlock { + pub element: String, + /// Whether this question follows another question (needs
    prefix) + pub needs_break: bool, +} + +impl NodeValue for QuestionBlock { + fn render(&self, node: &Node, fmt: &mut dyn Renderer) { + if self.needs_break { + fmt.text_raw("
    "); + } + fmt.text_raw(&format!("<{}>", self.element)); + fmt.contents(&node.children); + fmt.text_raw(&format!("", self.element)); + fmt.text_raw("\n"); + } +} + +pub struct QuestionBlockRule; + +impl BlockRule for QuestionBlockRule { + fn run(state: &mut BlockState) -> Option<(Node, usize)> { + let config = state.md.ext.get::()?; + + if state.line >= state.line_max { + return None; + } + + let line = state.get_line(state.line); + if !line.starts_with("q: ") { + return None; + } + + // Check if previous line was also a question block + let needs_break = if state.line > 0 { + let prev_line = state.get_line(state.line - 1); + prev_line.starts_with("q: ") + } else { + false + }; + + let mut node = Node::new(QuestionBlock { + element: config.0.question.clone(), + needs_break, + }); + + // Parse inline content (skip "q: " prefix = 3 chars) + let content = line[3..].to_owned(); + let mapping = vec![(0, state.line_offsets[state.line].first_nonspace + 3)]; + node.children + .push(Node::new(InlineRoot::new(content, mapping))); + + Some((node, 1)) + } +} + +// ============================================================================= +// HTML Block: `@html ` +// ============================================================================= + +#[derive(Debug)] +pub struct RawHtmlBlock { + pub content: String, +} + +impl NodeValue for RawHtmlBlock { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&self.content); + fmt.text_raw("\n"); + } +} + +pub struct HtmlBlockRule; + +impl BlockRule for HtmlBlockRule { + fn run(state: &mut BlockState) -> Option<(Node, usize)> { + // This rule is enabled when any clover extensions are present + state.md.ext.get::()?; + + if state.line >= state.line_max { + return None; + } + + let line = state.get_line(state.line); + if !line.starts_with("@html ") { + return None; + } + + let content = &line[6..]; + let node = Node::new(RawHtmlBlock { + content: content.to_string(), + }); + + Some((node, 1)) + } +} + +// ============================================================================= +// Artifact Ref: `@slug-name` +// ============================================================================= + +#[derive(Debug)] +pub struct ArtifactRef { + pub element: String, + pub slug: String, +} + +impl NodeValue for ArtifactRef { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!("<{0}>{1}", self.element, self.slug)); + } +} + +pub struct ArtifactRefRule; + +impl InlineRule for ArtifactRefRule { + const MARKER: char = '@'; + + fn run(state: &mut InlineState) -> Option<(Node, usize)> { + let config = state.md.ext.get::()?; + + let input = &state.src[state.pos..state.pos_max]; + if !input.starts_with('@') { + return None; + } + + // Match @[a-z0-9-]+ but not @html (that's a block rule) + let rest = &input[1..]; + if rest.starts_with("html ") { + return None; + } + + let slug_len = rest + .chars() + .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-') + .count(); + + if slug_len == 0 { + return None; + } + + let slug = &rest[..slug_len]; + let node = Node::new(ArtifactRef { + element: config.0.artifact_ref.clone(), + slug: slug.to_string(), + }); + + Some((node, 1 + slug_len)) + } +} + +// ============================================================================= +// Question Ref: `#2602142011` (10 or 12 digits) +// ============================================================================= + +#[derive(Debug)] +pub struct QuestionRef { + pub element: String, + pub id: String, +} + +impl NodeValue for QuestionRef { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!("<{0}>{1}", self.element, self.id)); + } +} + +pub struct QuestionRefRule; + +impl InlineRule for QuestionRefRule { + const MARKER: char = '#'; + + fn run(state: &mut InlineState) -> Option<(Node, usize)> { + let config = state.md.ext.get::()?; + + let input = &state.src[state.pos..state.pos_max]; + if !input.starts_with('#') { + return None; + } + + // Check it's not ## (that's labelled redaction) + if input.starts_with("##") { + return None; + } + + let rest = &input[1..]; + let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count(); + + // Must be exactly 10 or 12 digits + if digit_count != 10 && digit_count != 12 { + return None; + } + + // Make sure the next char after digits is not also a digit + if rest + .chars() + .nth(digit_count) + .is_some_and(|c| c.is_ascii_digit()) + { + return None; + } + + let id = &rest[..digit_count]; + let node = Node::new(QuestionRef { + element: config.0.question_ref.clone(), + id: id.to_string(), + }); + + Some((node, 1 + digit_count)) + } +} + +// ============================================================================= +// Labelled Redaction: `##label##` +// ============================================================================= + +#[derive(Debug)] +pub struct LabelledRedaction { + pub element: String, + pub label: String, +} + +impl NodeValue for LabelledRedaction { + fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { + fmt.text_raw(&format!("<{0}>{1}", self.element, self.label)); + } +} + +pub struct LabelledRedactionRule; + +impl InlineRule for LabelledRedactionRule { + const MARKER: char = '#'; + + fn run(state: &mut InlineState) -> Option<(Node, usize)> { + let config = state.md.ext.get::()?; + + let input = &state.src[state.pos..state.pos_max]; + if !input.starts_with("##") { + return None; + } + + let rest = &input[2..]; + + // Find closing ## + let label_end = rest.find("##")?; + if label_end == 0 { + return None; // Empty label + } + + let label = &rest[..label_end]; + + // Label should be simple identifier-like (alphanumeric, hyphens, underscores, spaces) + if !label + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ') + { + return None; + } + + let node = Node::new(LabelledRedaction { + element: config.0.labelled_redaction.clone(), + label: label.to_string(), + }); + + // ## + label + ## + Some((node, 2 + label_end + 2)) + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index a0bddf632d5ebb8f3704b89e8928337460dfa20d..e885957200d86fcd7ed6b8b511b18b2760c8bec3 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,4 +1,5 @@ pub mod autolink; +pub mod clover; pub mod comment; pub mod frontmatter; pub mod inline_tags; @@ -8,22 +9,35 @@ pub mod template; pub use frontmatter::{extract_preamble_and_frontmatter, PreambleResult}; +use crate::CloverExtensions; use markdown_it::{parser::block::BlockState, MarkdownIt, Node, NodeValue, Renderer}; use oxc_diagnostics::OxcDiagnostic; -/// Register all markodown extensions -pub fn add_all(md: &mut MarkdownIt) { +/// Register all markodown extensions. +/// If `markdown_only` is true, skip Marko-specific rules (tags, templates, statements). +pub fn add_all(md: &mut MarkdownIt, markdown_only: bool, clover_extensions: &Option) { + // Always add frontmatter parsing (YAML metadata is standard markdown extension) md.block.add_rule::(); - md.block.add_rule::(); - md.block.add_rule::(); - md.block.add_rule::(); - md.inline.add_rule::(); - md.inline.add_rule::(); - md.inline.add_rule::(); + if !markdown_only { + // Marko-specific block rules + md.block.add_rule::(); + md.block.add_rule::(); + md.block.add_rule::(); - // Post-processor for matching inline tag markers - inline_tags::add(md); + // Marko-specific inline rules + md.inline.add_rule::(); + md.inline.add_rule::(); + md.inline.add_rule::(); + + // Post-processor for matching inline tag markers + inline_tags::add(md); + } + + // Clover's Q+A extensions (can be used with or without Marko features) + if let Some(config) = clover_extensions { + clover::add(md, config.clone()); + } } /// An AST node representing a JavaScript error diff --git a/src/wasm.rs b/src/wasm.rs index ce4edd250a13b0458863ef1d9404e30dc605a14e..d3a4c4dc63799a8e08297317a24fb7a5c13498d5 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -1,4 +1,4 @@ -use crate::{ComponentImports, OutputFormat}; +use crate::{CloverExtensions, ComponentImports, OutputFormat}; use oxc_diagnostics::OxcDiagnostic; use serde::Serialize; use wasm_bindgen::prelude::*; @@ -102,6 +102,8 @@ pub fn transform( layout_import: Option, component_imports: JsValue, self_path: Option, + markdown_only: Option, + clover_extensions: JsValue, ) -> JsValue { // Deserialize component_imports from JsValue (can be undefined/null) let component_imports: Option = @@ -111,12 +113,22 @@ pub fn transform( serde_wasm_bindgen::from_value(component_imports).ok() }; + // Deserialize clover_extensions from JsValue (can be undefined/null) + let clover_extensions: Option = + if clover_extensions.is_undefined() || clover_extensions.is_null() { + None + } else { + serde_wasm_bindgen::from_value(clover_extensions).ok() + }; + match crate::transform( src, force_format, layout_import, component_imports, self_path, + markdown_only.unwrap_or(false), + clover_extensions, ) { Ok(output) => serde_wasm_bindgen::to_value(&TransformResult { text: Some(output.text), diff --git a/tests/fixtures.rs b/tests/fixtures.rs index b832e79f1bb94c3cd5b095934181ca984493719e..39d6e3f3824f9ce3ccbd447bff46cc81cfeb1c9b 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -36,6 +36,8 @@ fn run_fixture_with_options( layout.map(|s| s.to_string()), component_imports.clone(), self_path.map(|s| s.to_string()), + false, + None, ) .unwrap(); @@ -63,6 +65,8 @@ fn run_fixture_with_options( layout.map(|s| s.to_string()), component_imports.clone(), self_path.map(|s| s.to_string()), + false, + None, ) .unwrap(); diff --git a/tests/fixtures/23-outline-extracting.marko b/tests/fixtures/23-outline-extracting.marko index 2736aa794e967adf1a9268c42d231ecfa4670c2d..af3a3a51e6cf2b2c4d34dee376b51de9e8488c60 100644 --- a/tests/fixtures/23-outline-extracting.marko +++ b/tests/fixtures/23-outline-extracting.marko @@ -11,6 +11,7 @@ good night rain time +