asciidoctor-rhrev 1.2.3 → 1.5.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.
@@ -2,16 +2,210 @@
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> never carries the rhrev-changed role: that
186
+ // needs a converter-level override, the same way the Ruby gem's HTML5
187
+ // converter is overridden. Built and verified working standalone (a real
188
+ // Opal subclass of Html5Converter, registered via ConverterFactory,
189
+ // delegating to super for everything but a marked table), but it broke
190
+ // real Antora builds: this exact demo site has three separate,
191
+ // non-deduped @asciidoctor/core installs (rhrev, the site itself, and
192
+ // baiyibai-extensions, whose cite-it extension wraps Antora's own document
193
+ // loading), one of them (2.2.8 vs 2.2.9 elsewhere) a different version.
194
+ // Antora's own converter/html5.js reads whatever is currently registered
195
+ // as the 'html5' converter and builds a real Opal subclass on top of it;
196
+ // with three non-identical copies of the Opal runtime involved, that
197
+ // crashed (Object.setPrototypeOf called on null or undefined), confirmed
198
+ // against a real local build, not assumed. That is a dependency-hygiene
199
+ // problem in a sibling package, out of scope here.
200
+ //
201
+ // Instead, propagateCellContentMarks above tags the cell's marked nested
202
+ // block directly (a normal rollup-context node, so it needs no converter
203
+ // override at all, the same unmodified converter already supports a role
204
+ // there), rather than the cell itself. The visible result is a border
205
+ // around the changed paragraph inside the cell rather than around the
206
+ // whole cell box; confirmed against the real, unmodified converter output,
207
+ // not assumed to work the same as the PDF gem's margin bar.
208
+
15
209
  // Catalog for storing revision entries
16
210
  class RevisionCatalog {
17
211
  constructor() {
@@ -24,7 +218,7 @@ class RevisionCatalog {
24
218
  if (!this.entries[revision]) {
25
219
  this.entries[revision] = []
26
220
  }
27
-
221
+
28
222
  this.entries[revision].push({
29
223
  anchor,
30
224
  change,
@@ -66,13 +260,13 @@ function isInitialRelease(revnumber) {
66
260
  function formatPrevRev(prevTitle, prevDate, doc) {
67
261
  const includeDateAttr = doc.getAttribute('rhrev-customization-prevrev-include-date')
68
262
  const includeDate = includeDateAttr !== undefined
69
-
263
+
70
264
  if (includeDate && prevDate) {
71
265
  const dateFormat = doc.getAttribute('rhrev-customization-date-format') || '%Y-%m-%d'
72
266
  // Simple date formatting (Node.js doesn't have strftime, keep it simple)
73
267
  return `${prevTitle} ${prevDate}`
74
268
  }
75
-
269
+
76
270
  return prevTitle
77
271
  }
78
272
 
@@ -83,20 +277,20 @@ function preprocessAttributeContent(text) {
83
277
 
84
278
  function formatChangeAsHtmlList(text) {
85
279
  if (!text) return ''
86
-
280
+
87
281
  const processed = preprocessAttributeContent(text)
88
-
282
+
89
283
  // Split by asterisks to create list items
90
284
  const tokens = processed.split(/\s(?=\*+\s)/)
91
285
  const items = []
92
-
286
+
93
287
  if (tokens.length === 1 && !processed.includes('*')) {
94
288
  // Single item without asterisk - wrap in list anyway
95
289
  items.push({ level: 1, content: processed })
96
290
  } else {
97
291
  tokens.forEach(token => {
98
292
  if (!token.trim()) return
99
-
293
+
100
294
  const match = token.trim().match(/^(\*+)\s+(.+)/)
101
295
  if (match) {
102
296
  items.push({ level: match[1].length, content: match[2] })
@@ -105,60 +299,58 @@ function formatChangeAsHtmlList(text) {
105
299
  }
106
300
  })
107
301
  }
108
-
302
+
109
303
  // Always generate HTML list
110
304
  const html = ['<ul>']
111
305
  items.forEach(item => {
112
306
  html.push(`<li>${item.content}</li>`)
113
307
  })
114
308
  html.push('</ul>')
115
-
309
+
116
310
  return html.join('\n')
117
311
  }
118
312
 
119
313
  function collectAllEntries(doc) {
120
314
  const catalog = new RevisionCatalog()
121
315
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
122
-
316
+
123
317
  // Collect document-level entries (-all)
124
318
  for (let major = 0; major <= 9; major++) {
125
319
  for (let minor = 0; minor <= 9; minor++) {
126
320
  const revision = `${major}-${minor}`
127
321
  const prevrevAttr = `${revision}-prevrev`
128
-
322
+
129
323
  if (doc.getAttribute(prevrevAttr)) {
130
324
  const allAttrName = `${prefix}${revision}-all`
131
325
  const allValue = doc.getAttribute(allAttrName)
132
-
326
+
133
327
  if (allValue) {
134
328
  catalog.addAllEntry(revision, allValue)
135
329
  }
136
330
  }
137
331
  }
138
332
  }
139
-
333
+
140
334
  // Collect block-level entries by scanning document
141
- const contexts = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
142
-
143
335
  function scanBlocks(blocks) {
144
336
  if (!blocks) return
145
-
337
+
146
338
  blocks.forEach(block => {
147
339
  const blockId = block.getId()
148
340
  const blockContext = block.getContext()
149
-
150
- if (contexts.includes(blockContext) && blockId) {
341
+
342
+ if (ROW_LEVEL_CONTEXTS.includes(blockContext) && blockId) {
151
343
  // Try both getAttributes() and direct attribute access
152
344
  const attrs = block.getAttributes()
153
-
345
+
154
346
  // Also check if attributes are exposed differently
155
347
  // Some Asciidoctor.js versions expose them via getAttribute(name) only
156
348
  Object.keys(attrs).forEach(key => {
157
349
  const value = attrs[key]
158
-
350
+
159
351
  if (key.startsWith(prefix) && !key.includes('-all') && !key.endsWith('-cover')) {
160
352
  const revision = key.substring(prefix.length)
161
-
353
+
162
354
  catalog.addEntry(revision, blockId, String(value), {
163
355
  title: block.getTitle(),
164
356
  context: blockContext
@@ -166,30 +358,38 @@ function collectAllEntries(doc) {
166
358
  }
167
359
  })
168
360
  }
169
-
361
+
170
362
  // Recurse into nested blocks
171
363
  if (block.getBlocks) {
172
364
  scanBlocks(block.getBlocks())
173
365
  }
174
366
  })
175
367
  }
176
-
368
+
177
369
  scanBlocks(doc.getBlocks())
178
-
370
+
371
+ // Paragraphs, lists, and the other rollup contexts, plus table cells:
372
+ // see propagateCellContentMarks/rollupChildRevisionEntries/rollupTableCellRevisionEntries
373
+ // above. Must run after the row-level scan above, so a section's own
374
+ // entry, if independently marked, already exists for a child to append onto.
375
+ propagateCellContentMarks(doc, prefix)
376
+ rollupChildRevisionEntries(catalog, doc, prefix)
377
+ rollupTableCellRevisionEntries(catalog, doc, prefix)
378
+
179
379
  return catalog
180
380
  }
181
381
 
182
382
  function generateInitialReleaseHtml(doc) {
183
383
  const discrete = doc.hasAttribute('rhrev-discrete')
184
384
  const html = []
185
-
385
+
186
386
  // Wrapper
187
387
  if (discrete) {
188
388
  html.push('<div class="rhrev-section">')
189
389
  } else {
190
390
  html.push('<section id="revision-history">')
191
391
  }
192
-
392
+
193
393
  // Title
194
394
  const customTitle = doc.getAttribute('rhrev-customization-title')
195
395
  if (customTitle !== undefined && customTitle === '') {
@@ -202,12 +402,12 @@ function generateInitialReleaseHtml(doc) {
202
402
  const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
203
403
  html.push(`<h2>${versionLabel} History</h2>`)
204
404
  }
205
-
405
+
206
406
  // Simple table
207
407
  const initialText = doc.getAttribute('rhrev-customization-initial-release-text') ||
208
408
  doc.getAttribute('rhrev-localization-initial-release') ||
209
409
  'Initial release'
210
-
410
+
211
411
  const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
212
412
  const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
213
413
 
@@ -215,7 +415,7 @@ function generateInitialReleaseHtml(doc) {
215
415
  const frame = doc.getAttribute('rhrev-table-frame') || 'all'
216
416
  const grid = doc.getAttribute('rhrev-table-grid') || 'all'
217
417
  html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
218
-
418
+
219
419
  // Caption as title div
220
420
  if (!doc.hasAttribute('rhrev-disable-caption')) {
221
421
  const caption = doc.getAttribute('rhrev-table-caption')
@@ -223,7 +423,7 @@ function generateInitialReleaseHtml(doc) {
223
423
  html.push(`<div class="title">${caption}</div>`)
224
424
  }
225
425
  }
226
-
426
+
227
427
  html.push('<colgroup>')
228
428
  columnWidths.forEach(w => {
229
429
  html.push(`<col style="width: ${w}%;">`)
@@ -253,24 +453,24 @@ function generateInitialReleaseHtml(doc) {
253
453
  html.push('</tr>')
254
454
  html.push('</tbody>')
255
455
  html.push('</table>')
256
-
456
+
257
457
  // Close wrapper
258
458
  html.push(discrete ? '</div>' : '</section>')
259
-
459
+
260
460
  return html.join('\n')
261
461
  }
262
462
 
263
463
  function generateHtmlOutput(doc, catalog, currentPage = '') {
264
464
  const discrete = doc.hasAttribute('rhrev-discrete')
265
465
  const html = []
266
-
466
+
267
467
  // Wrapper
268
468
  if (discrete) {
269
469
  html.push('<div class="rhrev-section">')
270
470
  } else {
271
471
  html.push('<section id="revision-history">')
272
472
  }
273
-
473
+
274
474
  // Title
275
475
  const customTitle = doc.getAttribute('rhrev-customization-title')
276
476
  if (customTitle !== undefined && customTitle === '') {
@@ -283,25 +483,25 @@ function generateHtmlOutput(doc, catalog, currentPage = '') {
283
483
  const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
284
484
  html.push(`<h2>${versionLabel} History</h2>`)
285
485
  }
286
-
486
+
287
487
  // Table
288
488
  html.push(generateHtmlTable(doc, catalog, currentPage))
289
-
489
+
290
490
  // Close wrapper
291
491
  html.push(discrete ? '</div>' : '</section>')
292
-
492
+
293
493
  return html.join('\n')
294
494
  }
295
495
 
296
496
  function generateHtmlTable(doc, catalog, currentPage = '') {
297
497
  const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
298
498
  const html = []
299
-
499
+
300
500
  // Build table with frame/grid classes
301
501
  const frame = doc.getAttribute('rhrev-table-frame') || 'all'
302
502
  const grid = doc.getAttribute('rhrev-table-grid') || 'all'
303
503
  html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
304
-
504
+
305
505
  // Caption as title div
306
506
  if (!doc.hasAttribute('rhrev-disable-caption')) {
307
507
  const caption = doc.getAttribute('rhrev-table-caption')
@@ -309,19 +509,19 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
309
509
  html.push(`<div class="title">${caption}</div>`)
310
510
  }
311
511
  }
312
-
512
+
313
513
  html.push('<colgroup>')
314
514
  columnWidths.forEach(w => {
315
515
  html.push(`<col style="width: ${w}%;">`)
316
516
  })
317
517
  html.push('</colgroup>')
318
518
  html.push('<tbody>')
319
-
519
+
320
520
  // Custom first row
321
521
  const firstRow = doc.getAttribute('rhrev-customization-first-row')
322
522
  const firstRowLeft = doc.getAttribute('rhrev-customization-first-row-left')
323
523
  const firstRowRight = doc.getAttribute('rhrev-customization-first-row-right')
324
-
524
+
325
525
  if (firstRow) {
326
526
  html.push('<tr class="rhrev-custom-first-row">')
327
527
  html.push(`<td colspan="2">${firstRow}</td>`)
@@ -332,11 +532,11 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
332
532
  html.push(`<td>${firstRowRight || '&nbsp;'}</td>`)
333
533
  html.push('</tr>')
334
534
  }
335
-
535
+
336
536
  const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
337
537
  const majorChangesText = doc.getAttribute('rhrev-localization-major-changes') || 'Major changes since'
338
538
  const allText = doc.getAttribute('rhrev-localization-all') || 'All'
339
-
539
+
340
540
  catalog.sortedRevisions().forEach(revision => {
341
541
  const prevrevAttr = `${revision}-prevrev`
342
542
  const prevrevdateAttr = `${revision}-prevrevdate`
@@ -344,7 +544,7 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
344
544
  const prevTitle = String(doc.getAttribute(prevrevAttr) || revision.replaceAll('-', '.'))
345
545
  const prevDate = String(doc.getAttribute(prevrevdateAttr) || '')
346
546
  const prevRevText = formatPrevRev(prevTitle, prevDate, doc)
347
-
547
+
348
548
  // Build header text
349
549
  let headerText
350
550
  if (prevTitle.trim().toLowerCase().startsWith(revisionLabel.toLowerCase())) {
@@ -352,13 +552,13 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
352
552
  } else {
353
553
  headerText = `${majorChangesText} ${revisionLabel} ${prevRevText}`
354
554
  }
355
-
555
+
356
556
  // Header row for this revision
357
557
  html.push('<tr class="rhrev-header">')
358
558
  html.push(`<th class="tableblock halign-left valign-top">${sectionLabel}</th>`)
359
559
  html.push(`<th class="tableblock halign-left valign-top"><strong>${headerText}</strong></th>`)
360
560
  html.push('</tr>')
361
-
561
+
362
562
  // All entry
363
563
  const allChange = catalog.allEntries[revision]
364
564
  if (allChange) {
@@ -367,17 +567,17 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
367
567
  html.push(`<td>${formatChangeAsHtmlList(allChange)}</td>`)
368
568
  html.push('</tr>')
369
569
  }
370
-
570
+
371
571
  // Block entries
372
572
  const entries = catalog.entries[revision] || []
373
573
  entries.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
374
-
574
+
375
575
  entries.forEach(entry => {
376
576
  const anchor = entry.anchor
377
- const title = entry.title || anchor.replace(/[_-]/g, ' ').split(' ').map(w =>
577
+ const title = entry.title || anchor.replace(/[_-]/g, ' ').split(' ').map(w =>
378
578
  w.charAt(0).toUpperCase() + w.slice(1)
379
579
  ).join(' ')
380
-
580
+
381
581
  html.push('<tr>')
382
582
 
383
583
  // Build link: local anchor if the section lives on the current page (directly or via include)
@@ -421,10 +621,10 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
421
621
  html.push('</tr>')
422
622
  })
423
623
  })
424
-
624
+
425
625
  html.push('</tbody>')
426
626
  html.push('</table>')
427
-
627
+
428
628
  return html.join('\n')
429
629
  }
430
630
 
@@ -433,30 +633,30 @@ module.exports.register = function (registry, context = {}) {
433
633
  // Register block macro for rhrev::[]
434
634
  registry.blockMacro('rhrev', function () {
435
635
  const self = this
436
-
636
+
437
637
  self.process(function (parent, target, attrs) {
438
638
  const doc = parent.getDocument()
439
-
639
+
440
640
  // Check if manual mode
441
641
  if (doc.getAttribute('rhrev') === 'manual') {
442
642
  return self.createBlock(parent, 'paragraph', '')
443
643
  }
444
-
644
+
445
645
  // Check initial release handling
446
646
  const revnumber = doc.getAttribute('revnumber') || '1.0'
447
647
  const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
448
-
648
+
449
649
  if (isInitialRelease(revnumber)) {
450
650
  if (initialHandling === 'skip') {
451
651
  return self.createBlock(parent, 'paragraph', '')
452
652
  }
453
-
653
+
454
654
  if (initialHandling === 'initial-release') {
455
655
  const html = generateInitialReleaseHtml(doc)
456
656
  return self.createBlock(parent, 'pass', html)
457
657
  }
458
658
  }
459
-
659
+
460
660
  // Try to use catalog from Antora pipeline extension (component-scoped by default).
461
661
  // global.antoraRhrevCatalog is the site-wide catalog; .byComponent holds per-component catalogs.
462
662
  let catalog
@@ -489,37 +689,98 @@ module.exports.register = function (registry, context = {}) {
489
689
  return self.createBlock(parent, 'pass', html)
490
690
  })
491
691
  })
492
-
692
+
693
+ // Propagates a marked nested block's rhrev attribute onto its enclosing
694
+ // a| table cell, and strips it from the nested block. See
695
+ // propagateCellContentMarks above for the full reasoning (the same
696
+ // isolated-inner-document gap the PDF gem's converter works around).
697
+ // Must be registered, and therefore run, before the change-bar tagging
698
+ // treeProcessor immediately below: treeprocessors all run before
699
+ // conversion starts, so if propagation happened later, that pass would
700
+ // already have tagged the nested paragraph at its original, unpropagated
701
+ // location, since it is a rollup context in its own right.
702
+ registry.treeProcessor(function () {
703
+ const self = this
704
+
705
+ self.process(function (doc) {
706
+ const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
707
+ propagateCellContentMarks(doc, prefix)
708
+ return doc
709
+ })
710
+ })
711
+
712
+ // Tag blocks carrying the current revision attribute with the rhrev-changed
713
+ // role so the site stylesheet can draw an HTML change bar (right-side border).
714
+ // Enabled by rhrev-change-bars, mirroring the PDF backend; color/width/offset
715
+ // overrides are emitted as CSS custom properties. Side is always right in HTML.
716
+ // The first six contexts get their own revision-history row when marked;
717
+ // the other nine (mirrors ROLLUP_CONTEXTS) roll up into their enclosing
718
+ // section's entry instead (see rollupChildRevisionEntries), but still get
719
+ // their own bar here: tagging needs no id or anchor, a role is a role
720
+ // regardless of block type.
721
+ registry.treeProcessor(function () {
722
+ const self = this
723
+
724
+ self.process(function (doc) {
725
+ if (!doc.hasAttribute('rhrev-change-bars')) return doc
726
+ const revnumber = doc.getAttribute('revnumber')
727
+ if (!revnumber) return doc
728
+ const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
729
+ const changeAttr = prefix + String(revnumber).replace(/\./g, '-')
730
+ const contexts = ROW_LEVEL_CONTEXTS.concat(ROLLUP_CONTEXTS)
731
+
732
+ let tagged = false
733
+ doc.findBy((b) => contexts.includes(b.getContext())).forEach((block) => {
734
+ const attrs = block.getAttributes()
735
+ if (!attrs || attrs[changeAttr] === undefined) return
736
+ const role = attrs.role ? attrs.role + ' rhrev-changed' : 'rhrev-changed'
737
+ block.setAttribute('role', role)
738
+ tagged = true
739
+ })
740
+ if (!tagged) return doc
741
+
742
+ const overrides = []
743
+ const color = doc.getAttribute('rhrev-change-bars-color')
744
+ if (color) overrides.push(`--rhrev-change-bar-color: #${String(color).replace(/^#/, '')};`)
745
+ const width = doc.getAttribute('rhrev-change-bars-width')
746
+ if (width) overrides.push(`--rhrev-change-bar-width: ${width}px;`)
747
+ const offset = doc.getAttribute('rhrev-change-bars-offset')
748
+ if (offset) overrides.push(`--rhrev-change-bar-offset: ${offset}px;`)
749
+ if (overrides.length) {
750
+ const style = self.createBlock(doc, 'pass', `<style>:root { ${overrides.join(' ')} }</style>`)
751
+ doc.getBlocks().unshift(style)
752
+ }
753
+ return doc
754
+ })
755
+ })
756
+
493
757
  // Register treeprocessor for auto-injection
494
758
  registry.treeProcessor(function () {
495
759
  const self = this
496
-
760
+
497
761
  self.process(function (doc) {
498
762
  if (!doc.hasAttribute('rhrev')) {
499
763
  return doc
500
764
  }
501
-
765
+
502
766
  const rhrevValue = doc.getAttribute('rhrev')
503
767
  if (rhrevValue === 'macro' || rhrevValue === 'manual') {
504
768
  return doc
505
769
  }
506
-
770
+
507
771
  // Auto-inject revision history at document start
508
772
  const blocks = doc.getBlocks()
509
-
510
- // Create rhrev block
511
- const rhrevBlock = self.createBlock(doc, 'pass', '')
512
-
773
+
513
774
  // Check initial release handling
514
775
  const revnumber = doc.getAttribute('revnumber') || '1.0'
515
776
  const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
516
-
777
+
517
778
  let html
518
779
  if (isInitialRelease(revnumber)) {
519
780
  if (initialHandling === 'skip') {
520
781
  return doc
521
782
  }
522
-
783
+
523
784
  if (initialHandling === 'initial-release') {
524
785
  html = generateInitialReleaseHtml(doc)
525
786
  } else {
@@ -528,19 +789,23 @@ module.exports.register = function (registry, context = {}) {
528
789
  }
529
790
  } else {
530
791
  const catalog = collectAllEntries(doc)
531
-
792
+
532
793
  if (catalog.sortedRevisions().length === 0) {
533
794
  return doc
534
795
  }
535
-
796
+
536
797
  html = generateHtmlOutput(doc, catalog)
537
798
  }
538
-
539
- rhrevBlock.setContent(html)
540
-
799
+
800
+ // Create the block with its content directly: self.createBlock(...).setContent(html)
801
+ // used to be two steps here, but Block#setContent is not a function
802
+ // on a modern @asciidoctor/core (confirmed against 2.2.9, a
803
+ // pre-existing bug, unrelated to anything else in this file).
804
+ const rhrevBlock = self.createBlock(doc, 'pass', html)
805
+
541
806
  // Insert at beginning
542
807
  blocks.unshift(rhrevBlock)
543
-
808
+
544
809
  return doc
545
810
  })
546
811
  })
@@ -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.2.3",
3
+ "version": "1.5.0",
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": {