diff --git a/README.md b/README.md index af7172ea08bc7d9e9a895a9bf638f714044a2c78..2aefe135b4e7f86afcada935ea61ecf045f0ef1a 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ # 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 interactive blog posts. Markdown is compiled directly into `.marko` syntax, leveraging the existing ecosystem. +Markodown is used in production for +[my blog posts on paperclover.net](https://paperclover.net), where I ported my +posts from MDX to it. + [Markdown]: https://en.wikipedia.org/wiki/Markdown [Marko]: https://markojs.com/ -> **CONTENTS**: -> > - [Usage](#usage) > - [Components](#components) > - [Outline / Table of Contents](#outline-table-of-contents) @@ -24,7 +23,7 @@ leveraging the existing ecosystem. > - [Config](#config) > - [Frontmatter Layout Configuration](#frontmatter-layout-configuration) -Here's a glance at how things look. Complete example documents in <./examples> +Here's a glance at how things look. Complete example documents in `examples`. ```` --- @@ -271,21 +270,22 @@ import { Heading } from "@clo/markodown"; export interface Input { content: Marko.Body; - - /** Markdown scans for headings (h1..h6) */ + // 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. */ + // 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} /> diff --git a/lib/jsr.json b/lib/jsr.json index 04b30a33a98c0abfe45033d7c5ff23e062d82616..a31b03ecebd38fe4375e6336d22c503311e2108f 100644 --- a/lib/jsr.json +++ b/lib/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/markodown", - "version": "1.0.0-rc.9", + "version": "1.0.0", "license": "ISC", "exports": { ".": "./mod.ts", diff --git a/lib/mod.ts b/lib/mod.ts index 8cccadd82293b66e2f4a32a5878adb5ef06a1f5f..b4a4c7bf03d93c62108c2d774becab440e2d9911 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -29,7 +29,29 @@ export function transform(options: TransformOptions): Transformed { ); } -/** Converts a flat document outline into a nested tree. */ +/** + * Converts a flat document outline into a nested tree. You can consume this + * tree with a recursive Marko component: + + * ```marko + * + * <${input.content} /> + + * + *
    + *
  • + *
+ * + * + * + * + *

Contents:

+ * + *
  • + * + * + * ``` + */ export function outlineToTree(outline: Heading[]): HeadingTree[] { const root: HeadingTree[] = []; const stack: HeadingTree[] = []; diff --git a/src/lib.rs b/src/lib.rs index c18bd2e443a87a3ff0d6d3da4922048e5b442860..0cd75619ea5652973f8f01815bf137b964e8cf91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -234,11 +234,11 @@ pub fn transform( if let Some(self_path) = self_import { text = format!( - "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}" + "import Layout__markodown__ from \"{layout_path}\";\nexport * as layout 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}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\n{boilerplate}\n{hoisted}\n\n{text}" + "import Layout__markodown__ from \"{layout_path}\";\nexport * as layout from \"{layout_path}\";\nimport * as LayoutModule__markodown__ from \"{layout_path}\";\n{boilerplate}\n{hoisted}\n\n{text}" ); } } else { @@ -598,17 +598,70 @@ mod tests { // Frontmatter layout field // ------------------------------------------------------------------------- + // Quoted id= attribute on MarkoBlockComplete (content) #[test] - fn frontmatter_layout_not_exported_as_const() { - let source = "---\nlayout: ./l.marko\ntitle: Hi\n---\n\ntext"; - let out = run(source); + fn marko_block_complete_heading_single_quoted_id_decoded() { + let out = run_with_layout("

    A heading", "./l.marko"); assert!( - !out.contains("export const layout"), - "layout key must not be exported" + out.contains("id: 'real-world-pitfalls'"), + "outline id must not contain the attribute quotes: {out}" ); assert!( - out.contains("export const title"), - "other fields must still export" + !out.contains("''real-world-pitfalls''"), + "outline array must not contain raw single-quoted attribute value" + ); + } + + #[test] + fn marko_block_complete_heading_double_quoted_id_decoded() { + let out = run_with_layout("

    A heading", "./l.marko"); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "outline id must not contain the attribute double quotes: {out}" + ); + } + + // Quoted id= attribute on MarkoOpen (\ncontent\n) + #[test] + fn marko_open_heading_single_quoted_id_decoded() { + let out = run_with_layout("

    \nA heading\n", "./l.marko"); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "multiline MarkoOpen heading: outline id must not contain attribute quotes: {out}" + ); + } + + #[test] + fn marko_open_heading_double_quoted_id_decoded() { + let out = run_with_layout( + "

    \nA heading\n", + "./l.marko", + ); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "multiline MarkoOpen heading: outline id must not contain attribute double quotes: {out}" + ); + } + + // Quoted id= attribute on MarkoOpenWithText ( text on same line, close elsewhere) + #[test] + fn marko_open_with_text_heading_single_quoted_id_decoded() { + let out = run_with_layout("

    A heading\n", "./l.marko"); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "MarkoOpenWithText heading: outline id must not contain attribute quotes: {out}" + ); + } + + #[test] + fn marko_open_with_text_heading_double_quoted_id_decoded() { + let out = run_with_layout( + "

    A heading\n", + "./l.marko", + ); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "MarkoOpenWithText heading: outline id must not contain attribute double quotes: {out}" ); } @@ -712,6 +765,30 @@ mod tests { assert!(out.contains("level=2"), "level attr should be added"); } + #[test] + fn marko_heading_quoted_id_attr_decoded_in_outline() { + // id='real-world-pitfalls' (single-quoted attr) must be decoded to + // real-world-pitfalls (no quotes) before being placed in the outline array + let out = run_with_layout("

    A heading", "./l.marko"); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "outline id must not contain the attribute quotes: {out}" + ); + assert!( + !out.contains("''real-world-pitfalls''"), + "outline array must not contain raw single-quoted attribute value" + ); + } + + #[test] + fn marko_heading_double_quoted_id_attr_decoded_in_outline() { + let out = run_with_layout("

    A heading", "./l.marko"); + assert!( + out.contains("id: 'real-world-pitfalls'"), + "outline id must not contain the attribute quotes: {out}" + ); + } + #[test] fn heading_content_hoisted_into_define() { let out = run_with_layout("# **bold** heading", "./l.marko"); diff --git a/src/outline.rs b/src/outline.rs index 8ac8747b8edae387402e42ad0b5991d13d104d20..b3b61fef893cde04a2a56f1b27f9ded894e0ff03 100644 --- a/src/outline.rs +++ b/src/outline.rs @@ -13,6 +13,21 @@ use crate::marko_ast::AttributeValue; use crate::plugin::tags::{MarkoBlockComplete, MarkoOpen, MarkoOpenWithText}; use crate::typescript::parse_expr_extra; +/// Decode a static id attribute value, stripping quotes and unescaping if needed. +fn decode_static_id(src: &str, span: Span, is_quoted: bool) -> String { + let string = &src[span.start as usize..span.end as usize]; + if is_quoted { + let mut allocator = Allocator::new(); + let expr = parse_expr_extra(string, 0, &mut allocator); + match &expr { + Ok(oxc_ast::ast::Expression::StringLiteral(literal)) => literal.value.to_string(), + _ => panic!("verified beforehand as a string literal"), + } + } else { + string.to_owned() + } +} + /// A heading entry for the outline, with content reference for hoisting. #[derive(Debug, Clone)] pub struct HeadingEntry { @@ -248,19 +263,7 @@ fn collect_recursive( let tag_span = open.open.as_ref().tag_name_span(); let existing_id = match id_info { AttributeValue::Static { span, is_quoted } => { - let string = &open.open.src[span.start as usize..span.end as usize]; - Some(if is_quoted { - let mut allocator = Allocator::new(); - let expr = parse_expr_extra(string, 0, &mut allocator); - match &expr { - Ok(oxc_ast::ast::Expression::StringLiteral(literal)) => { - literal.value.into_string() - } - _ => panic!("verified beforehand as a string literal"), - } - } else { - string.to_owned() - }) + Some(decode_static_id(&open.open.src, span, is_quoted)) } _ => None, }; @@ -338,8 +341,8 @@ fn collect_recursive( let id_info = open.open.id; let tag_span = open.open.as_ref().tag_name_span(); let existing_id = match id_info { - AttributeValue::Static { span, .. } => { - Some(open.open.src[span.start as usize..span.end as usize].to_string()) + AttributeValue::Static { span, is_quoted } => { + Some(decode_static_id(&open.open.src, span, is_quoted)) } _ => None, }; @@ -417,8 +420,8 @@ fn collect_recursive( let id_info = block.open.id; let tag_span = block.open.as_ref().tag_name_span(); let existing_id = match id_info { - AttributeValue::Static { span, .. } => { - Some(block.open.src[span.start as usize..span.end as usize].to_string()) + AttributeValue::Static { span, is_quoted } => { + Some(decode_static_id(&block.open.src, span, is_quoted)) } _ => None, }; @@ -519,9 +522,10 @@ pub fn format_outline_array(headings: &[HeadingEntry]) -> String { let entries: Vec = headings .iter() .map(|h| { + let escaped_id = h.id.replace('\\', "\\\\").replace('\'', "\\'"); format!( "{{ level: {}, id: '{}', content: {} }}", - h.level, h.id, h.component_name + h.level, escaped_id, h.component_name ) }) .collect(); @@ -585,4 +589,19 @@ mod tests { assert!(result.contains("id: 'hello'")); assert!(result.contains("content: Heading_1__markodown__")); } + + #[test] + fn test_format_outline_array_escapes_single_quotes() { + let headings = vec![HeadingEntry { + level: 2, + id: "it's-here".to_string(), + text: "It's here".to_string(), + component_name: "Heading_1__markodown__".to_string(), + }]; + let result = format_outline_array(&headings); + assert!( + result.contains(r"id: 'it\'s-here'"), + "single quote in id must be escaped: {result}" + ); + } } diff --git a/src/plugin/frontmatter.rs b/src/plugin/frontmatter.rs index ade0d156f7a8b6ca72c7a6232571666d8fac4c18..fbbf3e94f3b6cc16ba3e752c542ee4fde01f750c 100644 --- a/src/plugin/frontmatter.rs +++ b/src/plugin/frontmatter.rs @@ -7,10 +7,16 @@ use crate::plugin::{get_line_raw, ErrorBlock, StatementBlock}; /// Parse frontmatter (---) at document start pub(crate) struct Rule; -/// Strip '//' comments from YAML content +/// Strip '//' comments from YAML content (only when // starts the line) fn strip_js_comments(yaml: &str) -> String { yaml.lines() - .map(|line| line.find("//").map_or(line, |pos| &line[..pos])) + .map(|line| { + if line.trim_start().starts_with("//") { + "" + } else { + line + } + }) .collect::>() .join("\n") } diff --git a/src/plugin/tags.rs b/src/plugin/tags.rs index 6e6574d53775202c38ac15bf9f314e366d7f5445..3ac0cc4411194c1d3c0004d7eadb35a3810304ef 100644 --- a/src/plugin/tags.rs +++ b/src/plugin/tags.rs @@ -238,7 +238,17 @@ impl BlockRule for Rule { // Check if the same-line content contains a matching close tag if let Some((content, close_tag)) = find_same_line_close(text, open.tag_name()) { - // Complete tag on one line: content + // Complete tag on one line: content. + // If the previous line is non-empty prose (not another tag), this + // self-contained tag is inline content within a paragraph - let + // the paragraph rule handle it instead. + if state.line > 0 { + let prev_line = get_line_raw(state, state.line - 1).trim(); + if !prev_line.is_empty() && !prev_line.starts_with('<') { + return None; + } + } + let content = content.trim(); // Calculate byte offset for source mapping diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 03713eac613d5f0bd8bf38a4e4f331740f5504cf..bca5bd7b2d620d226f11c0b879879bcfea6394f3 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -283,3 +283,10 @@ fn fixture_31_import_with_markdown_content() { // markdown link syntax [text](url) as a TypeScript array expression run_fixture("31-import-with-markdown-content"); } + +#[test] +fn fixture_32_inline_tag_in_paragraph() { + // regression: a self-contained inline tag (text) on its own line within a paragraph + // was being consumed by the block tag rule, splitting one paragraph into three + run_fixture("32-inline-tag-in-paragraph"); +} diff --git a/tests/fixtures/23-outline-extracting.marko b/tests/fixtures/23-outline-extracting.marko index e0bf48430eadbee57cd65ec12ccb804c0f710928..f7aedbe1d773438eb97b7b50995a7e1477e5633f 100644 --- a/tests/fixtures/23-outline-extracting.marko +++ b/tests/fixtures/23-outline-extracting.marko @@ -1,4 +1,5 @@ import Layout__markodown__ from "./layout.marko"; +export * as layout from "./layout.marko"; import * as LayoutModule__markodown__ from "./layout.marko"; <${'h' + level} ...attrs><${content} /> diff --git a/tests/fixtures/27-frontmatter-layout.marko b/tests/fixtures/27-frontmatter-layout.marko index 395515393b30c0a0ca944e5dcacf65e01c9412cc..e59cda25aaf54ea1178f1c2eafcd746ba1d1e7e8 100644 --- a/tests/fixtures/27-frontmatter-layout.marko +++ b/tests/fixtures/27-frontmatter-layout.marko @@ -1,4 +1,5 @@ import Layout__markodown__ from "./page-layout.marko"; +export * as layout from "./page-layout.marko"; import * as LayoutModule__markodown__ from "./page-layout.marko"; <${'h' + level} ...attrs><${content} /> diff --git a/tests/fixtures/28-outline-self-import.marko b/tests/fixtures/28-outline-self-import.marko index f4a97bf974dac17e76f79c7bf4331e1b7aeb6e58..9f8585fb438a76da305e4b2256c03e509c1fec2d 100644 --- a/tests/fixtures/28-outline-self-import.marko +++ b/tests/fixtures/28-outline-self-import.marko @@ -1,4 +1,5 @@ import Layout__markodown__ from "./layout.marko"; +export * as layout from "./layout.marko"; import * as LayoutModule__markodown__ from "./layout.marko"; import * as self__markodown__ from "./self.marko"; diff --git a/tests/fixtures/29-layout-all-components.marko b/tests/fixtures/29-layout-all-components.marko index 018269f82c441d7f7bbff00f60d1a7201b76f8fe..ee63b84d19c9d8b24180f7ce09a85e5469a0d882 100644 --- a/tests/fixtures/29-layout-all-components.marko +++ b/tests/fixtures/29-layout-all-components.marko @@ -1,4 +1,5 @@ import Layout__markodown__ from "./layout.marko"; +export * as layout from "./layout.marko"; import * as LayoutModule__markodown__ from "./layout.marko"; <${'h' + level} ...attrs><${content} /> diff --git a/tests/fixtures/30-layout-selective-components.marko b/tests/fixtures/30-layout-selective-components.marko index 4c34dbbe61f7551771aa956753c5f6dc15106345..6e87dd6a3a6ca858abb646ee5ca86d150b55cb51 100644 --- a/tests/fixtures/30-layout-selective-components.marko +++ b/tests/fixtures/30-layout-selective-components.marko @@ -1,4 +1,5 @@ import Layout__markodown__ from "./layout.marko"; +export * as layout from "./layout.marko"; import * as LayoutModule__markodown__ from "./layout.marko"; <${'h' + level} ...attrs><${content} /> diff --git a/tests/fixtures/32-inline-tag-in-paragraph.marko b/tests/fixtures/32-inline-tag-in-paragraph.marko new file mode 100644 index 0000000000000000000000000000000000000000..ae3190cacaa1d313457af3776fe7bf1fe1ce4cff --- /dev/null +++ b/tests/fixtures/32-inline-tag-in-paragraph.marko @@ -0,0 +1,7 @@ +

    xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx.

    +

    xxx xxxxx xx xxx xx xxxx xxxxxxxxxx xxx xxx xxxx xxx xxxxxxxxxx, +“xxxxxx” xxxxxxxxxx xxx “xxxxxx” +xxxxxxxxxx. xxxxxx xxxxxxxxxx xxx’x xxxx xxxxxxxx, xxxxxxxxx.

    +

    xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx.

    diff --git a/tests/fixtures/32-inline-tag-in-paragraph.mdo b/tests/fixtures/32-inline-tag-in-paragraph.mdo new file mode 100644 index 0000000000000000000000000000000000000000..f414d22c61447b8b40c4500a44a21868d1935541 --- /dev/null +++ b/tests/fixtures/32-inline-tag-in-paragraph.mdo @@ -0,0 +1,9 @@ +xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx. + +xxx xxxxx xx xxx xx xxxx xxxxxxxxxx xxx xxx xxxx xxx xxxxxxxxxx, +"xxxxxx" xxxxxxxxxx xxx "xxxxxx" +xxxxxxxxxx. xxxxxx xxxxxxxxxx xxx'x xxxx `xxxxxxxx`, `xxxxxxxxx`. + +xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx. diff --git a/wtf.mdo b/wtf.mdo new file mode 100644 index 0000000000000000000000000000000000000000..f414d22c61447b8b40c4500a44a21868d1935541 --- /dev/null +++ b/wtf.mdo @@ -0,0 +1,9 @@ +xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx. + +xxx xxxxx xx xxx xx xxxx xxxxxxxxxx xxx xxx xxxx xxx xxxxxxxxxx, +"xxxxxx" xxxxxxxxxx xxx "xxxxxx" +xxxxxxxxxx. xxxxxx xxxxxxxxxx xxx'x xxxx `xxxxxxxx`, `xxxxxxxxx`. + +xx xx xxxxxx xxx xxxx xxxxxxx xxxxxx xxxx xx xxxxx xxxxxx, xxx +xxx xxxx xxxxxxxxxx xxx xxx xx xxx xxxx xxxxxxxx.