From e606cdcab26868b675ff98d0c0ead0bd963ccc09 Mon Sep 17 00:00:00 2001 From: John Karr Date: Thu, 16 Apr 2026 03:53:27 -0400 Subject: [PATCH 1/2] cleanup and reorganize --- .gitignore | 1 + bin/documark | 2 + doc/layout-style.md | 77 ++++++++++++++++++++++++------------ lib/documark/export_clean.rb | 22 +++++++++++ lib/documark/parser.rb | 10 ++++- lib/documark/render_html.rb | 12 ------ test/cli_test.rb | 24 ++++++----- test/export_clean_test.rb | 33 ++++++++++++++++ test/parser_test.rb | 25 ++++++++++++ test/render_html_test.rb | 27 ------------- 10 files changed, 157 insertions(+), 76 deletions(-) create mode 100644 lib/documark/export_clean.rb create mode 100644 test/export_clean_test.rb diff --git a/.gitignore b/.gitignore index 5451bce..31a1d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ Gemfile.lock .vscode notes.txt +scratch diff --git a/bin/documark b/bin/documark index 0fb0b58..5979f53 100755 --- a/bin/documark +++ b/bin/documark @@ -6,12 +6,14 @@ require 'kramdown' require_relative '../lib/documark/runtime' require_relative '../lib/documark/parser' require_relative '../lib/documark/config' +require_relative '../lib/documark/export_clean' require_relative '../lib/documark/render_html' require_relative '../lib/documark/render_pdf' extend Documark::Runtime extend Documark::Parser extend Documark::Config +extend Documark::ExportClean extend Documark::RenderHtml extend Documark::RenderPdf diff --git a/doc/layout-style.md b/doc/layout-style.md index abc5d85..1f6f672 100644 --- a/doc/layout-style.md +++ b/doc/layout-style.md @@ -68,31 +68,20 @@ Typical pattern: ## Screen And Print -Documark is trying to support both browser viewing and printable output. +Screen presentation and print are different, the @media tags are a critical tool in targeting styling. -That makes `@media screen` and `@media print` especially important. +media tags and responsibilities: -Typical responsibilities: +- `@media all`: common formatting +- `@media screen`: browser reading experience, responsive layout +- `@media print`: print reading experience +- `@page`: rules within `@media print` controlling: page geometry, print typography, margin handling, page break behavior -- `@media screen`: browser reading experience, responsive layout, preview behavior -- `@media print`: page geometry, print typography, margin handling, page break behavior +### PDF Rendering And The Style Section -If a layout uses the same CSS for everything, it will usually be harder to get good print output. +The current prototype renders PDF by printing generated HTML through a Chromium-compatible browser. If the pipeline changes, CSS will still be used as a common styling language. -## PDF Rendering And The Style Section - -The current prototype renders PDF by printing generated HTML through a Chromium-compatible browser. - -That means the style section directly affects PDF output. - -Important implications: - -- `@page` rules matter -- print-mode typography matters -- browser defaults matter unless you reset them -- framework spacing can interfere with page geometry if left unmanaged - -The current implementation also omits `container_class` for PDF output. That is a practical decision: many container classes add horizontal padding that would stack on top of the margins already defined in `@page`. +The current implementation also omits `container_class` for PDF output. That is a practical decision: container classes exist to add padding for browsing presentation that would stack on top of the margins defined in `@page`. ## Recommended Responsibilities For The Style Section @@ -111,14 +100,21 @@ Depending on the layout, it may also define: - image sizing - page-break behavior for headings, tables, and figures -## Example Pattern +## Example ```css -@media screen { - /* screen preview defaults */ -} -@media print { +@charset "UTF-8"; +/* + Load Roboto for heading levels. + Keeping this in CSS makes the font setup part of the layout itself. +*/ +@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap'); + +@media all { + /* + Shared defaults for both screen preview and print output. + */ body { font-family: Georgia, "Times New Roman", Times, serif; font-size: 11pt; @@ -126,29 +122,58 @@ Depending on the layout, it may also define: color: #000; } + /* Headings use Roboto, matching the Google Fonts example style block. */ + h1, h2, h3, h4, h5, h6 { + font-family: "Roboto", sans-serif; + font-optical-sizing: auto; + font-weight: 600; + font-style: normal; + font-variation-settings: "wdth" 100; + } +} + +@media screen { + /* + Screen-only container behavior. + This pairs with `container_class: container` in layout front matter. + */ + .container { + max-width: 840px; + margin: 2rem auto; + padding: 0 1.25rem; + } +} + +@media print { + /* PDF and print geometry. */ @page { size: letter portrait; margin: 1in; } + /* Reset browser print margins so @page controls spacing cleanly. */ html, body { margin: 0; padding: 0; } + /* Avoid separating top-level headings from their first following content. */ h1, h2, h3 { page-break-after: avoid; } + /* Reduce awkward single lines at top/bottom of printed pages. */ p, li, blockquote { orphans: 3; widows: 3; } + /* Keep structural blocks intact across page breaks when possible. */ pre, blockquote, table, figure { page-break-inside: avoid; } + /* Print links as regular text styling. */ a { color: #000; text-decoration: none; @@ -186,7 +211,7 @@ The style section is the right place to override those defaults. ## Custom CSS -The stylesheets key in the front matter allows you to use your own css, which can be compiled from scss. You can certainly move all of the styling to your custom sheet, in wich case the style section can just be some blank lines. +The `stylesheets` key in the front matter allows you to use your own CSS, which can be compiled from SCSS. You can certainly move all of the styling to your custom sheet, in which case the style section can just be a blank line. ## Related Documents diff --git a/lib/documark/export_clean.rb b/lib/documark/export_clean.rb new file mode 100644 index 0000000..41695ae --- /dev/null +++ b/lib/documark/export_clean.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative 'runtime' + +module Documark + module ExportClean + module_function + extend Documark::Runtime + + def cleanup_markdown_output(content) + text = content.to_s.dup + # Remove common kramdown attribute list syntax from block and inline usage. + text.gsub!(/^[ \t]*\{:[^}\n]*\}[ \t]*\n/, '') + text.gsub!(/[ \t]*\{:[^}\n]*\}/, '') + text + end + + def write_markdown_output(output_path, body) + ordie { File.write(output_path, cleanup_markdown_output(body)) } + end + end +end \ No newline at end of file diff --git a/lib/documark/parser.rb b/lib/documark/parser.rb index ad11fde..8df3ad9 100644 --- a/lib/documark/parser.rb +++ b/lib/documark/parser.rb @@ -38,13 +38,19 @@ def read_document(input_path) content = ordie { File.read(input_path) } header = read_header(content) doc = split_documark_sections(content) - raise 'missing frontmatter section' if doc["data"].nil? + data = parse_data_section(doc["data"]) + + if doc["data"].nil? + data = {} + data["title"] = File.basename(input_path) + warn "No data section found; defaulting title to #{data['title']}" + end { "content" => content, "header" => header, "doc" => doc, - "data" => parse_data_section(doc["data"]) + "data" => data } end diff --git a/lib/documark/render_html.rb b/lib/documark/render_html.rb index 8a3490c..b512014 100644 --- a/lib/documark/render_html.rb +++ b/lib/documark/render_html.rb @@ -14,14 +14,6 @@ def compose_html(options, layout, data, html) ERB.new(template).result_with_hash(options: options, layout: layout, data: data, html: html) end - def cleanup_markdown_output(content) - text = content.to_s.dup - # Remove common kramdown attribute list syntax from block and inline usage. - text.gsub!(/^[ \t]*\{:[^}\n]*\}[ \t]*\n/, '') - text.gsub!(/[ \t]*\{:[^}\n]*\}/, '') - text - end - def render_page(options, layout, data, body) html = Kramdown::Document.new(body).to_html compose_html(options, layout, data, html) @@ -30,9 +22,5 @@ def render_page(options, layout, data, body) def write_page(output_path, page) ordie { File.write(output_path, page) } end - - def write_markdown_output(output_path, body) - ordie { File.write(output_path, cleanup_markdown_output(body)) } - end end end diff --git a/test/cli_test.rb b/test/cli_test.rb index 38dd5a0..e18c872 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -106,16 +106,22 @@ def test_markdown_target_strips_kramdown_attribute_lists end end - def test_fails_when_frontmatter_data_section_is_missing - _stdout, stderr, status = run_cli( - 'process', - '--input', fixture_path('fmerrormissing.dm'), - '--output', '/tmp/out.html', - '--target', 'html' - ) + def test_warns_and_defaults_title_when_data_section_is_missing + Dir.mktmpdir('documark-cli-test') do |tmpdir| + output_path = File.join(tmpdir, 'out.html') - refute status.success? - assert_includes stderr, 'missing frontmatter section' + _stdout, stderr, status = run_cli( + 'process', + '--input', fixture_path('fmerrormissing.dm'), + '--output', output_path, + '--target', 'html' + ) + + assert status.success?, stderr + assert_includes stderr, 'No data section found; defaulting title to fmerrormissing.dm' + html = File.read(output_path) + assert_includes html, 'fmerrormissing.dm' + end end def test_fails_when_data_section_is_unterminated diff --git a/test/export_clean_test.rb b/test/export_clean_test.rb new file mode 100644 index 0000000..9ba766d --- /dev/null +++ b/test/export_clean_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_helper' +require_relative '../lib/documark/export_clean' + +class DocumarkExportCleanTest < Minitest::Test + def test_cleanup_markdown_output_removes_attribute_list_syntax + source = <<~DOC + # Heading + {: .lead} + + Paragraph{: .tight} + DOC + + cleaned = Documark::ExportClean.cleanup_markdown_output(source) + + refute_includes cleaned, '{: .lead}' + refute_includes cleaned, '{: .tight}' + assert_includes cleaned, '# Heading' + assert_includes cleaned, 'Paragraph' + end + + def test_write_markdown_output_writes_cleaned_content + Dir.mktmpdir('documark-export-clean-test') do |tmpdir| + output_path = File.join(tmpdir, 'out.md') + + Documark::ExportClean.write_markdown_output(output_path, "Line{: .trim}\n") + + assert_equal "Line\n", File.read(output_path) + end + end +end \ No newline at end of file diff --git a/test/parser_test.rb b/test/parser_test.rb index 6fd6a61..40fe0bb 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -92,4 +92,29 @@ def test_read_layout_uses_explicit_default_layout_path assert_includes parsed['style'], '@media print' end end + + def test_read_document_warns_and_defaults_title_when_data_section_is_missing + Dir.mktmpdir('documark-parser-test') do |tmpdir| + input_path = File.join(tmpdir, 'no-data.dm') + File.write(input_path, <<~DOC) + !! documark document + !! layout + --- + container_class: container + --- + @media print {} + !! end + + # Body + DOC + + document = nil + stderr = capture_io do + document = Documark::Parser.read_document(input_path) + end.last + + assert_equal 'no-data.dm', document['data']['title'] + assert_match(/No data section found; defaulting title to no-data\.dm/, stderr) + end + end end diff --git a/test/render_html_test.rb b/test/render_html_test.rb index 93c775d..b540fe8 100644 --- a/test/render_html_test.rb +++ b/test/render_html_test.rb @@ -1,26 +1,9 @@ # frozen_string_literal: true -require 'tmpdir' require_relative 'test_helper' require_relative '../lib/documark/render_html' class DocumarkRenderHtmlTest < Minitest::Test - def test_cleanup_markdown_output_removes_attribute_list_syntax - source = <<~DOC - # Heading - {: .lead} - - Paragraph{: .tight} - DOC - - cleaned = Documark::RenderHtml.cleanup_markdown_output(source) - - refute_includes cleaned, '{: .lead}' - refute_includes cleaned, '{: .tight}' - assert_includes cleaned, '# Heading' - assert_includes cleaned, 'Paragraph' - end - def test_render_page_builds_full_html_document layout = { 'stylesheets' => ['https://example.com/site.css'], @@ -40,14 +23,4 @@ def test_render_page_builds_full_html_document assert_includes page, '' assert_includes page, '

Hello

' end - - def test_write_markdown_output_writes_cleaned_content - Dir.mktmpdir('documark-render-test') do |tmpdir| - output_path = File.join(tmpdir, 'out.md') - - Documark::RenderHtml.write_markdown_output(output_path, "Line{: .trim}\n") - - assert_equal "Line\n", File.read(output_path) - end - end end From a3873a5cb31902656c829e05279898afb6a21c1a Mon Sep 17 00:00:00 2001 From: John Karr Date: Fri, 17 Apr 2026 03:11:02 -0400 Subject: [PATCH 2/2] Add @{}, @[], @<> in-document tag syntax with TagProcessor Implement a two-phase TagProcessor (preprocess/postprocess) that intercepts @{}, @[], and @<> directives in the document body before Kramdown sees them, replacing them with HTML comment placeholders that are substituted back after Markdown rendering. @{} applies CSS classes, IDs, and data-* attributes as
(block/ section) or (inline); supports explicit and next-word-only closure. @[] introduces semantic HTML5 block elements (aside, section, figure, etc.) in single-block and section forms with @[/element] close. @<> introduces inline HTML elements (u, mark, abbr, etc.) with the same explicit/next-word closure rules as @{} inline. \@ escape sequence passes a literal @ through; \!! delegates to Markdown's own \! escape. --- doc/spec.md | 258 ++++++++++++++++++++++++++++++++- lib/documark/default.dml | 9 +- lib/documark/render_html.rb | 5 +- lib/documark/tag_processor.rb | 218 ++++++++++++++++++++++++++++ test/tag_processor_test.rb | 260 ++++++++++++++++++++++++++++++++++ 5 files changed, 742 insertions(+), 8 deletions(-) create mode 100644 lib/documark/tag_processor.rb create mode 100644 test/tag_processor_test.rb diff --git a/doc/spec.md b/doc/spec.md index 54f5370..1aed717 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -89,7 +89,7 @@ Rules: - A `data` block SHOULD be present. - YAML aliases are not permitted. -- Parsed YAML SHOULD be a mapping object. +- Parsed YAML MUST be a mapping object. Common keys: @@ -142,9 +142,30 @@ Common layout keys: ## 6. Markdown Dialect and Extensions -The initial ruby Documark implementation uses the Kramdown library and supports all of its extensions. As the format matures, it is possible that Documark will develop its own patterns for extension, possibly replacing the Kramdown patterns. +The initial Ruby Documark implementation uses the Kramdown library as its Markdown engine. -While you may use anything valid for Kramdown in your Markdown sections, it is possible that these won't be compatible with a future revision. If you want documents to safely upgrade if there is major change as Documark matures, stick to the official markdown specification , plus widely adapted extensions like GFM tables. +Documark defines its own extension syntax for applying classes, IDs, data attributes, and semantic HTML elements to document content (see sections 8.1, 8.2, and 8.4). These replace the need for Kramdown's block attribute extension (`{: .class }`). The Kramdown extension syntax is unsupported in Documark — it is not part of the Documark standard and its behaviour in future implementations is not guaranteed. It may incidentally work in the current implementation because Kramdown is the underlying engine, but documents relying on it will not be portable. + +For maximum forward compatibility, the body of a Documark document SHOULD use: + +- The core Markdown specification: +- Widely adopted extensions such as GFM tables and fenced code blocks +- Documark's own `@{}`, `@<>`, and `@[]` tag syntax for styling and semantic structure + +### 6.1 Style Recommendations + +These are recommendations, not format rules. They are intended to improve readability, portability, and consistency across Documark documents. + +**Prefer `*` over `_` for emphasis and bold.** Markdown permits both `*italic*` and `_italic_`, and both `**bold**` and `__bold__`. In Documark, `_` is strongly associated with underline in the minds of many readers and writers — even though Markdown does not implement underline. To avoid visual confusion, authors SHOULD use `*` and `**` for italic and bold respectively. + +**Combine `@{}` with Markdown emphasis rather than using `@` or `@`.** When a styled emphasis span is needed, the preferred form is: + +```text +@{ .classname }**bold text**@{} +@{ .classname }*italic text*@{} +``` + +This keeps Markdown's own constructs intact, simplifies export, and avoids reliance on `@<>` for elements Markdown already handles. ## 7. Validation and Errors @@ -155,3 +176,234 @@ Implementations MUST reject or fail in these cases: - unterminated `data` or `layout` block - malformed layout block delimiters - layout front matter is not a mapping + +## 8. In-Document Tag Syntax + +Documark reserves the `@` prefix combined with a brace-like delimiter for within-document directives. These controls are distinct from `!!` directives, which are document-level structure only. + +The four reserved sigil families are: + +| Sigil | Purpose | +|-------|---------| +| `@{ }` | Block attribute and inline span tags (classes, IDs, data attributes) | +| `@[ ]` | Semantic block element directives | +| `@< >` | Inline HTML element directives | +| `@( )` | Reserved — purpose not yet defined | + +These forms are visually distinctive in plain text and form a uniform family that is straightforward to target with editor syntax highlighting rules. + +### 8.1 `@{}` — Block Attribute and Inline Span Tags + +`@{}` applies CSS classes, element IDs, and `data-*` attributes to Markdown content. It has three scope modes determined by position. + +#### Supported attributes + +| Notation | Output | +|----------|--------| +| `.classname` | Added to `class="..."` | +| `#identifier` | Sets `id="..."` | +| `data-key="value"` | Sets `data-key="value"` (quoted) | +| `data-key=value` | Sets `data-key="value"` (unquoted shorthand) | + +`style=` inline values are NOT supported. Use CSS classes. + +ARIA attributes are NOT supported as authored attributes. Semantic elements (section 8.2) carry implicit ARIA roles. Cases requiring explicit ARIA are handled via pass-through HTML. + +#### Single-block form + +`@{ }` on its own line with content immediately following (no blank line between) wraps the next Markdown block in a `
`. + +```text +@{ .chapter-intro } +This paragraph is wrapped in a div with class "chapter-intro". + +The next paragraph is not wrapped. +``` + +Rules: + +- The tag line MUST contain only the `@{ }` directive and nothing else. +- There MUST be no blank line between the tag line and the first line of the block. +- Only the immediately following block (up to the next blank line) is wrapped. +- Output element is `
`. + +#### Section form + +`@{ }` on its own line with a blank line immediately following opens a section that wraps all content until an empty close marker `@{}`. + +```text +@{ .warning } + +First paragraph inside the section. + +Second paragraph also inside. + +@{} +``` + +Rules: + +- There MUST be a blank line between the tag line and the first content line. +- The close marker is `@{}` (the `@{}` form with empty content) on its own line. +- Everything between open and close is rendered as Markdown and wrapped in `
`. +- Output element is `
`. + +#### Inline span form + +`@{ }` appearing within a line of text (not on its own line) applies a `` to inline content. + +```text +This sentence has @{ .highlight } one word highlighted. + +This sentence has @{ .highlight } several highlighted words @{} and then normal text. +``` + +Rules: + +- If no close marker `@{}` is present before the end of the block, the tag applies to the **next word only**. +- If `@{}` (empty close) is encountered before end-of-block, the span covers content between open and close. +- Output element is ``. + +--- + +### 8.2 `@[]` — Semantic Element Directives + +`@[]` introduces HTML5 semantic elements that have no direct Markdown equivalent: `aside`, `section`, `article`, `figure`, `figcaption`, `header`, `footer`, and others. + +The distinction from `@{}` is intentional: + +- `@{}` is a styling and attribute overlay; it always outputs `
` or ``. +- `@[]` is structural; it outputs the named HTML element. + +`@[]` accepts the same attribute notation as `@{}`: `.class`, `#id`, `data-*`. + +The element name in `@[]` is not validated against a whitelist. Unknown element names pass through to the HTML output; browsers ignore elements they do not recognise. + +#### Single-block form + +`@[element]` on its own line with content immediately following (no blank line between) wraps the next block in the named element. + +```text +@[aside .literary-note] +This paragraph becomes the content of an aside element. + +The next paragraph is outside the aside. +``` + +Rules: + +- No blank line between the tag line and the first line of the block. +- Only the immediately following block is wrapped. +- No close marker required for single-block form. + +#### Section form + +`@[element]` on its own line with a blank line immediately following opens a section closed by `@[/element]`. + +```text +@[aside .note] + +First paragraph inside the aside. + +Second paragraph also inside. + +@[/aside] +``` + +Rules: + +- There MUST be a blank line between the tag line and the first content line. +- The close marker is `@[/element]` where `element` matches the opening element name exactly. +- A mismatched close marker (e.g. `@[/article]` inside an `@[aside]` section) is treated as content, not as a close. +- Everything between open and close is rendered as Markdown inside the named element. + +#### Inline use + +`@[]` does not have an inline form. Markdown's own syntax covers inline semantic elements (`*em*`, `**strong**`, `` `code` ``). Using Markdown's native forms is preferred because it keeps `.dm` files cleanly extractable to plain Markdown. + +--- + +### 8.3 Namespace Reservation + +`@()` is reserved and MUST NOT be used in documents. Its purpose is not yet defined. Implementations MAY warn on encountering this form and SHOULD pass it through as literal text. + +### 8.4 `@<>` — Inline Element Directives + +`@<>` introduces inline HTML elements that have no Markdown equivalent: ``, ``, ``, ``, ``, ``, ``, ``, ``, and others. The angle-bracket sigil visually echoes HTML and signals that this is a named HTML element rather than a CSS class overlay. + +The taxonomy of in-document tag forms is: + +| Sigil | Output | Scope | +|-------|--------|-------| +| `@{}` | `
` or `` | block, section, or inline — styling overlay | +| `@[]` | named block-level element | block or section | +| `@<>` | named inline element | inline only | + +`@<>` accepts the same attribute notation as `@{}` and `@[]`: `.class`, `#id`, `data-*`. + +#### Closure rules + +`@<>` follows the same rules as the inline span form of `@{}`: scope is determined by whether an explicit close is present. + +```text +This is @ underlined @ text. + +This is @ word only (unterminated, applies to next word). +``` + +- If an explicit close `@` is present, the span covers all content between open and close. +- If no close is encountered before end-of-block, the directive applies to the **next word only**. +- `@<>` MUST NOT be used in block or section position (on its own line). It is inline-only. + +#### Elements with Markdown equivalents + +`@<>` is intended for inline HTML elements that Markdown cannot express. It SHOULD NOT be used for elements that Markdown already handles natively: `b`, `i`, `strong`, `em`, `code`, `s`, `strike`. + +When combining a CSS class with a Markdown-native emphasis element, use `@{}` inline span syntax and embed the Markdown notation inside it: + +```text +@{ .prismatic }**Shimmering**@{} +``` + +This renders as `Shimmering` and round-trips cleanly to plain Markdown by stripping the span wrapper and preserving the `**Shimmering**` content. By contrast, `@Shimmering@` would require semantic knowledge of which HTML elements are Markdown-equivalent to export correctly, and is therefore non-preferred. + +#### Recommended usage: underline + +Markdown has no underline syntax. `@` is the Documark idiomatic form: + +```text +This word is @ underlined @ in the output. +``` + +Authors should be aware that underline is strongly associated with hyperlinks in screen rendering. Using `@` for decorative emphasis in screen output may confuse readers. CSS `@media print` rules can be used to apply underline in print contexts only. + +`@` is available as a semantically richer alternative when the intent is to mark inserted or added text (rendered as underline by browsers by default). + +#### Export and round-trip behaviour + +When a Documark document is rendered back to plain Markdown or another format that does not support those inline HTML elements natively, `@<>` directives SHOULD be stripped — the enclosed text is preserved, the element wrapper is discarded. Implementations MAY offer an option to emit the directives as raw inline HTML instead, but this reference implementation does not provide that option. + +### 8.5 Escaping Tag-Like Content + +To include literal text that would otherwise be recognised as a Documark tag, prefix the `@` with a backslash: `\@`. Additionally the `!!` sequence at the beginning of a line should be escaped. + +```text +\@{ .green } +\@[aside] +\@[/aside] +\!! +``` + +The backslash escape is consistent with Markdown's own escape convention (`\*`, `\#`, etc.), which authors already know. + +Rules: + +- A `\@` sequence at the start of a line (after stripping leading whitespace) MUST be treated as literal text, not as a tag directive. +- A `\@` sequence within a line MUST be treated as a literal `@`. +- The leading backslash is consumed by the processor and MUST NOT appear in the output. +- Escaping applies to all `@`-sigil forms: `\@{}`, `\@[]`, `\@()`, `\@<>`. +- A `\!!` at the start of a body line prevents the `!!` from being misread as a directive; the backslash is handled by the Markdown engine (which treats `\!` as a literal `!`) — no special Documark processor handling is required. +- A backslash not immediately followed by `@` is passed through unchanged and handled by the Markdown engine according to its own escape rules. + +It is strongly recommended that document authors escape any `@`-sigil-like text that appears in running prose or headings and is not intended as a directive — for example when describing Documark syntax within a Documark document. Processors are not required to detect or reject unescaped tag-like text that appears in non-directive positions; behaviour in such cases is not defined. + diff --git a/lib/documark/default.dml b/lib/documark/default.dml index 0a896f8..6bfa87a 100644 --- a/lib/documark/default.dml +++ b/lib/documark/default.dml @@ -3,10 +3,11 @@ --- # Pico styles semantic HTML directly, so headings, paragraphs, lists, tables, -# and code blocks look reasonable without adding framework-specific classes. # Other good options for Markdown-oriented documents include Sakura, MVP.css,# and Simple.css if you want a different visual tone with the same -# semantic-HTML-first approach. If you want to use a non-semantic -# framework, see https://kramdown.gettalong.org/quickref.html#block-attributes -# to embed classes in your markdown. +# and code blocks look reasonable without adding framework-specific classes. +# Other good options for Markdown-oriented documents include Sakura, MVP.css, +# and Simple.css if you want a different visual tone with the same +# semantic-HTML-first approach. To apply classes to blocks or use semantic +# HTML elements, use Documark's @{} and @[] tag syntax. stylesheets: - "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" diff --git a/lib/documark/render_html.rb b/lib/documark/render_html.rb index b512014..52c7aad 100644 --- a/lib/documark/render_html.rb +++ b/lib/documark/render_html.rb @@ -3,6 +3,7 @@ require 'erb' require 'kramdown' require_relative 'runtime' +require_relative 'tag_processor' module Documark module RenderHtml @@ -15,7 +16,9 @@ def compose_html(options, layout, data, html) end def render_page(options, layout, data, body) - html = Kramdown::Document.new(body).to_html + prepped, registry = Documark::TagProcessor.preprocess(body) + html = Kramdown::Document.new(prepped).to_html + html = Documark::TagProcessor.postprocess(html, registry) compose_html(options, layout, data, html) end diff --git a/lib/documark/tag_processor.rb b/lib/documark/tag_processor.rb new file mode 100644 index 0000000..14b9985 --- /dev/null +++ b/lib/documark/tag_processor.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +module Documark + module TagProcessor + # Matches a @{ ... } line that is the entire (stripped) line + BLOCK_TAG_RE = /\A@\{([^}]*)\}\z/ + # Matches a @[element attrs] line that is the entire (stripped) line + SEMANTIC_TAG_RE = /\A@\[([a-zA-Z][a-zA-Z0-9_-]*)([^\]]*)\]\z/ + # Matches a @[/element] closing line + SEMANTIC_CLOSE_RE = /\A@\[\/([a-zA-Z][a-zA-Z0-9_-]*)\]\z/ + # Finds placeholders in post-processed HTML + PLACEHOLDER_RE = // + # Sentinel used internally to protect \@ escape sequences during inline processing + ESCAPE_SENTINEL = "\x00DMAT\x00" + + module_function + + # Phase 1: replace @{} and @[] directives with HTML comment placeholders. + # Returns [body_with_placeholders, registry] where registry maps int → HTML string. + # Markdown content is left untouched so Kramdown processes it normally. + def preprocess(body) + Preprocessor.new(body).run + end + + # Phase 2: substitute placeholders emitted by preprocess with actual HTML tags. + def postprocess(html, registry) + html.gsub(PLACEHOLDER_RE) { registry[Regexp.last_match(1).to_i] || Regexp.last_match(0) } + end + + # Convenience method: preprocess + postprocess without Kramdown in between. + # Used directly by unit tests; real rendering goes through render_html.rb. + def process(body) + prepped, registry = preprocess(body) + postprocess(prepped, registry) + end + + # Parse @{} / @[] attribute string into an HTML attribute string. + # Supports: .classname #id data-key="value" data-key=value + def build_attrs(str) + return "" if str.nil? || str.strip.empty? + + classes = [] + id = nil + data_attrs = {} + + str.scan(/\.([a-zA-Z][a-zA-Z0-9_-]*)/) { |m| classes << m[0] } + str.scan(/#([a-zA-Z][a-zA-Z0-9_-]*)/) { |m| id = m[0] } + + # Quoted data attributes first + str.scan(/(data-[a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/) { |k, v| data_attrs[k] = v } + # Unquoted data attributes (||= so quoted value takes precedence) + str.scan(/(data-[a-zA-Z][a-zA-Z0-9_-]*)=(\S+)/) do |k, v| + data_attrs[k] ||= v.delete('"') + end + + parts = [] + parts << %( class="#{classes.join(' ')}") unless classes.empty? + parts << %( id="#{id}") if id + data_attrs.each { |k, v| parts << %( #{k}="#{v}") } + parts.join + end + + # Internal: walks the body line-by-line, replacing @{} and @[] directives + # with HTML comment placeholders and recording the real tags in a registry. + class Preprocessor + def initialize(body) + @lines = body.lines(chomp: true) + @registry = {} + @n = 0 + @out = [] + end + + def run + i = 0 + while i < @lines.length + line = @lines[i].strip + + if line.start_with?('\\@') + # Escaped @-sigil: strip the backslash, pass through as literal text + @out << @lines[i].sub('\\@', '@') + i += 1 + elsif (m = line.match(BLOCK_TAG_RE)) + attrs = TagProcessor.build_attrs(m[1]) + section_mode = @lines[i + 1].nil? || @lines[i + 1].strip.empty? + open_ph = placeholder("") + close_ph = placeholder("
") + + if section_mode + @out << open_ph + @out << "" + i += 1 + i += 1 while i < @lines.length && @lines[i].strip.empty? + while i < @lines.length + cur = @lines[i].strip + close_m = cur.match(BLOCK_TAG_RE) + if close_m && close_m[1].strip.empty? + @out << close_ph + i += 1 + break + end + @out << inline(@lines[i]) + i += 1 + end + else + @out << open_ph + @out << "" + i += 1 + while i < @lines.length && !@lines[i].strip.empty? + @out << inline(@lines[i]) + i += 1 + end + @out << "" + @out << close_ph + end + + elsif (m = line.match(SEMANTIC_TAG_RE)) + element = m[1] + attrs = TagProcessor.build_attrs(m[2]) + section_mode = @lines[i + 1].nil? || @lines[i + 1].strip.empty? + open_ph = placeholder("<#{element}#{attrs}>") + close_ph = placeholder("") + + if section_mode + @out << open_ph + @out << "" + i += 1 + i += 1 while i < @lines.length && @lines[i].strip.empty? + while i < @lines.length + cur = @lines[i].strip + close_m = cur.match(SEMANTIC_CLOSE_RE) + if close_m && close_m[1] == element + @out << close_ph + i += 1 + break + end + @out << inline(@lines[i]) + i += 1 + end + else + @out << open_ph + @out << "" + i += 1 + while i < @lines.length && !@lines[i].strip.empty? + @out << inline(@lines[i]) + i += 1 + end + @out << "" + @out << close_ph + end + + else + @out << inline(@lines[i]) + i += 1 + end + end + + [@out.join("\n"), @registry] + end + + private + + def placeholder(html) + key = @n + @registry[key] = html + @n += 1 + "" + end + + # Replace inline @{} span markers with placeholders. + # Explicit close: @{ .foo } some words @{} → span wrapping "some words" + # Unterminated: @{ .foo } word → span wrapping next word only + # Escaped: \@{ .foo } → literal @{ .foo } (backslash consumed) + def inline(line) + return line unless line.include?('@') + + # Protect \@ escape sequences before any tag processing. + result = line.gsub('\\@', ESCAPE_SENTINEL) + + if result.include?('@{') + result = result.gsub(/@\{([^}]+)\}(.*?)@\{\}/) do + open_ph = placeholder("") + close_ph = placeholder("
") + "#{open_ph}#{Regexp.last_match(2).strip}#{close_ph}" + end + + result = result.gsub(/@\{([^}]+)\}\s*(\S+)/) do + open_ph = placeholder("") + close_ph = placeholder("") + "#{open_ph}#{Regexp.last_match(2)}#{close_ph}" + end + end + + if result.include?('@<') + result = result.gsub(/@<([a-zA-Z][a-zA-Z0-9_-]*)([^>]*)>(.*?)@<\/\1>/) do + element = Regexp.last_match(1) + attrs = Regexp.last_match(2) + content = Regexp.last_match(3).strip + open_ph = placeholder("<#{element}#{TagProcessor.build_attrs(attrs)}>") + close_ph = placeholder("") + "#{open_ph}#{content}#{close_ph}" + end + + result = result.gsub(/@<([a-zA-Z][a-zA-Z0-9_-]*)([^>]*)>\s*(\S+)/) do + element = Regexp.last_match(1) + attrs = Regexp.last_match(2) + word = Regexp.last_match(3) + open_ph = placeholder("<#{element}#{TagProcessor.build_attrs(attrs)}>") + close_ph = placeholder("") + "#{open_ph}#{word}#{close_ph}" + end + end + + # Restore escaped @-sigils (backslash consumed, bare @ remains) + result.gsub(ESCAPE_SENTINEL, '@') + end + end + end +end diff --git a/test/tag_processor_test.rb b/test/tag_processor_test.rb new file mode 100644 index 0000000..e0689ad --- /dev/null +++ b/test/tag_processor_test.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require_relative 'test_helper' +require_relative '../lib/documark/tag_processor' + +class TagProcessorTest < Minitest::Test + # --------------------------------------------------------------------------- + # build_attrs + # --------------------------------------------------------------------------- + + def test_build_attrs_returns_empty_for_blank_input + assert_equal '', Documark::TagProcessor.build_attrs('') + assert_equal '', Documark::TagProcessor.build_attrs(nil) + assert_equal '', Documark::TagProcessor.build_attrs(' ') + end + + def test_build_attrs_single_class + assert_equal ' class="green"', Documark::TagProcessor.build_attrs('.green') + end + + def test_build_attrs_multiple_classes + assert_equal ' class="green larger"', Documark::TagProcessor.build_attrs('.green .larger') + end + + def test_build_attrs_id + assert_equal ' id="intro"', Documark::TagProcessor.build_attrs('#intro') + end + + def test_build_attrs_class_and_id + result = Documark::TagProcessor.build_attrs('.warning #note') + assert_includes result, 'class="warning"' + assert_includes result, 'id="note"' + end + + def test_build_attrs_quoted_data_attribute + result = Documark::TagProcessor.build_attrs('data-section="1"') + assert_includes result, 'data-section="1"' + end + + def test_build_attrs_unquoted_data_attribute + result = Documark::TagProcessor.build_attrs('data-x=foo') + assert_includes result, 'data-x="foo"' + end + + def test_build_attrs_quoted_takes_precedence_over_unquoted + result = Documark::TagProcessor.build_attrs('data-x="bar"') + assert_includes result, 'data-x="bar"' + refute_includes result, '"'*3 + end + + # --------------------------------------------------------------------------- + # @{} single-block form + # --------------------------------------------------------------------------- + + def test_block_tag_single_block_wraps_next_block_in_div + body = "@{ .green }\nThis is a paragraph." + result = Documark::TagProcessor.process(body) + assert_includes result, '
' + assert_includes result, 'This is a paragraph.' + assert_includes result, '
' + end + + def test_block_tag_single_block_does_not_consume_following_blank_line_block + body = "@{ .green }\nFirst block.\n\nSecond block." + result = Documark::TagProcessor.process(body) + assert_includes result, '
' + assert_includes result, 'First block.' + assert_includes result, '
' + assert_includes result, 'Second block.' + # Second block should NOT be inside the div + div_content = result[/]*>(.*?)<\/div>/m, 1] + refute_includes div_content.to_s, 'Second block.' + end + + # --------------------------------------------------------------------------- + # @{} section form + # --------------------------------------------------------------------------- + + def test_block_tag_section_wraps_multiple_blocks + body = "@{ .warning }\n\nFirst paragraph.\n\nSecond paragraph.\n\n@{}" + result = Documark::TagProcessor.process(body) + assert_includes result, '
' + assert_includes result, 'First paragraph.' + assert_includes result, 'Second paragraph.' + assert_includes result, '
' + end + + def test_block_tag_section_with_id_and_data + body = "@{ .box #main data-level=\"2\" }\n\nContent.\n\n@{}" + result = Documark::TagProcessor.process(body) + assert_includes result, 'class="box"' + assert_includes result, 'id="main"' + assert_includes result, 'data-level="2"' + assert_includes result, '
' + end + + # --------------------------------------------------------------------------- + # @{} inline/span form + # --------------------------------------------------------------------------- + + def test_inline_tag_unterminated_wraps_next_word + body = "Text @{ .highlight } word more text." + result = Documark::TagProcessor.process(body) + assert_includes result, 'word' + assert_includes result, 'more text.' + end + + def test_inline_tag_with_explicit_close + body = "Text @{ .highlight } a run of words @{} done." + result = Documark::TagProcessor.process(body) + assert_includes result, 'a run of words' + assert_includes result, 'done.' + end + + # --------------------------------------------------------------------------- + # @[] single-block form + # --------------------------------------------------------------------------- + + def test_semantic_tag_single_block_wraps_in_named_element + body = "@[aside .callout]\nThis is an aside." + result = Documark::TagProcessor.process(body) + assert_includes result, '' + end + + def test_semantic_tag_single_block_no_classes + body = "@[figure]\nAn image description." + result = Documark::TagProcessor.process(body) + assert_includes result, '
' + assert_includes result, 'An image description.' + assert_includes result, '
' + end + + # --------------------------------------------------------------------------- + # @[] section form + # --------------------------------------------------------------------------- + + def test_semantic_tag_section_wraps_multiple_blocks + body = "@[aside .note]\n\nFirst.\n\nSecond.\n\n@[/aside]" + result = Documark::TagProcessor.process(body) + assert_includes result, '' + end + + def test_semantic_tag_section_close_requires_matching_element + # @[/article] should NOT close an @[aside] section + body = "@[aside]\n\nContent.\n@[/article]\nMore.\n@[/aside]" + result = Documark::TagProcessor.process(body) + assert_includes result, '' + # The @[/article] line should appear in content (not consumed as close) + assert_includes result, '@[/article]' + end + + # --------------------------------------------------------------------------- + # Pass-through: lines without tags are unchanged + # --------------------------------------------------------------------------- + + def test_plain_markdown_is_unchanged + body = "# Heading\n\nA paragraph.\n\n- item one\n- item two" + result = Documark::TagProcessor.process(body) + assert_includes result, '# Heading' + assert_includes result, 'A paragraph.' + assert_includes result, '- item one' + end + + # --------------------------------------------------------------------------- + # Escaping: \@ suppresses tag processing + # --------------------------------------------------------------------------- + + def test_escaped_block_tag_on_own_line_is_literal + body = "\\@{ .green }\nThis paragraph is NOT wrapped." + result = Documark::TagProcessor.process(body) + refute_includes result, '' + assert_includes result, '' + assert_includes result, '@[/aside]' + end + + def test_escaped_inline_tag_is_literal + body = "Text \\@{ .highlight } word more text." + result = Documark::TagProcessor.process(body) + refute_includes result, ' inline element form + # --------------------------------------------------------------------------- + + def test_inline_element_explicit_close + body = "Text @ underlined text @ done." + result = Documark::TagProcessor.process(body) + assert_includes result, 'underlined text' + assert_includes result, 'done.' + end + + def test_inline_element_unterminated_wraps_next_word + body = "Text @ word more text." + result = Documark::TagProcessor.process(body) + assert_includes result, 'word' + assert_includes result, 'more text.' + end + + def test_inline_element_with_class + body = "Text @ important @ done." + result = Documark::TagProcessor.process(body) + assert_includes result, 'important' + assert_includes result, 'done.' + end + + def test_inline_element_with_id + body = "See @ this @ below." + result = Documark::TagProcessor.process(body) + assert_includes result, 'this' + assert_includes result, 'below.' + end + + def test_escaped_inline_element_is_literal + body = "Text \\@ word more text." + result = Documark::TagProcessor.process(body) + refute_includes result, 'word' + assert_includes result, '@' + assert_includes result, 'word more text.' + end +end