asciidoctor-rhrev 1.3.0 → 1.5.1

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.
@@ -2,16 +2,315 @@
2
2
 
3
3
  /**
4
4
  * Antora extension for revision history (rhrev) block macro.
5
- *
5
+ *
6
6
  * Provides HTML5 backend support for revision history tables.
7
7
  * Auto-injects revision history at document start unless :rhrev: macro or manual mode.
8
- *
8
+ *
9
9
  * Usage in antora-playbook.yml:
10
10
  * antora:
11
11
  * extensions:
12
12
  * - require: ./antora-extension/antora-rhrev-asciidoc.js
13
13
  */
14
14
 
15
+ // Mirrors Converter::ROLLUP_CONTEXTS on the Ruby gem's PDF side and
16
+ // ROLLUP_CONTEXTS in rhrev_html.rb: block types with no revision-history row
17
+ // of their own. A marked one rolls up as a bullet into its nearest
18
+ // enclosing section's entry instead (or the document-level -all entry, if
19
+ // it has no enclosing section, or its enclosing section has no id).
20
+ const ROLLUP_CONTEXTS = ['paragraph', 'ulist', 'olist', 'dlist', 'admonition', 'open', 'quote', 'verse', 'sidebar']
21
+
22
+ // Row-level: gets its own revision-history row when marked, requires an id.
23
+ // Same six contexts collectAllEntries already scanned before this port.
24
+ const ROW_LEVEL_CONTEXTS = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
25
+
26
+ // Walks up from node to its nearest enclosing section, or null if none
27
+ // exists (the document preamble). Same shape as Converter#enclosing_section
28
+ // on the Ruby side.
29
+ function enclosingSection(node) {
30
+ let section = node.getParent ? node.getParent() : null
31
+ while (section && section.getContext && section.getContext() !== 'section') {
32
+ section = section.getParent ? section.getParent() : null
33
+ }
34
+ return section || null
35
+ }
36
+
37
+ // Finds anchorNode's existing entry for this revision on catalog and
38
+ // appends another bullet onto its change text, or creates the entry if
39
+ // anchorNode has none yet. Same shape as Converter#append_child_change_to_entry.
40
+ function appendChildChangeToEntry(catalog, revision, anchorNode, changeText) {
41
+ const entries = catalog.entries[revision] || (catalog.entries[revision] = [])
42
+ const anchorId = anchorNode.getId()
43
+ const entry = entries.find((e) => e.anchor === anchorId)
44
+ if (entry) {
45
+ entry.change = (entry.change && String(entry.change).length) ? `${entry.change} * ${changeText}` : changeText
46
+ } else {
47
+ catalog.addEntry(revision, anchorId, changeText, {
48
+ title: anchorNode.getTitle ? anchorNode.getTitle() : null,
49
+ context: anchorNode.getContext ? anchorNode.getContext() : null
50
+ })
51
+ }
52
+ }
53
+
54
+ // Appends onto catalog's existing -all change text for this revision, or
55
+ // sets it fresh if none exists yet. Same shape as Converter#append_child_change_to_all.
56
+ function appendChildChangeToAll(catalog, revision, changeText) {
57
+ const existing = catalog.allEntries[revision]
58
+ const combined = (existing && String(existing).length) ? `${existing} * ${changeText}` : changeText
59
+ catalog.addAllEntry(revision, combined)
60
+ }
61
+
62
+ // Gathers every marked rollup-context node in one unified, document-order
63
+ // traversal (findBy returns document order on its own, confirmed directly,
64
+ // no separate sort needed). Must run after the row-level scan in
65
+ // collectAllEntries, so a section's own entry, if it is also independently
66
+ // marked, already exists for a child's bullet to append onto instead of
67
+ // racing to create a duplicate.
68
+ function rollupChildRevisionEntries(catalog, doc, prefix) {
69
+ const nodes = doc.findBy((n) => ROLLUP_CONTEXTS.includes(n.getContext()))
70
+ nodes.forEach((node) => {
71
+ const attrs = node.getAttributes()
72
+ if (!attrs) return
73
+ const section = enclosingSection(node)
74
+ const hasSection = !!(section && section.getId())
75
+ Object.keys(attrs).forEach((key) => {
76
+ if (!key.startsWith(prefix)) return
77
+ if (key.includes('-all') || key.endsWith('-cover')) return
78
+ const revision = key.substring(prefix.length)
79
+ const value = String(attrs[key])
80
+ if (hasSection) {
81
+ appendChildChangeToEntry(catalog, revision, section, value)
82
+ } else {
83
+ appendChildChangeToAll(catalog, revision, value)
84
+ }
85
+ })
86
+ })
87
+ }
88
+
89
+ // Table cells cannot carry an rhrev attribute of their own through normal
90
+ // AsciiDoc parsing: a plain (|) cell never parses its content as blocks at
91
+ // all, an attribute list there is just literal text. An AsciiDoc-style (a|)
92
+ // cell does parse its content as blocks, so [rhrev1-2="..."] attaches, but
93
+ // to the first nested block, not the cell, and that nested block lives in a
94
+ // structurally separate inner document (getInnerDocument()), unreachable
95
+ // from the outer document's own findBy. Confirmed directly, same gotcha as
96
+ // the Ruby side.
97
+ //
98
+ // Copies any rhrev-prefixed attribute from a marked nested rollup-context
99
+ // node onto the enclosing cell (for the revision-history entry, see
100
+ // rollupTableCellRevisionEntries below, which needs it on the cell), then
101
+ // strips it from the nested node. For the visual side, tagging the cell's
102
+ // own <td>/<th> needs a converter-level override (see the NOT IMPLEMENTED
103
+ // note further down: built, verified standalone, broke real Antora builds
104
+ // on a sibling package's dependency conflict, dropped). Tagging the nested
105
+ // node itself instead needs no override at all: it renders through the
106
+ // same, completely unmodified converter as any other rollup-context block,
107
+ // which already supports a role. So when this key matches the *current*
108
+ // revision's change attribute and change bars are enabled, the role is set
109
+ // here directly, on the node inside the cell's own inner document, before
110
+ // the attribute is stripped. The outer change-bar tagging treeProcessor
111
+ // cannot reach this on its own: it walks the outer document only, and the
112
+ // inner document is structurally separate, unreachable from the outer
113
+ // document's own findBy, confirmed directly, same gotcha as the Ruby side.
114
+ //
115
+ // Must run at treeprocessor time (see below), not lazily inside
116
+ // collectAllEntries: treeprocessors all run before conversion starts.
117
+ // Safe to call twice (collectAllEntries also calls it directly, for the
118
+ // block-macro's own-page fallback path): once a mark is propagated,
119
+ // tagged, and stripped, a second pass finds nothing left to move.
120
+ function propagateCellContentMarks(doc, prefix) {
121
+ const changeBarsEnabled = doc.hasAttribute('rhrev-change-bars')
122
+ const revnumber = doc.getAttribute('revnumber')
123
+ const changeAttr = (changeBarsEnabled && revnumber) ? prefix + String(revnumber).replace(/\./g, '-') : null
124
+
125
+ doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
126
+ const rows = table.getRows()
127
+ const cells = [].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat())
128
+ cells.forEach((cell) => {
129
+ if (cell.getStyle() !== 'asciidoc') return
130
+ const inner = cell.getInnerDocument ? cell.getInnerDocument() : null
131
+ if (!inner) return
132
+ inner.findBy((n) => ROLLUP_CONTEXTS.includes(n.getContext())).forEach((node) => {
133
+ const attrs = node.getAttributes()
134
+ if (!attrs) return
135
+ Object.keys(attrs).forEach((key) => {
136
+ if (!key.startsWith(prefix)) return
137
+ cell.setAttribute(key, attrs[key])
138
+ if (key === changeAttr) {
139
+ const roles = attrs.role
140
+ node.setAttribute('role', roles ? `${roles} rhrev-changed` : 'rhrev-changed')
141
+ }
142
+ node.removeAttribute(key)
143
+ })
144
+ })
145
+ })
146
+ })
147
+ }
148
+
149
+ // A marked table cell is not a rollup context itself: it rolls up to its
150
+ // own enclosing table, not the nearest section, and cells are not reachable
151
+ // through the same context-based findBy a normal block-level node is, so
152
+ // this walks table.getRows() directly. Same three-tier fallback as
153
+ // rollupChildRevisionEntries: the table's own entry if it has an id, else
154
+ // its nearest enclosing section's entry, else the document-level -all
155
+ // entry only when neither exists. Same shape as
156
+ // Converter#rollup_table_cell_revision_entries / Html5Converter#rollup_table_cell_revision_entries
157
+ // on the Ruby side.
158
+ function rollupTableCellRevisionEntries(catalog, doc, prefix) {
159
+ doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
160
+ const rows = table.getRows()
161
+ const cells = [].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat())
162
+ cells.forEach((cell) => {
163
+ const attrs = cell.getAttributes()
164
+ if (!attrs) return
165
+ Object.keys(attrs).forEach((key) => {
166
+ if (!key.startsWith(prefix)) return
167
+ if (key.includes('-all') || key.endsWith('-cover')) return
168
+ const revision = key.substring(prefix.length)
169
+ const value = String(attrs[key])
170
+ if (table.getId()) {
171
+ appendChildChangeToEntry(catalog, revision, table, value)
172
+ } else {
173
+ const section = enclosingSection(table)
174
+ if (section && section.getId()) {
175
+ appendChildChangeToEntry(catalog, revision, section, value)
176
+ } else {
177
+ appendChildChangeToAll(catalog, revision, value)
178
+ }
179
+ }
180
+ })
181
+ })
182
+ })
183
+ }
184
+
185
+ // The cell's own <td>/<th> DOES carry the rhrev-changed role, via a
186
+ // converter-level override, patched directly onto the SAME live converter
187
+ // class object Antora's own pipeline is already using for this document
188
+ // (doc.getConverter().$$class, reached through Asciidoctor's own public
189
+ // API), never a separately-required or separately-registered class.
190
+ //
191
+ // EARLIER ATTEMPT, SUPERSEDED (kept here since the next engineer will
192
+ // otherwise reach for the same idea first): a real Opal subclass of
193
+ // Html5Converter, registered via ConverterFactory, the same way the Ruby
194
+ // gem's PDF converter is overridden. Built and verified working
195
+ // standalone, but broke real Antora builds: this exact demo site had
196
+ // three separate, non-deduped @asciidoctor/core installs (rhrev, the site
197
+ // itself, and baiyibai-extensions, whose cite-it extension wraps Antora's
198
+ // own document loading), one of them a different version. Antora's own
199
+ // converter/html5.js reads whatever is currently registered as the
200
+ // 'html5' converter and builds a real Opal subclass on top of that; with
201
+ // non-identical copies of the Opal runtime involved, defining a SECOND,
202
+ // independent subclass crashed (Object.setPrototypeOf called on null or
203
+ // undefined), confirmed against a real local build, not assumed. A
204
+ // require('@antora/asciidoc-loader') from inside this file cannot avoid
205
+ // that risk either, even now that baiyibai-extensions' own version drift
206
+ // is fixed (1.15.1): this package ships its own @asciidoctor/core copy
207
+ // for its own test suite, so a plain require() here resolves against
208
+ // THIS package's node_modules first, not necessarily the consuming
209
+ // site's, and remains a live, standing risk of resolving a different
210
+ // copy regardless of what any sibling package currently pins.
211
+ //
212
+ // doc.getConverter() sidesteps the whole class of problem: it returns
213
+ // whichever converter object is ACTUALLY resolved for THIS document, in
214
+ // THIS process, by Antora's own pipeline, guaranteed correct by
215
+ // construction rather than by keeping multiple packages' pins in sync.
216
+ // Confirmed directly: asciidoctor.load(text).getConverter() already
217
+ // returns a real, usable converter instance, with a real $$class, before
218
+ // convert() is ever called, meaning it is available in time from inside a
219
+ // treeprocessor (treeprocessors run during load(), before conversion).
220
+ const PATCHED_CONVERT_TABLE = Symbol.for('rhrev.patchedConvertTable')
221
+
222
+ // Idempotency is keyed on the converter CLASS, not the document: a single
223
+ // Antora build converts many documents against the same resolved
224
+ // converter class object, and redefining $convert_table would otherwise
225
+ // re-wrap the previous wrapper on every single page, nesting deeper each
226
+ // time (and, worse, the second pass would capture the FIRST WRAPPER as
227
+ // its own "original", not the real one, once the reference below is
228
+ // overwritten).
229
+ //
230
+ // Captures the real, original $convert_table function reference directly
231
+ // off the class's own prototype before replacing it, and calls that
232
+ // reference directly, rather than Opal.find_super_dispatcher (Antora's
233
+ // own html5.js pattern, copied from initially, then found wrong for this
234
+ // case, confirmed against a real crash: find_super_dispatcher climbs an
235
+ // inheritance chain, correct when Opal.klass creates a genuine SUBCLASS
236
+ // the way Antora's own converter/html5.js does, but this patches the
237
+ // SAME class object in place, no subclass involved, so there is no
238
+ // superclass boundary to dispatch across; the original implementation
239
+ // lives directly on classDef.$$prototype itself, confirmed directly, and
240
+ // Opal.defn below overwrites that exact slot).
241
+ function patchConvertTableForCellTagging (doc) {
242
+ const Opal = global.Opal
243
+ if (!Opal) return
244
+ const converter = doc.getConverter && doc.getConverter()
245
+ const classDef = converter && converter.$$class
246
+ if (!classDef || classDef[PATCHED_CONVERT_TABLE]) return
247
+ const originalConvertTable = classDef.$$prototype['$convert_table']
248
+ if (typeof originalConvertTable !== 'function') return
249
+ classDef[PATCHED_CONVERT_TABLE] = true
250
+
251
+ Opal.defn(classDef, '$convert_table', function convertTableWithCellTags (node) {
252
+ const html = originalConvertTable.call(this, node)
253
+ return typeof html === 'string' ? liftRhrevChangedToCells(html) : html
254
+ })
255
+ }
256
+
257
+ // Moves the rhrev-changed marker from a marked cell's own nested content
258
+ // (propagateCellContentMarks above) onto its enclosing <td>/<th>, so the
259
+ // site stylesheet highlights the whole cell box, matching the PDF
260
+ // backend's own margin bar spanning the cell's full row height, rather
261
+ // than only the changed paragraph inside it. Operates on the already-
262
+ // rendered HTML string, not the AST: Asciidoctor's real convert_table has
263
+ // no per-cell hook to override cleanly (table rendering is one self-
264
+ // contained method, the same reason the PDF gem needed a Prawn-level
265
+ // patch rather than an ordinary converter override, confirmed against
266
+ // Asciidoctor's own source), so this calls the real, unmodified super()
267
+ // implementation first and only adjusts its output afterward.
268
+ //
269
+ // Finds the nearest PRECEDING <td/<th opening tag before each marker,
270
+ // confirmed against real rendered output (a plain cell's own <p
271
+ // class="tableblock"> vs an a| cell's <div class="content">, both
272
+ // verified). A table nested inside a cell, itself containing its own
273
+ // <td>/<th> tags between the marker and its true enclosing cell, would
274
+ // defeat that heuristic: a known, accepted limitation, not handled.
275
+ function liftRhrevChangedToCells (html) {
276
+ const cellTagRx = /<(td|th)\b[^>]*>/g
277
+ const cellTags = []
278
+ let m
279
+ while ((m = cellTagRx.exec(html))) {
280
+ cellTags.push({ start: m.index, end: m.index + m[0].length, text: m[0] })
281
+ }
282
+ if (cellTags.length === 0) return html
283
+
284
+ const markerRx = /class="[^"]*\brhrev-changed\b[^"]*"/g
285
+ const toPatch = new Set()
286
+ while ((m = markerRx.exec(html))) {
287
+ let owner
288
+ for (const tag of cellTags) {
289
+ if (tag.start <= m.index) owner = tag
290
+ else break
291
+ }
292
+ if (owner) toPatch.add(owner)
293
+ }
294
+ if (toPatch.size === 0) return html
295
+
296
+ let out = ''
297
+ let cursor = 0
298
+ for (const tag of cellTags) {
299
+ if (!toPatch.has(tag)) continue
300
+ out += html.slice(cursor, tag.start)
301
+ out += addRoleClass(tag.text, 'rhrev-changed')
302
+ cursor = tag.end
303
+ }
304
+ out += html.slice(cursor)
305
+ return out
306
+ }
307
+
308
+ function addRoleClass (tagText, role) {
309
+ if (new RegExp(`\\bclass="[^"]*\\b${role}\\b[^"]*"`).test(tagText)) return tagText
310
+ if (/\bclass="/.test(tagText)) return tagText.replace(/\bclass="/, `class="${role} `)
311
+ return tagText.replace(/^<(td|th)\b/, `<$1 class="${role}"`)
312
+ }
313
+
15
314
  // Catalog for storing revision entries
16
315
  class RevisionCatalog {
17
316
  constructor() {
@@ -24,7 +323,7 @@ class RevisionCatalog {
24
323
  if (!this.entries[revision]) {
25
324
  this.entries[revision] = []
26
325
  }
27
-
326
+
28
327
  this.entries[revision].push({
29
328
  anchor,
30
329
  change,
@@ -66,13 +365,13 @@ function isInitialRelease(revnumber) {
66
365
  function formatPrevRev(prevTitle, prevDate, doc) {
67
366
  const includeDateAttr = doc.getAttribute('rhrev-customization-prevrev-include-date')
68
367
  const includeDate = includeDateAttr !== undefined
69
-
368
+
70
369
  if (includeDate && prevDate) {
71
370
  const dateFormat = doc.getAttribute('rhrev-customization-date-format') || '%Y-%m-%d'
72
371
  // Simple date formatting (Node.js doesn't have strftime, keep it simple)
73
372
  return `${prevTitle} ${prevDate}`
74
373
  }
75
-
374
+
76
375
  return prevTitle
77
376
  }
78
377
 
@@ -83,20 +382,20 @@ function preprocessAttributeContent(text) {
83
382
 
84
383
  function formatChangeAsHtmlList(text) {
85
384
  if (!text) return ''
86
-
385
+
87
386
  const processed = preprocessAttributeContent(text)
88
-
387
+
89
388
  // Split by asterisks to create list items
90
389
  const tokens = processed.split(/\s(?=\*+\s)/)
91
390
  const items = []
92
-
391
+
93
392
  if (tokens.length === 1 && !processed.includes('*')) {
94
393
  // Single item without asterisk - wrap in list anyway
95
394
  items.push({ level: 1, content: processed })
96
395
  } else {
97
396
  tokens.forEach(token => {
98
397
  if (!token.trim()) return
99
-
398
+
100
399
  const match = token.trim().match(/^(\*+)\s+(.+)/)
101
400
  if (match) {
102
401
  items.push({ level: match[1].length, content: match[2] })
@@ -105,60 +404,58 @@ function formatChangeAsHtmlList(text) {
105
404
  }
106
405
  })
107
406
  }
108
-
407
+
109
408
  // Always generate HTML list
110
409
  const html = ['<ul>']
111
410
  items.forEach(item => {
112
411
  html.push(`<li>${item.content}</li>`)
113
412
  })
114
413
  html.push('</ul>')
115
-
414
+
116
415
  return html.join('\n')
117
416
  }
118
417
 
119
418
  function collectAllEntries(doc) {
120
419
  const catalog = new RevisionCatalog()
121
420
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
122
-
421
+
123
422
  // Collect document-level entries (-all)
124
423
  for (let major = 0; major <= 9; major++) {
125
424
  for (let minor = 0; minor <= 9; minor++) {
126
425
  const revision = `${major}-${minor}`
127
426
  const prevrevAttr = `${revision}-prevrev`
128
-
427
+
129
428
  if (doc.getAttribute(prevrevAttr)) {
130
429
  const allAttrName = `${prefix}${revision}-all`
131
430
  const allValue = doc.getAttribute(allAttrName)
132
-
431
+
133
432
  if (allValue) {
134
433
  catalog.addAllEntry(revision, allValue)
135
434
  }
136
435
  }
137
436
  }
138
437
  }
139
-
438
+
140
439
  // Collect block-level entries by scanning document
141
- const contexts = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
142
-
143
440
  function scanBlocks(blocks) {
144
441
  if (!blocks) return
145
-
442
+
146
443
  blocks.forEach(block => {
147
444
  const blockId = block.getId()
148
445
  const blockContext = block.getContext()
149
-
150
- if (contexts.includes(blockContext) && blockId) {
446
+
447
+ if (ROW_LEVEL_CONTEXTS.includes(blockContext) && blockId) {
151
448
  // Try both getAttributes() and direct attribute access
152
449
  const attrs = block.getAttributes()
153
-
450
+
154
451
  // Also check if attributes are exposed differently
155
452
  // Some Asciidoctor.js versions expose them via getAttribute(name) only
156
453
  Object.keys(attrs).forEach(key => {
157
454
  const value = attrs[key]
158
-
455
+
159
456
  if (key.startsWith(prefix) && !key.includes('-all') && !key.endsWith('-cover')) {
160
457
  const revision = key.substring(prefix.length)
161
-
458
+
162
459
  catalog.addEntry(revision, blockId, String(value), {
163
460
  title: block.getTitle(),
164
461
  context: blockContext
@@ -166,30 +463,38 @@ function collectAllEntries(doc) {
166
463
  }
167
464
  })
168
465
  }
169
-
466
+
170
467
  // Recurse into nested blocks
171
468
  if (block.getBlocks) {
172
469
  scanBlocks(block.getBlocks())
173
470
  }
174
471
  })
175
472
  }
176
-
473
+
177
474
  scanBlocks(doc.getBlocks())
178
-
475
+
476
+ // Paragraphs, lists, and the other rollup contexts, plus table cells:
477
+ // see propagateCellContentMarks/rollupChildRevisionEntries/rollupTableCellRevisionEntries
478
+ // above. Must run after the row-level scan above, so a section's own
479
+ // entry, if independently marked, already exists for a child to append onto.
480
+ propagateCellContentMarks(doc, prefix)
481
+ rollupChildRevisionEntries(catalog, doc, prefix)
482
+ rollupTableCellRevisionEntries(catalog, doc, prefix)
483
+
179
484
  return catalog
180
485
  }
181
486
 
182
487
  function generateInitialReleaseHtml(doc) {
183
488
  const discrete = doc.hasAttribute('rhrev-discrete')
184
489
  const html = []
185
-
490
+
186
491
  // Wrapper
187
492
  if (discrete) {
188
493
  html.push('<div class="rhrev-section">')
189
494
  } else {
190
495
  html.push('<section id="revision-history">')
191
496
  }
192
-
497
+
193
498
  // Title
194
499
  const customTitle = doc.getAttribute('rhrev-customization-title')
195
500
  if (customTitle !== undefined && customTitle === '') {
@@ -202,12 +507,12 @@ function generateInitialReleaseHtml(doc) {
202
507
  const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
203
508
  html.push(`<h2>${versionLabel} History</h2>`)
204
509
  }
205
-
510
+
206
511
  // Simple table
207
512
  const initialText = doc.getAttribute('rhrev-customization-initial-release-text') ||
208
513
  doc.getAttribute('rhrev-localization-initial-release') ||
209
514
  'Initial release'
210
-
515
+
211
516
  const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
212
517
  const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
213
518
 
@@ -215,7 +520,7 @@ function generateInitialReleaseHtml(doc) {
215
520
  const frame = doc.getAttribute('rhrev-table-frame') || 'all'
216
521
  const grid = doc.getAttribute('rhrev-table-grid') || 'all'
217
522
  html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
218
-
523
+
219
524
  // Caption as title div
220
525
  if (!doc.hasAttribute('rhrev-disable-caption')) {
221
526
  const caption = doc.getAttribute('rhrev-table-caption')
@@ -223,7 +528,7 @@ function generateInitialReleaseHtml(doc) {
223
528
  html.push(`<div class="title">${caption}</div>`)
224
529
  }
225
530
  }
226
-
531
+
227
532
  html.push('<colgroup>')
228
533
  columnWidths.forEach(w => {
229
534
  html.push(`<col style="width: ${w}%;">`)
@@ -253,24 +558,24 @@ function generateInitialReleaseHtml(doc) {
253
558
  html.push('</tr>')
254
559
  html.push('</tbody>')
255
560
  html.push('</table>')
256
-
561
+
257
562
  // Close wrapper
258
563
  html.push(discrete ? '</div>' : '</section>')
259
-
564
+
260
565
  return html.join('\n')
261
566
  }
262
567
 
263
568
  function generateHtmlOutput(doc, catalog, currentPage = '') {
264
569
  const discrete = doc.hasAttribute('rhrev-discrete')
265
570
  const html = []
266
-
571
+
267
572
  // Wrapper
268
573
  if (discrete) {
269
574
  html.push('<div class="rhrev-section">')
270
575
  } else {
271
576
  html.push('<section id="revision-history">')
272
577
  }
273
-
578
+
274
579
  // Title
275
580
  const customTitle = doc.getAttribute('rhrev-customization-title')
276
581
  if (customTitle !== undefined && customTitle === '') {
@@ -283,25 +588,25 @@ function generateHtmlOutput(doc, catalog, currentPage = '') {
283
588
  const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
284
589
  html.push(`<h2>${versionLabel} History</h2>`)
285
590
  }
286
-
591
+
287
592
  // Table
288
593
  html.push(generateHtmlTable(doc, catalog, currentPage))
289
-
594
+
290
595
  // Close wrapper
291
596
  html.push(discrete ? '</div>' : '</section>')
292
-
597
+
293
598
  return html.join('\n')
294
599
  }
295
600
 
296
601
  function generateHtmlTable(doc, catalog, currentPage = '') {
297
602
  const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
298
603
  const html = []
299
-
604
+
300
605
  // Build table with frame/grid classes
301
606
  const frame = doc.getAttribute('rhrev-table-frame') || 'all'
302
607
  const grid = doc.getAttribute('rhrev-table-grid') || 'all'
303
608
  html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
304
-
609
+
305
610
  // Caption as title div
306
611
  if (!doc.hasAttribute('rhrev-disable-caption')) {
307
612
  const caption = doc.getAttribute('rhrev-table-caption')
@@ -309,19 +614,19 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
309
614
  html.push(`<div class="title">${caption}</div>`)
310
615
  }
311
616
  }
312
-
617
+
313
618
  html.push('<colgroup>')
314
619
  columnWidths.forEach(w => {
315
620
  html.push(`<col style="width: ${w}%;">`)
316
621
  })
317
622
  html.push('</colgroup>')
318
623
  html.push('<tbody>')
319
-
624
+
320
625
  // Custom first row
321
626
  const firstRow = doc.getAttribute('rhrev-customization-first-row')
322
627
  const firstRowLeft = doc.getAttribute('rhrev-customization-first-row-left')
323
628
  const firstRowRight = doc.getAttribute('rhrev-customization-first-row-right')
324
-
629
+
325
630
  if (firstRow) {
326
631
  html.push('<tr class="rhrev-custom-first-row">')
327
632
  html.push(`<td colspan="2">${firstRow}</td>`)
@@ -332,11 +637,11 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
332
637
  html.push(`<td>${firstRowRight || '&nbsp;'}</td>`)
333
638
  html.push('</tr>')
334
639
  }
335
-
640
+
336
641
  const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
337
642
  const majorChangesText = doc.getAttribute('rhrev-localization-major-changes') || 'Major changes since'
338
643
  const allText = doc.getAttribute('rhrev-localization-all') || 'All'
339
-
644
+
340
645
  catalog.sortedRevisions().forEach(revision => {
341
646
  const prevrevAttr = `${revision}-prevrev`
342
647
  const prevrevdateAttr = `${revision}-prevrevdate`
@@ -344,7 +649,7 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
344
649
  const prevTitle = String(doc.getAttribute(prevrevAttr) || revision.replaceAll('-', '.'))
345
650
  const prevDate = String(doc.getAttribute(prevrevdateAttr) || '')
346
651
  const prevRevText = formatPrevRev(prevTitle, prevDate, doc)
347
-
652
+
348
653
  // Build header text
349
654
  let headerText
350
655
  if (prevTitle.trim().toLowerCase().startsWith(revisionLabel.toLowerCase())) {
@@ -352,13 +657,13 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
352
657
  } else {
353
658
  headerText = `${majorChangesText} ${revisionLabel} ${prevRevText}`
354
659
  }
355
-
660
+
356
661
  // Header row for this revision
357
662
  html.push('<tr class="rhrev-header">')
358
663
  html.push(`<th class="tableblock halign-left valign-top">${sectionLabel}</th>`)
359
664
  html.push(`<th class="tableblock halign-left valign-top"><strong>${headerText}</strong></th>`)
360
665
  html.push('</tr>')
361
-
666
+
362
667
  // All entry
363
668
  const allChange = catalog.allEntries[revision]
364
669
  if (allChange) {
@@ -367,17 +672,17 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
367
672
  html.push(`<td>${formatChangeAsHtmlList(allChange)}</td>`)
368
673
  html.push('</tr>')
369
674
  }
370
-
675
+
371
676
  // Block entries
372
677
  const entries = catalog.entries[revision] || []
373
678
  entries.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
374
-
679
+
375
680
  entries.forEach(entry => {
376
681
  const anchor = entry.anchor
377
- const title = entry.title || anchor.replace(/[_-]/g, ' ').split(' ').map(w =>
682
+ const title = entry.title || anchor.replace(/[_-]/g, ' ').split(' ').map(w =>
378
683
  w.charAt(0).toUpperCase() + w.slice(1)
379
684
  ).join(' ')
380
-
685
+
381
686
  html.push('<tr>')
382
687
 
383
688
  // Build link: local anchor if the section lives on the current page (directly or via include)
@@ -421,10 +726,10 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
421
726
  html.push('</tr>')
422
727
  })
423
728
  })
424
-
729
+
425
730
  html.push('</tbody>')
426
731
  html.push('</table>')
427
-
732
+
428
733
  return html.join('\n')
429
734
  }
430
735
 
@@ -433,30 +738,30 @@ module.exports.register = function (registry, context = {}) {
433
738
  // Register block macro for rhrev::[]
434
739
  registry.blockMacro('rhrev', function () {
435
740
  const self = this
436
-
741
+
437
742
  self.process(function (parent, target, attrs) {
438
743
  const doc = parent.getDocument()
439
-
744
+
440
745
  // Check if manual mode
441
746
  if (doc.getAttribute('rhrev') === 'manual') {
442
747
  return self.createBlock(parent, 'paragraph', '')
443
748
  }
444
-
749
+
445
750
  // Check initial release handling
446
751
  const revnumber = doc.getAttribute('revnumber') || '1.0'
447
752
  const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
448
-
753
+
449
754
  if (isInitialRelease(revnumber)) {
450
755
  if (initialHandling === 'skip') {
451
756
  return self.createBlock(parent, 'paragraph', '')
452
757
  }
453
-
758
+
454
759
  if (initialHandling === 'initial-release') {
455
760
  const html = generateInitialReleaseHtml(doc)
456
761
  return self.createBlock(parent, 'pass', html)
457
762
  }
458
763
  }
459
-
764
+
460
765
  // Try to use catalog from Antora pipeline extension (component-scoped by default).
461
766
  // global.antoraRhrevCatalog is the site-wide catalog; .byComponent holds per-component catalogs.
462
767
  let catalog
@@ -489,11 +794,35 @@ module.exports.register = function (registry, context = {}) {
489
794
  return self.createBlock(parent, 'pass', html)
490
795
  })
491
796
  })
492
-
797
+
798
+ // Propagates a marked nested block's rhrev attribute onto its enclosing
799
+ // a| table cell, and strips it from the nested block. See
800
+ // propagateCellContentMarks above for the full reasoning (the same
801
+ // isolated-inner-document gap the PDF gem's converter works around).
802
+ // Must be registered, and therefore run, before the change-bar tagging
803
+ // treeProcessor immediately below: treeprocessors all run before
804
+ // conversion starts, so if propagation happened later, that pass would
805
+ // already have tagged the nested paragraph at its original, unpropagated
806
+ // location, since it is a rollup context in its own right.
807
+ registry.treeProcessor(function () {
808
+ const self = this
809
+
810
+ self.process(function (doc) {
811
+ const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
812
+ propagateCellContentMarks(doc, prefix)
813
+ return doc
814
+ })
815
+ })
816
+
493
817
  // Tag blocks carrying the current revision attribute with the rhrev-changed
494
818
  // role so the site stylesheet can draw an HTML change bar (right-side border).
495
819
  // Enabled by rhrev-change-bars, mirroring the PDF backend; color/width/offset
496
820
  // overrides are emitted as CSS custom properties. Side is always right in HTML.
821
+ // The first six contexts get their own revision-history row when marked;
822
+ // the other nine (mirrors ROLLUP_CONTEXTS) roll up into their enclosing
823
+ // section's entry instead (see rollupChildRevisionEntries), but still get
824
+ // their own bar here: tagging needs no id or anchor, a role is a role
825
+ // regardless of block type.
497
826
  registry.treeProcessor(function () {
498
827
  const self = this
499
828
 
@@ -503,7 +832,18 @@ module.exports.register = function (registry, context = {}) {
503
832
  if (!revnumber) return doc
504
833
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
505
834
  const changeAttr = prefix + String(revnumber).replace(/\./g, '-')
506
- const contexts = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
835
+ const contexts = ROW_LEVEL_CONTEXTS.concat(ROLLUP_CONTEXTS)
836
+
837
+ // A marked table cell was already tagged on its own nested content by
838
+ // propagateCellContentMarks, in the earlier treeprocessor: that walk
839
+ // is scoped to each cell's own, structurally separate inner document,
840
+ // invisible to this treeprocessor's own outer-document findBy below,
841
+ // so `tagged` (which only reflects THIS loop) is never a reliable
842
+ // signal for whether a cell needs patching. Runs unconditionally once
843
+ // change bars are known enabled, before the tagged/return-early check,
844
+ // so a document whose only marked content is a table cell (no other
845
+ // block matched below) still gets its converter patched.
846
+ patchConvertTableForCellTagging(doc)
507
847
 
508
848
  let tagged = false
509
849
  doc.findBy((b) => contexts.includes(b.getContext())).forEach((block) => {
@@ -533,33 +873,30 @@ module.exports.register = function (registry, context = {}) {
533
873
  // Register treeprocessor for auto-injection
534
874
  registry.treeProcessor(function () {
535
875
  const self = this
536
-
876
+
537
877
  self.process(function (doc) {
538
878
  if (!doc.hasAttribute('rhrev')) {
539
879
  return doc
540
880
  }
541
-
881
+
542
882
  const rhrevValue = doc.getAttribute('rhrev')
543
883
  if (rhrevValue === 'macro' || rhrevValue === 'manual') {
544
884
  return doc
545
885
  }
546
-
886
+
547
887
  // Auto-inject revision history at document start
548
888
  const blocks = doc.getBlocks()
549
-
550
- // Create rhrev block
551
- const rhrevBlock = self.createBlock(doc, 'pass', '')
552
-
889
+
553
890
  // Check initial release handling
554
891
  const revnumber = doc.getAttribute('revnumber') || '1.0'
555
892
  const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
556
-
893
+
557
894
  let html
558
895
  if (isInitialRelease(revnumber)) {
559
896
  if (initialHandling === 'skip') {
560
897
  return doc
561
898
  }
562
-
899
+
563
900
  if (initialHandling === 'initial-release') {
564
901
  html = generateInitialReleaseHtml(doc)
565
902
  } else {
@@ -568,19 +905,23 @@ module.exports.register = function (registry, context = {}) {
568
905
  }
569
906
  } else {
570
907
  const catalog = collectAllEntries(doc)
571
-
908
+
572
909
  if (catalog.sortedRevisions().length === 0) {
573
910
  return doc
574
911
  }
575
-
912
+
576
913
  html = generateHtmlOutput(doc, catalog)
577
914
  }
578
-
579
- rhrevBlock.setContent(html)
580
-
915
+
916
+ // Create the block with its content directly: self.createBlock(...).setContent(html)
917
+ // used to be two steps here, but Block#setContent is not a function
918
+ // on a modern @asciidoctor/core (confirmed against 2.2.9, a
919
+ // pre-existing bug, unrelated to anything else in this file).
920
+ const rhrevBlock = self.createBlock(doc, 'pass', html)
921
+
581
922
  // Insert at beginning
582
923
  blocks.unshift(rhrevBlock)
583
-
924
+
584
925
  return doc
585
926
  })
586
927
  })
@@ -10,6 +10,53 @@
10
10
 
11
11
  const RHREV_MACRO_RX = /^rhrev::(\[.*?\])?$/gm
12
12
 
13
+ // Used only to parse each page's raw AsciiDoc into a real document tree
14
+ // (buildComponentCatalog below), replacing the previous regex/heuristic-
15
+ // anchor-guessing scan. Never used to convert or register a converter, so
16
+ // none of the cross-package Opal/ConverterFactory conflict from
17
+ // antora-rhrev-asciidoc.js applies here.
18
+ const asciidoctor = require('@asciidoctor/core')()
19
+
20
+ // Antora resource-id includes (page$file.adoc) are not real filesystem
21
+ // paths; a bare asciidoctor.load() outside Antora's own content-catalog-
22
+ // aware include resolver would try, and fail, to read them as literal
23
+ // files. A no-op include processor means each page parses on its own,
24
+ // without needing (or attempting) cross-file resolution at all: the
25
+ // existing includedBy/crossIncludeRx scan below already handles cross-
26
+ // component include detection for href purposes, a separate, unrelated,
27
+ // line-based mechanism (tags have no AST representation, so it stays
28
+ // regex-based on purpose), reused unchanged.
29
+ const noIncludeRegistry = asciidoctor.Extensions.create()
30
+ noIncludeRegistry.includeProcessor(function () {
31
+ this.handles(() => true)
32
+ this.process(() => {})
33
+ })
34
+
35
+ // Mirrors ROLLUP_CONTEXTS in antora-rhrev-asciidoc.js and
36
+ // Converter::ROLLUP_CONTEXTS on the Ruby gem's PDF side: block types with
37
+ // no revision-history row of their own. A marked one rolls up as a bullet
38
+ // into its nearest enclosing section's entry instead (or the document-
39
+ // level -all entry, if it has no enclosing section, or its enclosing
40
+ // section has no id). Duplicated here rather than required from the
41
+ // sibling file: the two files are independent Antora extension entry
42
+ // points, loaded and registered separately, matching the existing
43
+ // duplication between this file and antora-rhrev-asciidoc.js (each has
44
+ // its own copy of, for example, compareRevisions and formatChangeAsHtmlList).
45
+ const ROLLUP_CONTEXTS = ['paragraph', 'ulist', 'olist', 'dlist', 'admonition', 'open', 'quote', 'verse', 'sidebar']
46
+
47
+ // Row-level: gets its own revision-history row when marked, requires an id.
48
+ const ROW_LEVEL_CONTEXTS = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
49
+
50
+ // Walks up from node to its nearest enclosing section, or null if none
51
+ // exists (the document preamble).
52
+ function enclosingSection(node) {
53
+ let section = node.getParent ? node.getParent() : null
54
+ while (section && section.getContext && section.getContext() !== 'section') {
55
+ section = section.getParent ? section.getParent() : null
56
+ }
57
+ return section || null
58
+ }
59
+
13
60
  module.exports.register = function ({ config: extensionConfig = {} }) {
14
61
  const logger = this.getLogger('rhrev-extension')
15
62
  const quiet = extensionConfig.quiet !== undefined ? extensionConfig.quiet : true
@@ -115,6 +162,41 @@ function mergeCatalogs(catalogs) {
115
162
  return merged
116
163
  }
117
164
 
165
+ // Finds anchorNode's existing entry for this revision on catalog and
166
+ // appends another bullet onto its change text, or creates the entry
167
+ // (carrying the same cross-page metadata a row-level entry gets: page,
168
+ // component, version, module, stem, matchLine, containingTags, all needed
169
+ // downstream for href resolution and the includedBy check) if anchorNode
170
+ // has none yet. meta carries the marked child's own line/tag information
171
+ // for a rollup entry (anchorNode is a section, not the marked node), or
172
+ // the anchor node's own for a row-level entry (anchorNode is the marked
173
+ // node itself, so meta === the anchor's own position).
174
+ function appendChildChangeToEntry(catalog, revision, anchorNode, changeText, pageMeta, meta) {
175
+ const entries = catalog.entries[revision] || (catalog.entries[revision] = [])
176
+ const anchorId = anchorNode.getId()
177
+ const entry = entries.find((e) => e.anchor === anchorId)
178
+ if (entry) {
179
+ entry.change = (entry.change && String(entry.change).length) ? `${entry.change} * ${changeText}` : changeText
180
+ } else {
181
+ entries.push({
182
+ anchor: anchorId,
183
+ change: changeText,
184
+ title: anchorNode.getTitle ? anchorNode.getTitle() : null,
185
+ sequence: catalog.sequence++,
186
+ ...pageMeta,
187
+ ...meta
188
+ })
189
+ }
190
+ }
191
+
192
+ // Appends onto catalog's existing -all change text for this revision, or
193
+ // sets it fresh if none exists yet.
194
+ function appendChildChangeToAll(catalog, revision, changeText) {
195
+ const existing = catalog.allEntries[revision]
196
+ const combined = (existing && String(existing).length) ? `${existing} * ${changeText}` : changeText
197
+ catalog.allEntries[revision] = combined
198
+ }
199
+
118
200
  function buildComponentCatalog(pageFiles, logger, quiet) {
119
201
  const catalog = {
120
202
  entries: {}, // { '1-1': [...], '1-2': [...] }
@@ -160,22 +242,29 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
160
242
  }
161
243
  }
162
244
 
163
- // Collect block-level entries from ALL pages
245
+ // Collect block-level entries from ALL pages, by real parsing (each page
246
+ // its own asciidoctor.load(), sourcemap: true for real line numbers,
247
+ // the no-op include processor above so a cross-component include::
248
+ // never touches the filesystem or throws), not the previous regex/
249
+ // heuristic-anchor-guessing scan: that scan could only recognize a
250
+ // marked block immediately followed, within 200 characters of raw text,
251
+ // by [#anchor], .Title, or a heading line, so it had no way to reach
252
+ // paragraphs, lists, admonitions, or any of the other rollup contexts,
253
+ // or table cells, at all. Real parsing has no such limit.
164
254
  pageFiles.forEach(file => {
165
255
  if (!file.contents) return
166
-
256
+
167
257
  const content = file.contents.toString()
168
-
258
+
169
259
  if (!quiet && content.includes(prefix)) {
170
260
  logger.debug(`rhrev-extension: Scanning ${file.src?.relative} for block attributes`)
171
261
  }
172
-
173
- // Strip delimited block content (source, literal, listing) to avoid matching examples
174
- const strippedContent = stripVerbatimBlocks(content)
175
262
 
176
- // Compute tag regions in the stripped content (for cross-component include detection)
263
+ // Tag-region boundary detection stays line-based, on purpose: tags are
264
+ // a raw preprocessor concept for include::, with no AST representation
265
+ // to walk instead. Unrelated to the block-type-scanning gap above.
177
266
  const fileTagBoundaries = {}
178
- strippedContent.split('\n').forEach((line, lineNum) => {
267
+ content.split('\n').forEach((line, lineNum) => {
179
268
  const startM = line.match(/^\/\/ tag::([^\[\]]+)\[\]$/)
180
269
  if (startM) fileTagBoundaries[startM[1]] = { startLine: lineNum }
181
270
  const endM = line.match(/^\/\/ end::([^\[\]]+)\[\]$/)
@@ -183,79 +272,152 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
183
272
  })
184
273
  catalog.fileMeta[file.src?.relative || file.path] = fileTagBoundaries
185
274
 
186
- // Match block attribute lines: [rhrev1-1="text"] or [rhrev1-0-1="text"] (N-part versions)
187
- const blockAttrRx = new RegExp(`\\[${prefix}([0-9]+(?:-[0-9]+)+)="([^"]+)"\\]`, 'g')
188
- let match
275
+ let doc
276
+ try {
277
+ doc = asciidoctor.load(content, { safe: 'safe', sourcemap: true, extension_registry: noIncludeRegistry, attributes: componentAttrs })
278
+ } catch (e) {
279
+ if (!quiet) logger.debug(`rhrev-extension: failed to parse ${file.src?.relative}, skipped: ${e.message}`)
280
+ return
281
+ }
189
282
 
190
- while ((match = blockAttrRx.exec(strippedContent)) !== null) {
191
- const revision = match[1]
192
- const changeText = match[2]
193
- const matchLine = strippedContent.substring(0, match.index).split('\n').length - 1
194
- const containingTags = Object.entries(fileTagBoundaries)
283
+ const pageMeta = {
284
+ page: file.src?.relative || file.path,
285
+ component: file.src?.component,
286
+ version: file.src?.version,
287
+ module: file.src?.module || 'ROOT',
288
+ stem: file.src?.stem || (file.src?.basename || '').replace(/\.[^.]+$/, '')
289
+ }
290
+
291
+ // node.getLineNumber() is 1-based; startLine/endLine above are 0-based
292
+ // (from Array#forEach's index), matching the original regex scan's own
293
+ // 0-based matchLine, so this converts once, here, rather than at every
294
+ // comparison site.
295
+ const metaFor = (node) => {
296
+ const lineNumber = node.getLineNumber ? node.getLineNumber() : null
297
+ const matchLine = lineNumber ? lineNumber - 1 : undefined
298
+ const containingTags = (matchLine === undefined) ? [] : Object.entries(fileTagBoundaries)
195
299
  .filter(([, b]) => b.startLine !== undefined && b.endLine !== undefined &&
196
300
  matchLine > b.startLine && matchLine < b.endLine)
197
301
  .map(([name]) => name)
198
-
199
- // Look for anchor/title IMMEDIATELY after the attribute (within next 200 chars)
200
- const matchEnd = match.index + match[0].length
201
- const nextChunk = strippedContent.substring(matchEnd, matchEnd + 200)
202
-
203
- // Look for [#anchor], .Title, or === Section in that order
204
- const anchorMatch = nextChunk.match(/^\s*\[#([^\]]+)\]/)
205
- const titleMatch = nextChunk.match(/^\s*\.([^\n]+)/)
206
- const sectionMatch = nextChunk.match(/^\s*(==+)\s+([^\n]+)/)
207
-
208
- let anchor = null
209
- let title = null
210
-
211
- if (anchorMatch) {
212
- anchor = anchorMatch[1]
213
- // Still need to find title after anchor
214
- const afterAnchor = nextChunk.substring(anchorMatch[0].length)
215
- const titleAfterAnchor = afterAnchor.match(/^\s*\.([^\n]+)/)
216
- const sectionAfterAnchor = afterAnchor.match(/^\s*(==+)\s+([^\n]+)/)
217
- title = titleAfterAnchor ? titleAfterAnchor[1] : (sectionAfterAnchor ? sectionAfterAnchor[2] : anchor)
218
- } else if (sectionMatch) {
219
- title = sectionMatch[2]
220
- // Generate anchor from section title (Asciidoctor's default behavior)
221
- anchor = '_' + title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
222
- } else if (titleMatch) {
223
- title = titleMatch[1]
224
- // No explicit anchor, will use title as fallback
225
- }
226
-
227
- if (!quiet) {
228
- logger.debug(`rhrev-extension: Found ${revision} -> anchor=${anchor}, title=${title} in ${file.src?.relative}`)
229
- }
230
-
231
- if (anchor) {
232
- if (!catalog.entries[revision]) {
233
- catalog.entries[revision] = []
234
- }
302
+ return { matchLine, containingTags }
303
+ }
235
304
 
236
- catalog.entries[revision].push({
237
- anchor,
238
- change: changeText,
239
- title,
240
- sequence: catalog.sequence++,
241
- page: file.src?.relative || file.path,
242
- component: file.src?.component,
243
- version: file.src?.version,
244
- module: file.src?.module || 'ROOT',
245
- stem: file.src?.stem || (file.src?.basename || '').replace(/\.[^.]+$/, ''),
246
- matchLine,
247
- containingTags
248
- })
249
-
305
+ // Row-level: same six contexts as before, still requires an id.
306
+ doc.findBy((n) => ROW_LEVEL_CONTEXTS.includes(n.getContext())).forEach((block) => {
307
+ const blockId = block.getId()
308
+ if (!blockId) return
309
+ const attrs = block.getAttributes()
310
+ if (!attrs) return
311
+ Object.keys(attrs).forEach((key) => {
312
+ if (!key.startsWith(prefix)) return
313
+ if (key.includes('-all') || key.endsWith('-cover')) return
314
+ const revision = key.substring(prefix.length)
315
+ catalog.entries[revision] = catalog.entries[revision] || []
316
+ catalog.entries[revision].push({
317
+ anchor: blockId,
318
+ change: String(attrs[key]),
319
+ title: block.getTitle(),
320
+ sequence: catalog.sequence++,
321
+ ...pageMeta,
322
+ ...metaFor(block)
323
+ })
250
324
  if (!quiet) {
251
- logger.debug(`rhrev-extension: Found block entry ${revision} -> ${anchor} in ${file.src?.relative}`)
325
+ logger.debug(`rhrev-extension: Found block entry ${revision} -> ${blockId} in ${file.src?.relative}`)
252
326
  }
253
- } else if (!quiet) {
254
- logger.debug(`rhrev-extension: Could not determine anchor for ${revision} in ${file.src?.relative}`)
255
- }
256
- }
327
+ })
328
+ })
329
+
330
+ // Table cells cannot carry an rhrev attribute of their own through
331
+ // normal AsciiDoc parsing: a plain (|) cell never parses its content
332
+ // as blocks at all, an attribute list there is just literal text. An
333
+ // AsciiDoc-style (a|) cell does parse its content as blocks, so
334
+ // [rhrev1-2="..."] attaches, but to the first nested block, not the
335
+ // cell, and that nested block lives in a structurally separate inner
336
+ // document (getInnerDocument()), unreachable from the outer document's
337
+ // own findBy. Same gotcha as antora-rhrev-asciidoc.js and the Ruby
338
+ // gem's converter.rb. Copies the mark onto the enclosing cell (for the
339
+ // revision-history entry below) and strips it from the nested node.
340
+ doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
341
+ const rows = table.getRows()
342
+ const cells = [].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat())
343
+ cells.forEach((cell) => {
344
+ if (cell.getStyle() !== 'asciidoc') return
345
+ const inner = cell.getInnerDocument ? cell.getInnerDocument() : null
346
+ if (!inner) return
347
+ inner.findBy((n) => ROLLUP_CONTEXTS.includes(n.getContext())).forEach((node) => {
348
+ const attrs = node.getAttributes()
349
+ if (!attrs) return
350
+ Object.keys(attrs).forEach((key) => {
351
+ if (!key.startsWith(prefix)) return
352
+ cell.setAttribute(key, attrs[key])
353
+ node.removeAttribute(key)
354
+ })
355
+ })
356
+ })
357
+ })
358
+
359
+ // Paragraphs, lists, admonitions, and the other rollup contexts: no
360
+ // row of their own, since they have no title/section-number for the
361
+ // location column. A marked one contributes a bullet to its nearest
362
+ // enclosing section's entry instead (creating that entry, with the
363
+ // section's own metadata, if the section has none yet for this
364
+ // revision), or the document-level -all entry if it has no enclosing
365
+ // section or that section has no id. findBy returns document order on
366
+ // its own, no separate sort needed (the same fix the Ruby side needed
367
+ // for its own node.lineno-based sort, which silently no-ops without
368
+ // sourcemap: true; this file already sets that, and findBy doesn't
369
+ // need it for ordering regardless).
370
+ doc.findBy((n) => ROLLUP_CONTEXTS.includes(n.getContext())).forEach((node) => {
371
+ const attrs = node.getAttributes()
372
+ if (!attrs) return
373
+ const section = enclosingSection(node)
374
+ const hasSection = !!(section && section.getId())
375
+ Object.keys(attrs).forEach((key) => {
376
+ if (!key.startsWith(prefix)) return
377
+ if (key.includes('-all') || key.endsWith('-cover')) return
378
+ const revision = key.substring(prefix.length)
379
+ const value = String(attrs[key])
380
+ if (hasSection) {
381
+ appendChildChangeToEntry(catalog, revision, section, value, pageMeta, metaFor(section))
382
+ } else {
383
+ appendChildChangeToAll(catalog, revision, value)
384
+ }
385
+ })
386
+ })
387
+
388
+ // A marked table cell is not a rollup context itself: it rolls up to
389
+ // its own enclosing table, not the nearest section, and cells are not
390
+ // reachable through the same context-based findBy a normal block-level
391
+ // node is, so this walks table.getRows() directly. Same three-tier
392
+ // fallback as the rollup pass above: the table's own entry if it has
393
+ // an id, else its nearest enclosing section's entry, else the
394
+ // document-level -all entry only when neither exists.
395
+ doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
396
+ const rows = table.getRows()
397
+ const cells = [].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat())
398
+ cells.forEach((cell) => {
399
+ const attrs = cell.getAttributes()
400
+ if (!attrs) return
401
+ Object.keys(attrs).forEach((key) => {
402
+ if (!key.startsWith(prefix)) return
403
+ if (key.includes('-all') || key.endsWith('-cover')) return
404
+ const revision = key.substring(prefix.length)
405
+ const value = String(attrs[key])
406
+ if (table.getId()) {
407
+ appendChildChangeToEntry(catalog, revision, table, value, pageMeta, metaFor(table))
408
+ } else {
409
+ const section = enclosingSection(table)
410
+ if (section && section.getId()) {
411
+ appendChildChangeToEntry(catalog, revision, section, value, pageMeta, metaFor(section))
412
+ } else {
413
+ appendChildChangeToAll(catalog, revision, value)
414
+ }
415
+ }
416
+ })
417
+ })
418
+ })
257
419
  })
258
-
420
+
259
421
  return catalog
260
422
  }
261
423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asciidoctor-rhrev",
3
- "version": "1.3.0",
3
+ "version": "1.5.1",
4
4
  "description": "Revision history block macro extension for Asciidoctor.js and Antora (HTML5 backend)",
5
5
  "main": "antora-extension/antora-rhrev-asciidoc.js",
6
6
  "scripts": {