red_quilt 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 689739c1f6cf971cbeaacdbb65b50fcf7600cecef38bc25aa607ab5e87a559e5
4
- data.tar.gz: 2d57a5a5993bfb8352c18ec8c6da5dc06169d59198e72870399c85d4cc1e1769
3
+ metadata.gz: 211bfba5adede6552fb64c3dfadf6c895325af2c58a577eadb86c8d426a3dd0b
4
+ data.tar.gz: 2e542ae344e626dc5a1bf0198b80761dfac3317b48f11011569235e5418048c9
5
5
  SHA512:
6
- metadata.gz: b3f3b90749d7307db9121d9f1af968a72e94cad5aa06a3f9cf54505ff047e482246f5aa2c0fa14f1b74d9b3d03b516629b3f1c4e57e5ecb6cf5d171d4bc2b6f6
7
- data.tar.gz: f8a8929d8075e0c9e3ec32145daac57ca4565ff299bed3f119faee01f5253727d44acea4dacac579ee676715138e4f03f16e0555544b4b25ee1e30c779db1de5
6
+ metadata.gz: 8526155c616d353741dd68ad7740a0ad3d18e08139c239a6d2ff2d48198721e4b1a84a8da3eca98dae033d1da73a59817a766fe0b3bf89391897559a0fc37047
7
+ data.tar.gz: 272a4918326acc93dc8699308a8597c9e621f98518f0307ef7a8f2eed9ba03dc3153f2fbea641e6b02eb76af0adf612a7d8e8fca7b08360c130d1e34b7b5266c
data/CHANGELOG.md CHANGED
@@ -7,8 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-07-20
11
+
12
+ ### Changed
13
+
14
+ - **Breaking: source positions now follow the unist Point convention**, which
15
+ cmark sourcepos and mdast both use. Three related changes:
16
+ - `source_location` columns are 1-based (they were 0-based; lines were
17
+ already 1-based). Both are counted in characters, and `end` remains
18
+ exclusive.
19
+ - Block spans cover the block as authored rather than its content alone.
20
+ `# H1` now starts at the `#`, `> quote` at the `>`, a list item at its
21
+ bullet, a fenced code block includes both fences, and a setext heading
22
+ includes its `===` / `---` underline.
23
+ - Leading indent is excluded from block spans, so ` text` starts at
24
+ column 3 rather than column 1. This applies to ATX and setext headings,
25
+ paragraphs, blockquotes, fenced code blocks, lists, list items, and
26
+ thematic breaks.
27
+ - The fenced code block change also fixes a wrong *line* number: the span
28
+ used to start at the first content line, reporting the block one line
29
+ below its opening fence.
30
+
31
+ Every expected value was verified against commonmark.js and
32
+ mdast-util-from-markdown; RedQuilt now agrees with mdast on all of them
33
+ except an unclosed fence, where the two references disagree and RedQuilt
34
+ follows cmark. Callers that added 1 to columns, or that relied on a
35
+ heading span excluding `# `, need updating.
36
+
37
+ `NodeRef#text` is unaffected: a heading's text remains its content, without
38
+ the marker.
39
+
40
+ - **Breaking: `NodeRef#info` returns nil for nodes that are not code
41
+ blocks**, where 0.8.0 returned `""`. A code block written without an info
42
+ string still returns `""`, so the two cases are now distinguishable. This
43
+ matches the new attribute accessors, which all use nil for "this node's
44
+ type does not carry the attribute".
45
+ - Lowered the minimum supported Ruby from 3.3 to 3.1. The only 3.1
46
+ incompatibility was `String#byteindex` (added in Ruby 3.2), used in two
47
+ inline hot paths; both operate on a binary (`String#b`) view of the source,
48
+ where `String#index` returns the same byte offsets. CI now runs the test
49
+ suite on 3.1 through 4.0.
50
+ - `Gemfile.lock` is no longer committed. A lockfile resolved on one Ruby pins
51
+ development gem versions that do not exist for the others (e.g. `rdoc 8.0.0`
52
+ and `rbs 4.0.3` have no Ruby 3.1 release), so a single committed lockfile
53
+ cannot serve the supported range. Each Ruby now resolves its own.
54
+
55
+ ### Fixed
56
+
57
+ - `source_location` reported wrong line numbers for any source containing a
58
+ multibyte character. The line-start table is indexed by byte offset, but it
59
+ was built with `String#index`, which counts characters, so every line after
60
+ the first multibyte character drifted. Existing multibyte coverage did not
61
+ catch this because it used single-line sources.
62
+ - `Renderer::MDAST` emitted positions that violated the unist spec: `column`
63
+ was 0-based where unist requires 1-based, and `offset` was a byte index
64
+ where unist requires a 0-based character index. The latter made every
65
+ offset after a multibyte character wrong.
66
+
67
+ ### Added
68
+
69
+ - Node attribute accessors on `NodeRef`: `heading_level`, `list_ordered?`,
70
+ `list_start`, `list_tight?`, `list_delimiter`, `link_destination`,
71
+ `link_title`, `footnote_label`, `footnote_number`, and `header?`. Each
72
+ returns nil when the node's type does not carry the attribute, so callers
73
+ can walk a document branching on `#type` and read that type's fields
74
+ directly. Previously the only public route was `#to_h`, which builds a Hash
75
+ for the whole subtree — 5391 objects for a 257-node list where these
76
+ accessors allocate none.
77
+
78
+ These wrappers are the safe way to read attributes. The `Arena` accessors
79
+ they delegate to are the raw layer and skip the type check, and several
80
+ attributes share a storage column, so reading one off a mismatched node
81
+ there returns another field's value rather than nil (`Arena#link_destination`
82
+ on a paragraph returns the paragraph's text).
83
+
84
+ ## [0.8.0] - 2026-06-24
85
+
86
+ ### Added
87
+
88
+ - `NodeRef#info`: returns the fence info string of a code block (e.g. `ruby`
89
+ in ` ```ruby `, or `vtt audio="x.mp3"`); `""` for code blocks without one
90
+ and for every other node type. The raw code content remains available via
91
+ `NodeRef#text`.
92
+ - `Renderer::HTML#render_fragment(nodes)`: renders an Array of `NodeRef` in
93
+ order and returns the HTML fragment without affecting the main render
94
+ output. Renderer state shared across nodes (e.g. the heading-id slugger) is
95
+ preserved between calls. Lets callers that partition a document render the
96
+ pieces separately without reaching into renderer internals.
97
+ - `Arena` semantic payload accessors (`heading_level`, `list_ordered?`,
98
+ `code_block_info`, `link_destination`, `footnote_number`, …) for callers
99
+ that walk `Document#arena`, replacing direct use of the raw `int1`/`str2`
100
+ columns.
101
+
102
+ ## [0.7.2] - 2026-06-23
103
+
10
104
  ### Added
11
105
 
106
+ - Opt-in YAML frontmatter support via the `frontmatter:` option on `parse` /
107
+ `render_html` and the `--frontmatter` CLI flag (off by default). A leading
108
+ `---` … `---` block is removed from the rendered body and exposed as
109
+ `Document#frontmatter`; in standalone output its `title` / `lang` keys fill
110
+ in `<title>` / `<html lang>`.
12
111
  - Opt-in Mermaid diagram support via the `mermaid:` option on `render_html` /
13
112
  `Document#to_html` and the `--mermaid` CLI flag (off by default). Fenced
14
113
  ` ```mermaid ` code blocks render as `<pre class="mermaid">` containers; in
data/README.md CHANGED
@@ -48,6 +48,7 @@ RedQuilt.render_html("Hi <em>tag</em>", allow_html: true)
48
48
  | `extended_autolinks:` | `false` | GFM: linkify bare `http(s)://` / `www.` / email addresses |
49
49
  | `footnotes:` | `false` | GFM footnotes (see below) |
50
50
  | `lint:` | `false` | Collect lint diagnostics (empty links, missing image alt, heading-level skips) |
51
+ | `frontmatter:` | `false` | Parse leading YAML frontmatter (`---`) as metadata (see below) |
51
52
 
52
53
  ### Footnotes (opt-in)
53
54
 
@@ -100,6 +101,41 @@ RedQuilt.parse("```mermaid\ngraph LR\n A --> B\n```")
100
101
  In standalone output each diagram is made interactive with
101
102
  [svg-pan-zoom](https://github.com/bumbu/svg-pan-zoom) (loaded from a CDN).
102
103
 
104
+ ### YAML frontmatter (opt-in)
105
+
106
+ `parse` / `render_html` accept `frontmatter:` to extract a leading YAML
107
+ frontmatter block (the `---` … `---` fences used by Jekyll, Hugo).
108
+ The block is parsed with `Psych.safe_load` and removed from the rendered body;
109
+ the parsed Hash is exposed as `Document#frontmatter`.
110
+
111
+ ```ruby
112
+ doc = RedQuilt.parse(<<~MD, frontmatter: true)
113
+ ---
114
+ title: My Page
115
+ lang: ja
116
+ ---
117
+
118
+ # Body
119
+ MD
120
+ doc.frontmatter # => {"title" => "My Page", "lang" => "ja"}
121
+ doc.to_html # => "<h1>Body</h1>\n" (frontmatter stripped)
122
+ ```
123
+
124
+ In standalone output the frontmatter's `title` / `lang` fill in `<title>` /
125
+ `<html lang>` when no explicit argument is given (explicit argument >
126
+ frontmatter > default):
127
+
128
+ ```ruby
129
+ doc.to_html(standalone: true)
130
+ # <html lang="ja"> … <title>My Page</title> …
131
+ ```
132
+
133
+ The feature is opt-in, so a bare `---` is never mistaken for frontmatter
134
+ unless `frontmatter: true` is passed. Frontmatter lines are blanked rather
135
+ than deleted, so body source spans and diagnostic line numbers stay relative
136
+ to the start of the file. Invalid YAML records a `:frontmatter` warning
137
+ diagnostic and leaves `Document#frontmatter` as `nil` without raising.
138
+
103
139
  ### Tilt integration
104
140
 
105
141
  RedQuilt ships a [Tilt](https://github.com/jeremyevans/tilt) adapter.
@@ -186,6 +222,8 @@ redquilt --mermaid --open input.md
186
222
  Dir.tmpdir when -o is not given)
187
223
  --mermaid Render `mermaid` code blocks as diagrams (loads
188
224
  mermaid.js from a CDN in standalone output)
225
+ --frontmatter Parse leading YAML frontmatter (---) as metadata;
226
+ fills <title>/lang in standalone output
189
227
  --diagnostics Print diagnostics to stderr
190
228
  --diagnostics-only Print diagnostics only (suppress output)
191
229
  -h, --help Show help
data/docs/api.md CHANGED
@@ -18,6 +18,7 @@ doc.source_map # Line/column lookup (lazy memoized)
18
18
  doc.diagnostics # Array of RedQuilt::Diagnostic collected while parsing
19
19
  doc.allow_html? # Check HTML pass-through setting
20
20
  doc.disallow_raw_html? # Check GFM disallowed-raw-HTML filtering setting
21
+ doc.frontmatter # Parsed YAML frontmatter Hash, or nil (see below)
21
22
 
22
23
  # Standalone document with an embedded theme:
23
24
  doc.to_html(standalone: true, theme: :default, title: "My Doc", lang: "en")
@@ -27,6 +28,15 @@ doc.to_html(standalone: true, theme: :default, title: "My Doc", lang: "en")
27
28
  # Render `mermaid` code blocks as <pre class="mermaid"> diagrams; in
28
29
  # standalone mode the mermaid.js runtime is loaded from a CDN too.
29
30
  doc.to_html(standalone: true, mermaid: true)
31
+
32
+ # Parse a leading YAML frontmatter block (--- ... ---). Off by default; when
33
+ # enabled the block is removed from the rendered body and exposed as a Hash.
34
+ doc = RedQuilt.parse("---\ntitle: Hi\nlang: ja\n---\n\n# Body", frontmatter: true)
35
+ doc.frontmatter # => {"title" => "Hi", "lang" => "ja"} (nil when absent/disabled)
36
+ # In standalone output frontmatter title/lang fill <title>/<html lang> unless
37
+ # an explicit argument overrides them. Invalid YAML adds a :frontmatter
38
+ # warning diagnostic and leaves doc.frontmatter as nil.
39
+ doc.to_html(standalone: true)
30
40
  ```
31
41
 
32
42
  ## NodeRef (AST node wrapper)
@@ -41,12 +51,34 @@ node.walk # Enumerator[NodeRef] or { |node| ... } block
41
51
  node.find_all(:link) # Array[NodeRef] with matching type
42
52
  node.text # String (concatenated child text)
43
53
 
54
+ # Node attributes, by type. Each returns nil when the node's type does not
55
+ # carry the attribute, so you can branch on #type and read that type's own
56
+ # fields without building a Hash per node.
57
+ node.info # CODE_BLOCK: fence info, e.g. "ruby". "" when the
58
+ # block was written without one.
59
+ node.heading_level # HEADING: 1..6
60
+ node.list_ordered? # LIST: true for "1.", false for "-"
61
+ node.list_start # LIST: start number of an ordered list
62
+ node.list_tight? # LIST: tight vs loose
63
+ node.list_delimiter # LIST: delimiter as authored, e.g. "-" or "."
64
+ node.link_destination # LINK / IMAGE: destination URL
65
+ node.link_title # LINK / IMAGE: title, nil when absent
66
+ node.footnote_label # FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: label
67
+ node.footnote_number # FOOTNOTE_DEFINITION / FOOTNOTE_REFERENCE: 1-based
68
+ node.header? # TABLE_ROW / TABLE_CELL: part of the header row?
69
+
44
70
  # Position information (byte offset)
45
71
  node.source_span # SourceSpan with start_byte, end_byte
46
72
 
47
73
  # Position information (line/column)
48
74
  node.source_location # { start_line, start_column, end_line, end_column }
49
- # line: 1-indexed, column: 0-indexed (character-based)
75
+ # line and column are both 1-indexed and counted in
76
+ # characters, following the unist Point convention that
77
+ # cmark sourcepos and mdast use. `end` is exclusive: it
78
+ # is the position just past the node's last character.
79
+ # Block spans cover the block as authored, including
80
+ # markers ("# H1" starts at the "#", "```" fences are
81
+ # part of the code block), but excluding leading indent.
50
82
 
51
83
  # AST export
52
84
  node.to_h # Export subtree as Hash[Symbol, untyped]
@@ -231,6 +231,116 @@ module RedQuilt
231
231
  @str2[id]
232
232
  end
233
233
 
234
+ # --- Semantic payload accessors -------------------------------------
235
+ #
236
+ # int1..int3 / str1 / str2 are anonymous columns; their meaning is
237
+ # per-NodeType (see the class comment). These readers are the single
238
+ # source of truth for those conventions, so callers (renderers, the
239
+ # NodeRef wrapper, AST/MDAST export) never need to know which raw
240
+ # column a field lives in. Writers use add_node's keyword args,
241
+ # update_*, or a small set of typed writers (e.g.
242
+ # #resolve_footnote_definition) when the intent is worth naming.
243
+ #
244
+ # The reader is responsible for calling these only on the matching
245
+ # NodeType; on a mismatching node they return whatever the raw column
246
+ # holds (typically the 0 / nil default).
247
+
248
+ # HEADING: nesting level (1..6).
249
+ def heading_level(id)
250
+ @int1[id]
251
+ end
252
+
253
+ # HEADING: byte range of the heading text alone, excluding the `#` marker
254
+ # and any closing hashes. The node's source_span covers the heading as
255
+ # authored (for position reporting), so inline parsing needs this narrower
256
+ # range to avoid lexing the marker as content. Setext headings carry no
257
+ # marker prefix and leave this zeroed, in which case the span is the
258
+ # content range.
259
+ def heading_content_start(id)
260
+ @int2[id]
261
+ end
262
+
263
+ def heading_content_len(id)
264
+ @int3[id]
265
+ end
266
+
267
+ # LIST: ordered (`1.`) vs bullet (`-`).
268
+ def list_ordered?(id)
269
+ @int1[id] == 1
270
+ end
271
+
272
+ # LIST: the start number of an ordered list (1 unless overridden).
273
+ def list_start(id)
274
+ @int2[id]
275
+ end
276
+
277
+ # LIST: tight (no blank lines between items) vs loose.
278
+ def list_tight?(id)
279
+ @int3[id] == 1
280
+ end
281
+
282
+ # LIST: the item delimiter as authored (e.g. "-", "1.").
283
+ def list_delimiter(id)
284
+ @str1[id]
285
+ end
286
+
287
+ # TABLE_ROW: header row (rendered in <thead>) vs body row.
288
+ def table_row_header?(id)
289
+ @int1[id] == 1
290
+ end
291
+
292
+ # TABLE_CELL: header cell (<th>) vs data cell (<td>).
293
+ def table_cell_header?(id)
294
+ @int1[id] == 1
295
+ end
296
+
297
+ # CODE_BLOCK: the fence info string (e.g. 'ruby', 'vtt audio="x"').
298
+ def code_block_info(id)
299
+ @str2[id]
300
+ end
301
+
302
+ # LINK / IMAGE: destination URL.
303
+ def link_destination(id)
304
+ @str1[id]
305
+ end
306
+
307
+ # LINK / IMAGE: optional title attribute (nil/empty when absent).
308
+ def link_title(id)
309
+ @str2[id]
310
+ end
311
+
312
+ # FOOTNOTE_REFERENCE: the assigned footnote number.
313
+ def footnote_number(id)
314
+ @int1[id]
315
+ end
316
+
317
+ # FOOTNOTE_REFERENCE: which occurrence of a repeated reference this is
318
+ # (1-based), used to give each backref a unique anchor.
319
+ def footnote_occurrence(id)
320
+ @int2[id]
321
+ end
322
+
323
+ # FOOTNOTE_REFERENCE / FOOTNOTE_DEFINITION: the author-written label.
324
+ def footnote_label(id)
325
+ @str1[id]
326
+ end
327
+
328
+ # FOOTNOTE_DEFINITION: total number of references to this footnote,
329
+ # materialized by FootnotePass so renderers can read it off the node
330
+ # instead of consulting the registry. (FOOTNOTE_REFERENCE reuses int2
331
+ # for its own occurrence index; see #footnote_occurrence.)
332
+ def footnote_total_references(id)
333
+ @int2[id]
334
+ end
335
+
336
+ # FOOTNOTE_DEFINITION: records the resolved number and total reference
337
+ # count onto the node (read back via #footnote_number /
338
+ # #footnote_total_references).
339
+ def resolve_footnote_definition(id, number, total_references)
340
+ @int1[id] = number
341
+ @int2[id] = total_references
342
+ end
343
+
234
344
  # Returns a SourceSpan for the node, or nil when the node has no
235
345
  # span (source_start < 0, meaning the content is held in str1).
236
346
  def source_span(id)
@@ -251,6 +361,13 @@ module RedQuilt
251
361
  start_byte = @source_start[id]
252
362
  return nil if start_byte.nil? || start_byte.negative?
253
363
 
364
+ # An ATX heading's span deliberately covers its `#` marker so that
365
+ # positions point at the heading as authored. Text is the content
366
+ # alone, so slice the narrower content range instead.
367
+ if @type[id] == NodeType::HEADING && @int2[id].positive?
368
+ return @source.byteslice(@int2[id], @int3[id])
369
+ end
370
+
254
371
  @source.byteslice(start_byte, @source_len[id])
255
372
  end
256
373