asciidoctor-rhrev 1.8.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,23 @@
17
17
  // of their own. A marked one rolls up as a bullet into its nearest
18
18
  // enclosing section's entry instead (or the document-level -all entry, if
19
19
  // it has no enclosing section, or its enclosing section has no id).
20
- const { liftCellTextMarks } = require('./cell-text-marks.js')
20
+ const { liftCellTextMarks, cellTextMarks } = require('./cell-text-marks.js')
21
+ const {
22
+ thirdLevelMode,
23
+ thirdLevelKind,
24
+ revisionOf,
25
+ groupAttr,
26
+ groupKey,
27
+ sortMarkKeys,
28
+ revisionComparator,
29
+ mixedDraftGroups,
30
+ mixedDraftMessage,
31
+ markedRevisionOf,
32
+ markedRevisions,
33
+ dropReleaseMarks,
34
+ Matcher,
35
+ REVISION_RX
36
+ } = require('./third-level.js')
21
37
 
22
38
  const ROLLUP_CONTEXTS = ['paragraph', 'ulist', 'olist', 'dlist', 'admonition', 'open', 'quote', 'verse', 'sidebar']
23
39
 
@@ -40,7 +56,8 @@ function enclosingSection(node) {
40
56
  // appends another bullet onto its change text, or creates the entry if
41
57
  // anchorNode has none yet. Same shape as Converter#append_child_change_to_entry.
42
58
  function appendChildChangeToEntry(catalog, revision, anchorNode, changeText) {
43
- const entries = catalog.entries[revision] || (catalog.entries[revision] = [])
59
+ const key = catalog.revisionKey(revision)
60
+ const entries = catalog.entries[key] || (catalog.entries[key] = [])
44
61
  const anchorId = anchorNode.getId()
45
62
  const entry = entries.find((e) => e.anchor === anchorId)
46
63
  if (entry) {
@@ -56,9 +73,7 @@ function appendChildChangeToEntry(catalog, revision, anchorNode, changeText) {
56
73
  // Appends onto catalog's existing -all change text for this revision, or
57
74
  // sets it fresh if none exists yet. Same shape as Converter#append_child_change_to_all.
58
75
  function appendChildChangeToAll(catalog, revision, changeText) {
59
- const existing = catalog.allEntries[revision]
60
- const combined = (existing && String(existing).length) ? `${existing} * ${changeText}` : changeText
61
- catalog.addAllEntry(revision, combined)
76
+ catalog.appendAllEntry(revision, changeText)
62
77
  }
63
78
 
64
79
  // Gathers every marked rollup-context node in one unified, document-order
@@ -74,7 +89,7 @@ function rollupChildRevisionEntries(catalog, doc, prefix) {
74
89
  if (!attrs) return
75
90
  const section = enclosingSection(node)
76
91
  const hasSection = !!(section && section.getId())
77
- Object.keys(attrs).forEach((key) => {
92
+ sortMarkKeys(Object.keys(attrs), prefix, catalog.kind).forEach((key) => {
78
93
  if (!key.startsWith(prefix)) return
79
94
  if (key.includes('-all') || key.endsWith('-cover')) return
80
95
  const revision = key.substring(prefix.length)
@@ -122,7 +137,8 @@ function rollupChildRevisionEntries(catalog, doc, prefix) {
122
137
  function propagateCellContentMarks(doc, prefix) {
123
138
  const changeBarsEnabled = doc.hasAttribute('rhrev-change-bars')
124
139
  const revnumber = doc.getAttribute('revnumber')
125
- const changeAttr = (changeBarsEnabled && revnumber) ? prefix + String(revnumber).replace(/\./g, '-') : null
140
+ const matcher = (changeBarsEnabled && revnumber) ? new Matcher(prefix, revnumber, docThirdLevelMode(doc), docThirdLevelKind(doc), docIgnoredReleases(doc, prefix)) : null
141
+ const isChangeMark = matcher ? (key) => matcher.isMark(key) : null
126
142
 
127
143
  // First the raw-text rule (cell-text-marks.js): an rhrev attribute list
128
144
  // alone on the first line of any cell's content, any style, any row
@@ -131,7 +147,7 @@ function propagateCellContentMarks(doc, prefix) {
131
147
  // content as blocks. The role span it wraps a marked plain cell's text in
132
148
  // is what patchConvertTableForCellTagging's HTML lift finds for such a
133
149
  // cell, the same way a marked nested node's role serves an a| cell.
134
- liftCellTextMarks(doc, prefix, { changeAttr, wrapForTag: !!changeAttr })
150
+ liftCellTextMarks(doc, prefix, { isChangeMark, wrapForTag: !!matcher })
135
151
 
136
152
  doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
137
153
  const rows = table.getRows()
@@ -146,7 +162,7 @@ function propagateCellContentMarks(doc, prefix) {
146
162
  Object.keys(attrs).forEach((key) => {
147
163
  if (!key.startsWith(prefix)) return
148
164
  cell.setAttribute(key, attrs[key])
149
- if (key === changeAttr) {
165
+ if (matcher && matcher.isMark(key)) {
150
166
  const roles = attrs.role
151
167
  node.setAttribute('role', roles ? `${roles} rhrev-changed` : 'rhrev-changed')
152
168
  }
@@ -322,20 +338,99 @@ function addRoleClass (tagText, role) {
322
338
  return tagText.replace(/^<(td|th)\b/, `<$1 class="${role}"`)
323
339
  }
324
340
 
325
- // Catalog for storing revision entries
341
+ // The document's rhrev-third-level mode, warning once per document on an
342
+ // unrecognized value.
343
+ function docThirdLevelMode(doc) {
344
+ return thirdLevelMode(doc.getAttribute('rhrev-third-level'), (message) => {
345
+ if (doc.$$rhrevThirdLevelWarned) return
346
+ doc.$$rhrevThirdLevelWarned = true
347
+ doc.getLogger().warn(message)
348
+ })
349
+ }
350
+
351
+ // The document's rhrev-third-level-kind, or null without a mode, warning
352
+ // once per document on an unrecognized value.
353
+ function docThirdLevelKind(doc) {
354
+ return thirdLevelKind(doc.getAttribute('rhrev-third-level-kind'), docThirdLevelMode(doc), (message) => {
355
+ if (doc.$$rhrevThirdLevelKindWarned) return
356
+ doc.$$rhrevThirdLevelKindWarned = true
357
+ doc.getLogger().warn(message)
358
+ })
359
+ }
360
+
361
+ // The x-y releases a draft-kind document marks beside marks of their own
362
+ // drafts, warning once per release, same as
363
+ // ThirdLevel.drop_mixed_release_marks on the Ruby side. Reads the cell
364
+ // text marks liftCellTextMarks has not lifted yet, so it gives the same
365
+ // answer before and after propagateCellContentMarks. Memoized on the
366
+ // document.
367
+ function docMixedReleases(doc, prefix) {
368
+ if (doc.$$rhrevMixedReleases) return doc.$$rhrevMixedReleases
369
+ let releases = []
370
+ if (docThirdLevelKind(doc) === 'draft') {
371
+ const revisions = markedRevisions(doc, prefix)
372
+ doc.findBy((n) => n.getContext() === 'table').forEach((table) => {
373
+ const rows = table.getRows()
374
+ ;[].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat()).forEach((cell) => {
375
+ Object.keys(cellTextMarks(cell, prefix)).forEach((key) => {
376
+ const revision = markedRevisionOf(key, prefix)
377
+ if (revision) revisions.push(revision)
378
+ })
379
+ })
380
+ })
381
+ releases = mixedDraftGroups(revisions).map(([release, drafts]) => {
382
+ doc.getLogger().warn(mixedDraftMessage(prefix, release, drafts))
383
+ return release
384
+ })
385
+ }
386
+ doc.$$rhrevMixedReleases = releases
387
+ return releases
388
+ }
389
+
390
+ // The releases whose marks the document ignores: its own mixed releases,
391
+ // plus those of its component catalog, which antora-rhrev-html.js finds
392
+ // across every page of the component.
393
+ function docIgnoredReleases(doc, prefix) {
394
+ const componentName = doc.getAttribute('page-component-name')
395
+ const component = componentName && global.antoraRhrevCatalog && global.antoraRhrevCatalog.byComponent &&
396
+ global.antoraRhrevCatalog.byComponent[componentName]
397
+ return [...new Set(docMixedReleases(doc, prefix).concat((component && component.ignored) || []))]
398
+ }
399
+
400
+ // Catalog for storing revision entries. In collapse mode (thirdLevel) every
401
+ // revision is stored under its x-y group, and one anchor marked in several
402
+ // revisions of a group merges into one entry. kind orders an x-y revision
403
+ // relative to its x-y-z revisions.
326
404
  class RevisionCatalog {
327
- constructor() {
405
+ constructor(thirdLevel = null, kind = null) {
328
406
  this.entries = {} // Block-level entries: { '1-1': [...], '1-2': [...] }
329
407
  this.allEntries = {} // Document-level -all entries: { '1-1': 'text', '1-2': 'text' }
330
408
  this.sequence = 0
409
+ this.thirdLevel = thirdLevel
410
+ this.kind = kind
411
+ }
412
+
413
+ revisionKey(revision) {
414
+ return groupKey(revision, this.thirdLevel)
331
415
  }
332
416
 
333
417
  addEntry(revision, anchor, change, metadata = {}) {
334
- if (!this.entries[revision]) {
335
- this.entries[revision] = []
418
+ const key = this.revisionKey(revision)
419
+ if (!this.entries[key]) {
420
+ this.entries[key] = []
336
421
  }
337
422
 
338
- this.entries[revision].push({
423
+ if (this.thirdLevel === 'collapse') {
424
+ const existing = this.entries[key].find((e) => e.anchor === anchor)
425
+ if (existing) {
426
+ if (change && String(change).length) {
427
+ existing.change = (existing.change && String(existing.change).length) ? `${existing.change} * ${change}` : change
428
+ }
429
+ return
430
+ }
431
+ }
432
+
433
+ this.entries[key].push({
339
434
  anchor,
340
435
  change,
341
436
  title: metadata.title || null,
@@ -345,7 +440,12 @@ class RevisionCatalog {
345
440
  }
346
441
 
347
442
  addAllEntry(revision, change) {
348
- this.allEntries[revision] = change
443
+ this.allEntries[this.revisionKey(revision)] = change
444
+ }
445
+
446
+ appendAllEntry(revision, change) {
447
+ const existing = this.allEntries[this.revisionKey(revision)]
448
+ this.addAllEntry(revision, (existing && String(existing).length) ? `${existing} * ${change}` : change)
349
449
  }
350
450
 
351
451
  sortedRevisions() {
@@ -353,20 +453,8 @@ class RevisionCatalog {
353
453
  ...Object.keys(this.entries),
354
454
  ...Object.keys(this.allEntries)
355
455
  ])
356
- return Array.from(revisions).sort(compareRevisions)
357
- }
358
- }
359
-
360
- function compareRevisions(a, b) {
361
- const aParts = a.split('-').map(Number)
362
- const bParts = b.split('-').map(Number)
363
- const len = Math.max(aParts.length, bParts.length)
364
- for (let i = 0; i < len; i++) {
365
- const aVal = i < aParts.length ? aParts[i] : 0
366
- const bVal = i < bParts.length ? bParts[i] : 0
367
- if (aVal !== bVal) return bVal - aVal
456
+ return Array.from(revisions).sort(revisionComparator(this.kind))
368
457
  }
369
- return 0
370
458
  }
371
459
 
372
460
  function isInitialRelease(revnumber) {
@@ -427,25 +515,31 @@ function formatChangeAsHtmlList(text) {
427
515
  }
428
516
 
429
517
  function collectAllEntries(doc) {
430
- const catalog = new RevisionCatalog()
518
+ const thirdLevel = docThirdLevelMode(doc)
519
+ const catalog = new RevisionCatalog(thirdLevel, docThirdLevelKind(doc))
431
520
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
432
521
 
433
- // Collect document-level entries (-all)
434
- for (let major = 0; major <= 9; major++) {
435
- for (let minor = 0; minor <= 9; minor++) {
436
- const revision = `${major}-${minor}`
437
- const prevrevAttr = `${revision}-prevrev`
438
-
439
- if (doc.getAttribute(prevrevAttr)) {
440
- const allAttrName = `${prefix}${revision}-all`
441
- const allValue = doc.getAttribute(allAttrName)
442
-
443
- if (allValue) {
444
- catalog.addAllEntry(revision, allValue)
445
- }
446
- }
522
+ // Collect document-level entries (-all). Discover revisions from
523
+ // *-prevrev attributes so any number of segments works, the same as the
524
+ // Ruby side; with rhrev-third-level set, a revision's -all attribute
525
+ // counts too. Ascending order keeps merged collapse-mode text in
526
+ // revision order.
527
+ const revisions = new Set()
528
+ Object.keys(doc.getAttributes()).forEach((key) => {
529
+ let revision = null
530
+ if (key.endsWith('-prevrev')) {
531
+ revision = key.slice(0, -'-prevrev'.length)
532
+ } else if (thirdLevel && key.endsWith('-all')) {
533
+ revision = revisionOf(key.slice(0, -'-all'.length), prefix)
447
534
  }
448
- }
535
+ if (revision && REVISION_RX.test(revision)) revisions.add(revision)
536
+ })
537
+ Array.from(revisions).sort(revisionComparator(catalog.kind)).reverse().forEach((revision) => {
538
+ const allValue = doc.getAttribute(`${prefix}${revision}-all`)
539
+ if (allValue) {
540
+ catalog.appendAllEntry(revision, allValue)
541
+ }
542
+ })
449
543
 
450
544
  // Collect block-level entries by scanning document
451
545
  function scanBlocks(blocks) {
@@ -461,7 +555,7 @@ function collectAllEntries(doc) {
461
555
 
462
556
  // Also check if attributes are exposed differently
463
557
  // Some Asciidoctor.js versions expose them via getAttribute(name) only
464
- Object.keys(attrs).forEach(key => {
558
+ sortMarkKeys(Object.keys(attrs), prefix, catalog.kind).forEach(key => {
465
559
  const value = attrs[key]
466
560
 
467
561
  if (key.startsWith(prefix) && !key.includes('-all') && !key.endsWith('-cover')) {
@@ -654,11 +748,11 @@ function generateHtmlTable(doc, catalog, currentPage = '') {
654
748
  const allText = doc.getAttribute('rhrev-localization-all') || 'All'
655
749
 
656
750
  catalog.sortedRevisions().forEach(revision => {
657
- const prevrevAttr = `${revision}-prevrev`
658
- const prevrevdateAttr = `${revision}-prevrevdate`
751
+ const thirdLevel = docThirdLevelMode(doc)
752
+ const docAttrs = doc.getAttributes()
659
753
  const revisionLabel = String(doc.getAttribute('version-label') || 'Revision')
660
- const prevTitle = String(doc.getAttribute(prevrevAttr) || revision.replaceAll('-', '.'))
661
- const prevDate = String(doc.getAttribute(prevrevdateAttr) || '')
754
+ const prevTitle = String(groupAttr(docAttrs, revision, 'prevrev', thirdLevel) || revision.replaceAll('-', '.'))
755
+ const prevDate = String(groupAttr(docAttrs, revision, 'prevrevdate', thirdLevel) || '')
662
756
  const prevRevText = formatPrevRev(prevTitle, prevDate, doc)
663
757
 
664
758
  // Build header text
@@ -820,7 +914,13 @@ module.exports.register = function (registry, context = {}) {
820
914
 
821
915
  self.process(function (doc) {
822
916
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
917
+ // Dropping the ignored releases' marks before and after propagation
918
+ // removes them from blocks and nested cell content first, then from
919
+ // the cell text marks propagation lifts onto their cells.
920
+ const ignored = docIgnoredReleases(doc, prefix)
921
+ dropReleaseMarks(doc, prefix, ignored)
823
922
  propagateCellContentMarks(doc, prefix)
923
+ dropReleaseMarks(doc, prefix, ignored)
824
924
  return doc
825
925
  })
826
926
  })
@@ -842,7 +942,7 @@ module.exports.register = function (registry, context = {}) {
842
942
  const revnumber = doc.getAttribute('revnumber')
843
943
  if (!revnumber) return doc
844
944
  const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
845
- const changeAttr = prefix + String(revnumber).replace(/\./g, '-')
945
+ const matcher = new Matcher(prefix, revnumber, docThirdLevelMode(doc), docThirdLevelKind(doc), docIgnoredReleases(doc, prefix))
846
946
  const contexts = ROW_LEVEL_CONTEXTS.concat(ROLLUP_CONTEXTS)
847
947
 
848
948
  // A marked table cell was already tagged on its own nested content by
@@ -859,7 +959,7 @@ module.exports.register = function (registry, context = {}) {
859
959
  let tagged = false
860
960
  doc.findBy((b) => contexts.includes(b.getContext())).forEach((block) => {
861
961
  const attrs = block.getAttributes()
862
- if (!attrs || attrs[changeAttr] === undefined) return
962
+ if (!matcher.match(attrs)) return
863
963
  const role = attrs.role ? attrs.role + ' rhrev-changed' : 'rhrev-changed'
864
964
  block.setAttribute('role', role)
865
965
  tagged = true
@@ -1,6 +1,20 @@
1
1
  'use strict'
2
2
 
3
3
  const { liftCellTextMarks } = require('./cell-text-marks.js')
4
+ const {
5
+ thirdLevelMode,
6
+ thirdLevelKind,
7
+ revisionOf,
8
+ groupKey,
9
+ sortMarkKeys,
10
+ revisionComparator,
11
+ mixedDraftGroups,
12
+ mixedDraftMessage,
13
+ markedRevisionOf,
14
+ markedRevisions,
15
+ dropReleaseMarks,
16
+ REVISION_RX
17
+ } = require('./third-level.js')
4
18
 
5
19
  /**
6
20
  * Antora pipeline extension for revision history (rhrev).
@@ -43,7 +57,7 @@ noIncludeRegistry.includeProcessor(function () {
43
57
  // sibling file: the two files are independent Antora extension entry
44
58
  // points, loaded and registered separately, matching the existing
45
59
  // duplication between this file and antora-rhrev-asciidoc.js (each has
46
- // its own copy of, for example, compareRevisions and formatChangeAsHtmlList).
60
+ // its own copy of, for example, formatChangeAsHtmlList).
47
61
  const ROLLUP_CONTEXTS = ['paragraph', 'ulist', 'olist', 'dlist', 'admonition', 'open', 'quote', 'verse', 'sidebar']
48
62
 
49
63
  // Row-level: gets its own revision-history row when marked, requires an id.
@@ -151,9 +165,10 @@ function mergeCatalogs(catalogs) {
151
165
  allEntries: {},
152
166
  sortedRevisions() {
153
167
  const all = [...Object.keys(this.entries), ...Object.keys(this.allEntries)]
154
- return [...new Set(all)].sort(compareRevisions)
168
+ return [...new Set(all)].sort(revisionComparator(this.kind))
155
169
  }
156
170
  }
171
+ merged.kind = (catalogs.find((catalog) => catalog.kind) || {}).kind || null
157
172
  for (const catalog of catalogs) {
158
173
  for (const [revision, entries] of Object.entries(catalog.entries)) {
159
174
  if (!merged.entries[revision]) merged.entries[revision] = []
@@ -174,7 +189,8 @@ function mergeCatalogs(catalogs) {
174
189
  // the anchor node's own for a row-level entry (anchorNode is the marked
175
190
  // node itself, so meta === the anchor's own position).
176
191
  function appendChildChangeToEntry(catalog, revision, anchorNode, changeText, pageMeta, meta) {
177
- const entries = catalog.entries[revision] || (catalog.entries[revision] = [])
192
+ const key = groupKey(revision, catalog.thirdLevel)
193
+ const entries = catalog.entries[key] || (catalog.entries[key] = [])
178
194
  const anchorId = anchorNode.getId()
179
195
  const entry = entries.find((e) => e.anchor === anchorId)
180
196
  if (entry) {
@@ -194,9 +210,10 @@ function appendChildChangeToEntry(catalog, revision, anchorNode, changeText, pag
194
210
  // Appends onto catalog's existing -all change text for this revision, or
195
211
  // sets it fresh if none exists yet.
196
212
  function appendChildChangeToAll(catalog, revision, changeText) {
197
- const existing = catalog.allEntries[revision]
213
+ const key = groupKey(revision, catalog.thirdLevel)
214
+ const existing = catalog.allEntries[key]
198
215
  const combined = (existing && String(existing).length) ? `${existing} * ${changeText}` : changeText
199
- catalog.allEntries[revision] = combined
216
+ catalog.allEntries[key] = combined
200
217
  }
201
218
 
202
219
  function buildComponentCatalog(pageFiles, logger, quiet) {
@@ -213,7 +230,7 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
213
230
  ...Object.keys(this.allEntries)
214
231
  ]
215
232
  const uniqueRevisions = [...new Set(allRevisions)]
216
- return uniqueRevisions.sort(compareRevisions)
233
+ return uniqueRevisions.sort(revisionComparator(this.kind))
217
234
  }
218
235
  }
219
236
 
@@ -224,20 +241,63 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
224
241
  // Component attributes are in src.origin.descriptor.asciidoc.attributes
225
242
  const componentAttrs = firstFile.src?.origin?.descriptor?.asciidoc?.attributes || {}
226
243
  const prefix = componentAttrs.revhistoryprefix || 'rhrev'
244
+ catalog.thirdLevel = thirdLevelMode(componentAttrs['rhrev-third-level'], (message) => logger.warn(message))
245
+ catalog.kind = thirdLevelKind(componentAttrs['rhrev-third-level-kind'], catalog.thirdLevel, (message) => logger.warn(message))
227
246
 
228
247
  if (!quiet) {
229
248
  logger.debug(`rhrev-extension: Using prefix '${prefix}'`)
230
249
  }
231
250
 
232
- // Collect document-level entries by scanning for *-prevrev attributes (supports N-part versions)
251
+ // Parse every page before collecting anything (the parse itself is
252
+ // described where the block-level entries are collected): a draft-kind
253
+ // component ignores a release
254
+ // marked beside its own drafts on any page, which is only known once
255
+ // every page has been read. catalog.ignored lists those releases for
256
+ // antora-rhrev-asciidoc.js, so page tagging ignores them too.
257
+ const parsedPages = []
258
+ pageFiles.forEach((file) => {
259
+ if (!file.contents) return
260
+ const content = file.contents.toString()
261
+ let doc
262
+ try {
263
+ doc = asciidoctor.load(content, { safe: 'safe', sourcemap: true, extension_registry: noIncludeRegistry, attributes: componentAttrs })
264
+ } catch (e) {
265
+ if (!quiet) logger.debug(`rhrev-extension: failed to parse ${file.src?.relative}, skipped: ${e.message}`)
266
+ return
267
+ }
268
+ liftCellTextMarks(doc, prefix)
269
+ parsedPages.push({ file, content, doc })
270
+ })
271
+ catalog.ignored = []
272
+ if (catalog.kind === 'draft') {
273
+ const marked = Object.keys(componentAttrs).map((key) => markedRevisionOf(key, prefix)).filter(Boolean)
274
+ parsedPages.forEach(({ doc }) => marked.push(...markedRevisions(doc, prefix)))
275
+ catalog.ignored = mixedDraftGroups(marked).map(([release, drafts]) => {
276
+ logger.warn(mixedDraftMessage(prefix, release, drafts))
277
+ return release
278
+ })
279
+ parsedPages.forEach(({ doc }) => dropReleaseMarks(doc, prefix, catalog.ignored))
280
+ }
281
+
282
+ // Collect document-level entries by scanning for *-prevrev attributes
283
+ // (supports N-part versions); with rhrev-third-level set, a revision's
284
+ // -all attribute counts too. Ascending order keeps merged collapse-mode
285
+ // text in revision order.
233
286
  const prevrevRx = /^([0-9]+(?:-[0-9]+)+)-prevrev$/
287
+ const revisions = new Set()
234
288
  for (const attrName of Object.keys(componentAttrs)) {
235
289
  const m = attrName.match(prevrevRx)
236
- if (!m) continue
237
- const revision = m[1]
290
+ if (m) {
291
+ revisions.add(m[1])
292
+ } else if (catalog.thirdLevel && attrName.endsWith('-all')) {
293
+ const revision = revisionOf(attrName.slice(0, -'-all'.length), prefix)
294
+ if (revision && REVISION_RX.test(revision)) revisions.add(revision)
295
+ }
296
+ }
297
+ for (const revision of Array.from(revisions).sort(revisionComparator(catalog.kind)).reverse()) {
238
298
  const allValue = componentAttrs[`${prefix}${revision}-all`]
239
- if (allValue) {
240
- catalog.allEntries[revision] = allValue
299
+ if (allValue && !catalog.ignored.includes(revision)) {
300
+ appendChildChangeToAll(catalog, revision, allValue)
241
301
  if (!quiet) {
242
302
  logger.debug(`rhrev-extension: Found -all entry for ${revision}`)
243
303
  }
@@ -253,11 +313,7 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
253
313
  // by [#anchor], .Title, or a heading line, so it had no way to reach
254
314
  // paragraphs, lists, admonitions, or any of the other rollup contexts,
255
315
  // or table cells, at all. Real parsing has no such limit.
256
- pageFiles.forEach(file => {
257
- if (!file.contents) return
258
-
259
- const content = file.contents.toString()
260
-
316
+ parsedPages.forEach(({ file, content, doc }) => {
261
317
  if (!quiet && content.includes(prefix)) {
262
318
  logger.debug(`rhrev-extension: Scanning ${file.src?.relative} for block attributes`)
263
319
  }
@@ -274,14 +330,6 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
274
330
  })
275
331
  catalog.fileMeta[file.src?.relative || file.path] = fileTagBoundaries
276
332
 
277
- let doc
278
- try {
279
- doc = asciidoctor.load(content, { safe: 'safe', sourcemap: true, extension_registry: noIncludeRegistry, attributes: componentAttrs })
280
- } catch (e) {
281
- if (!quiet) logger.debug(`rhrev-extension: failed to parse ${file.src?.relative}, skipped: ${e.message}`)
282
- return
283
- }
284
-
285
333
  const pageMeta = {
286
334
  page: file.src?.relative || file.path,
287
335
  component: file.src?.component,
@@ -310,12 +358,20 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
310
358
  if (!blockId) return
311
359
  const attrs = block.getAttributes()
312
360
  if (!attrs) return
313
- Object.keys(attrs).forEach((key) => {
361
+ sortMarkKeys(Object.keys(attrs), prefix, catalog.kind).forEach((key) => {
314
362
  if (!key.startsWith(prefix)) return
315
363
  if (key.includes('-all') || key.endsWith('-cover')) return
316
364
  const revision = key.substring(prefix.length)
317
- catalog.entries[revision] = catalog.entries[revision] || []
318
- catalog.entries[revision].push({
365
+ if (catalog.thirdLevel === 'collapse') {
366
+ const grouped = catalog.entries[groupKey(revision, 'collapse')] || []
367
+ if (grouped.some((e) => e.anchor === blockId && e.page === pageMeta.page)) {
368
+ appendChildChangeToEntry(catalog, revision, block, String(attrs[key]), pageMeta, metaFor(block))
369
+ return
370
+ }
371
+ }
372
+ const rowKey = groupKey(revision, catalog.thirdLevel)
373
+ catalog.entries[rowKey] = catalog.entries[rowKey] || []
374
+ catalog.entries[rowKey].push({
319
375
  anchor: blockId,
320
376
  change: String(attrs[key]),
321
377
  title: block.getTitle(),
@@ -378,7 +434,7 @@ function buildComponentCatalog(pageFiles, logger, quiet) {
378
434
  if (!attrs) return
379
435
  const section = enclosingSection(node)
380
436
  const hasSection = !!(section && section.getId())
381
- Object.keys(attrs).forEach((key) => {
437
+ sortMarkKeys(Object.keys(attrs), prefix, catalog.kind).forEach((key) => {
382
438
  if (!key.startsWith(prefix)) return
383
439
  if (key.includes('-all') || key.endsWith('-cover')) return
384
440
  const revision = key.substring(prefix.length)
@@ -501,7 +557,7 @@ function generateRevisionHistoryHtml(file, catalog, logger, quiet) {
501
557
  ]
502
558
  const uniqueRevisions = [...new Set(allRevisions)]
503
559
 
504
- const sortedRevisions = uniqueRevisions.sort(compareRevisions)
560
+ const sortedRevisions = uniqueRevisions.sort(revisionComparator(catalog.kind))
505
561
 
506
562
  // Generate revision rows
507
563
  sortedRevisions.forEach(revision => {
@@ -582,18 +638,6 @@ function stripVerbatimBlocks(content) {
582
638
  return out.join('\n')
583
639
  }
584
640
 
585
- function compareRevisions(a, b) {
586
- const aParts = a.split('-').map(Number)
587
- const bParts = b.split('-').map(Number)
588
- const len = Math.max(aParts.length, bParts.length)
589
- for (let i = 0; i < len; i++) {
590
- const aVal = i < aParts.length ? aParts[i] : 0
591
- const bVal = i < bParts.length ? bParts[i] : 0
592
- if (aVal !== bVal) return bVal - aVal
593
- }
594
- return 0
595
- }
596
-
597
641
  function formatChangeAsHtmlList(text) {
598
642
  if (!text) return ''
599
643
 
@@ -21,8 +21,9 @@
21
21
  // (global.Opal), so the accepted syntax matches the Ruby side exactly. When
22
22
  // no Opal runtime is present the cell is left untouched.
23
23
  //
24
- // wrapForTag: when set, and the lifted attributes include changeAttr (the
25
- // current revision's own attribute, change bars enabled), the remaining
24
+ // wrapForTag: when set, and isChangeMark accepts one of the lifted
25
+ // attributes (a mark of the current revision, change bars enabled,
26
+ // third-level.js Matcher#isMark), the remaining
26
27
  // cell text is wrapped in an unconstrained role span carrying the
27
28
  // rhrev-changed role. Antora's HTML pipeline cannot tag a cell's own
28
29
  // <td>/<th> from a treeprocessor; antora-rhrev-asciidoc.js's converter
@@ -49,8 +50,18 @@ function parseAttributeList (list) {
49
50
  return attrs
50
51
  }
51
52
 
53
+ // The rhrev marks a cell's raw text carries on its first line, as a plain
54
+ // object, without lifting them. Empty when there are none.
55
+ function cellTextMarks (cell, prefix) {
56
+ const raw = cell.text
57
+ const m = typeof raw === 'string' && LEADING_ATTRIBUTE_LIST_RX.exec(raw)
58
+ const attrs = m && parseAttributeList(m[1])
59
+ if (!attrs) return {}
60
+ return Object.fromEntries(Object.entries(attrs).filter(([key]) => key.startsWith(prefix)))
61
+ }
62
+
52
63
  // Lifts a mark from one cell. Returns true when the cell changed.
53
- function liftCellTextMark (cell, prefix, { changeAttr = null, wrapForTag = false } = {}) {
64
+ function liftCellTextMark (cell, prefix, { isChangeMark = null, wrapForTag = false } = {}) {
54
65
  const raw = cell.text
55
66
  if (typeof raw !== 'string') return false
56
67
  const m = LEADING_ATTRIBUTE_LIST_RX.exec(raw)
@@ -62,7 +73,7 @@ function liftCellTextMark (cell, prefix, { changeAttr = null, wrapForTag = false
62
73
  markKeys.forEach((key) => cell.setAttribute(key, attrs[key]))
63
74
  let rest = raw.slice(m[0].length)
64
75
  const style = cell.getStyle()
65
- if (wrapForTag && changeAttr && markKeys.includes(changeAttr) && style !== 'asciidoc' && style !== 'literal') {
76
+ if (wrapForTag && isChangeMark && markKeys.some(isChangeMark) && style !== 'asciidoc' && style !== 'literal') {
66
77
  rest = `[.rhrev-changed]##${rest}##`
67
78
  }
68
79
  cell.text = rest
@@ -81,4 +92,4 @@ function liftCellTextMarks (doc, prefix, options = {}) {
81
92
  return lifted
82
93
  }
83
94
 
84
- module.exports = { liftCellTextMarks, liftCellTextMark, LEADING_ATTRIBUTE_LIST_RX }
95
+ module.exports = { liftCellTextMarks, liftCellTextMark, cellTextMarks, LEADING_ATTRIBUTE_LIST_RX }
@@ -0,0 +1,228 @@
1
+ 'use strict'
2
+
3
+ // Support for x.y.z revision numbers, selected by the rhrev-third-level
4
+ // attribute. Mirrors lib/asciidoctor/rhrev/third_level.rb on the Ruby side:
5
+ //
6
+ // collapse: the revision history table groups every revision by its first
7
+ // two levels, and the change bar covers every mark of the current x.y
8
+ // release.
9
+ // expand: the table lists every revision on its own, and the change bar
10
+ // covers every mark of the current x.y release. The third-level lanes
11
+ // are PDF-only; HTML draws one bar per element.
12
+ //
13
+ // Without the attribute, only a mark for exactly the current revnumber
14
+ // draws a bar, and every revision gets its own table group.
15
+ //
16
+ // The rhrev-third-level-kind attribute says what an x.y.z revision is:
17
+ //
18
+ // draft: the default; x.y.1, x.y.2, ... are drafts leading up to the
19
+ // release x.y, so x.y sorts after its drafts, an x.y mark draws no bar
20
+ // while the revnumber names a draft, and an x.y mark beside marks of its
21
+ // own drafts is dropped with a warning (mixedDraftGroups,
22
+ // dropReleaseMarks).
23
+ // amendment: x.y.1, x.y.2, ... amend the released x.y, so x.y sorts
24
+ // before them and its marks draw a bar for every amendment.
25
+
26
+ const MODES = ['expand', 'collapse']
27
+ const KINDS = ['draft', 'amendment']
28
+ const DEFAULT_KIND = 'draft'
29
+ const REVISION_RX = /^\d+(?:-\d+)*$/
30
+
31
+ // The mode for an attribute value, 'expand' or 'collapse', or null when
32
+ // the attribute is unset. An unrecognized value calls warn and yields null.
33
+ function thirdLevelMode (value, warn) {
34
+ if (value === undefined || value === null || value === false) return null
35
+ value = String(value)
36
+ if (MODES.includes(value)) return value
37
+ if (warn) warn(`rhrev-third-level must be expand or collapse, got '${value}'; ignoring it`)
38
+ return null
39
+ }
40
+
41
+ // The kind, 'draft' or 'amendment', for an attribute value, or null when
42
+ // mode is null. An unrecognized value calls warn and yields the default.
43
+ function thirdLevelKind (value, mode, warn) {
44
+ if (!mode) return null
45
+ if (value === undefined || value === null || value === false) return DEFAULT_KIND
46
+ value = String(value)
47
+ if (KINDS.includes(value)) return value
48
+ if (warn) warn(`rhrev-third-level-kind must be draft or amendment, got '${value}'; using ${DEFAULT_KIND}`)
49
+ return DEFAULT_KIND
50
+ }
51
+
52
+ // The revision a mark names, "1-3-2" for rhrev1-3-2, or null for any other
53
+ // attribute, including -all, -cover, and configuration keys sharing the
54
+ // prefix.
55
+ function revisionOf (key, prefix) {
56
+ if (!key.startsWith(prefix)) return null
57
+ const revision = key.substring(prefix.length)
58
+ return REVISION_RX.test(revision) ? revision : null
59
+ }
60
+
61
+ function revisionParts (revision) {
62
+ return revision.split('-').map(Number)
63
+ }
64
+
65
+ // The comparable form of a revision. In draft kind an x-y revision is the
66
+ // release its x-y-z drafts lead up to, so it sorts after them; otherwise
67
+ // x-y sorts as x-y-0, before x-y-1.
68
+ function sortKey (revision, kind) {
69
+ const parts = revisionParts(revision)
70
+ return kind === 'draft' && parts.length === 2 ? parts.concat(Infinity) : parts
71
+ }
72
+
73
+ function compareAscending (a, b) {
74
+ const len = Math.max(a.length, b.length)
75
+ for (let i = 0; i < len; i++) {
76
+ const x = i < a.length ? a[i] : 0
77
+ const y = i < b.length ? b[i] : 0
78
+ if (x !== y) return x < y ? -1 : 1
79
+ }
80
+ return 0
81
+ }
82
+
83
+ // A comparator ordering revisions newest first for the given kind.
84
+ function revisionComparator (kind) {
85
+ return (a, b) => compareAscending(sortKey(b, kind), sortKey(a, kind))
86
+ }
87
+
88
+ // The x-y groups whose release mark appears beside marks of its own
89
+ // drafts, each as [release, drafts], from the revisions a document marks.
90
+ function mixedDraftGroups (revisions) {
91
+ const unique = [...new Set(revisions)]
92
+ return unique
93
+ .filter((revision) => revision.split('-').length === 2)
94
+ .map((release) => [release, unique
95
+ .filter((revision) => revision.startsWith(`${release}-`) && revision.split('-').length === 3)
96
+ .sort((a, b) => compareAscending(revisionParts(a), revisionParts(b)))])
97
+ .filter(([, drafts]) => drafts.length > 0)
98
+ }
99
+
100
+ // The warning for one mixed group (mixedDraftGroups).
101
+ function mixedDraftMessage (prefix, release, drafts) {
102
+ return `${prefix}${release} marks the release that its drafts ${drafts.map((d) => prefix + d).join(', ')} lead up to; ignoring the ${prefix}${release} marks, mark each change with the draft that made it`
103
+ }
104
+
105
+ // The revision an attribute names as a mark, or as an -all or -cover
106
+ // entry, or null.
107
+ function markedRevisionOf (key, prefix) {
108
+ return revisionOf(key.replace(/-(?:all|cover)$/, ''), prefix)
109
+ }
110
+
111
+ // Calls fn(node, attributes) for every node that carries marks: the
112
+ // document, every block in it and in nested documents, and every table
113
+ // cell.
114
+ function eachMarkedNode (doc, fn) {
115
+ doc.findBy({ traverse_documents: true }, () => true).forEach((node) => {
116
+ fn(node, node.getAttributes() || {})
117
+ if (node.getContext() !== 'table') return
118
+ const rows = node.getRows()
119
+ ;[].concat(rows.head.flat(), rows.foot.flat(), rows.body.flat()).forEach((cell) => fn(cell, cell.getAttributes() || {}))
120
+ })
121
+ }
122
+
123
+ // Every revision doc marks, through marks, -all, and -cover entries.
124
+ function markedRevisions (doc, prefix) {
125
+ const revisions = []
126
+ eachMarkedNode(doc, (node, attrs) => Object.keys(attrs).forEach((key) => {
127
+ const revision = markedRevisionOf(key, prefix)
128
+ if (revision) revisions.push(revision)
129
+ }))
130
+ return revisions
131
+ }
132
+
133
+ // Removes every mark, -all, and -cover entry of the given releases from
134
+ // doc, same as ThirdLevel.drop_mixed_release_marks on the Ruby side.
135
+ function dropReleaseMarks (doc, prefix, releases) {
136
+ if (!releases.length) return
137
+ eachMarkedNode(doc, (node, attrs) => Object.keys(attrs).forEach((key) => {
138
+ if (releases.includes(markedRevisionOf(key, prefix))) node.removeAttribute(key)
139
+ }))
140
+ }
141
+
142
+ // The table group a revision belongs to: its first two levels in collapse
143
+ // mode, the revision itself otherwise.
144
+ function groupKey (revision, mode) {
145
+ if (mode !== 'collapse') return revision
146
+ const parts = revision.split('-')
147
+ return parts.length > 2 ? parts.slice(0, 2).join('-') : revision
148
+ }
149
+
150
+ // A table group's prevrev or prevrevdate value from a plain attribute
151
+ // object: the group's own attribute, or in collapse mode, the lowest
152
+ // third-level revision's attribute that is set.
153
+ function groupAttr (attrs, revision, suffix, mode) {
154
+ const own = attrs[`${revision}-${suffix}`]
155
+ if (own !== undefined && own !== null && own !== '') return own
156
+ if (mode !== 'collapse' || revision.split('-').length !== 2) return null
157
+ const rx = new RegExp(`^${revision}-(\\d+)-${suffix}$`)
158
+ let lowest = null
159
+ for (const key of Object.keys(attrs)) {
160
+ const m = rx.exec(key)
161
+ if (m && (lowest === null || Number(m[1]) < lowest[0])) lowest = [Number(m[1]), key]
162
+ }
163
+ return lowest ? attrs[lowest[1]] : null
164
+ }
165
+
166
+ // Attribute keys with every mark first, in ascending revision order, so
167
+ // change texts merged into one collapse-mode entry follow revision order,
168
+ // not source order. Every other key keeps its place after them.
169
+ function sortMarkKeys (keys, prefix, kind = null) {
170
+ return keys
171
+ .map((key, index) => ({ key, index, revision: revisionOf(key, prefix) }))
172
+ .sort((a, b) => {
173
+ if (!a.revision !== !b.revision) return a.revision ? -1 : 1
174
+ const byRevision = a.revision ? compareAscending(sortKey(a.revision, kind), sortKey(b.revision, kind)) : 0
175
+ return byRevision || a.index - b.index
176
+ })
177
+ .map(({ key }) => key)
178
+ }
179
+
180
+ // Decides which marks draw a change bar for the current revnumber.
181
+ class Matcher {
182
+ // ignored lists releases whose marks never match (mixedDraftGroups).
183
+ constructor (prefix, revnumber, mode, kind = DEFAULT_KIND, ignored = []) {
184
+ revnumber = String(revnumber)
185
+ this.prefix = prefix
186
+ this.kind = kind
187
+ this.ignored = ignored
188
+ this.exact = prefix + revnumber.replace(/\./g, '-')
189
+ this.mode = null
190
+ if (mode && /^\d+(?:\.\d+)+$/.test(revnumber)) {
191
+ const parts = revnumber.split('.').map(Number)
192
+ this.mode = mode
193
+ this.base = parts.slice(0, 2)
194
+ this.currentZ = parts[2]
195
+ }
196
+ }
197
+
198
+ isMark (key) {
199
+ if (!this.mode) return key === this.exact
200
+ const revision = revisionOf(key, this.prefix)
201
+ if (!revision || this.ignored.includes(revision)) return false
202
+ const parts = revisionParts(revision)
203
+ if (parts[0] !== this.base[0] || parts[1] !== this.base[1]) return false
204
+ if (this.currentZ === undefined) return true
205
+ return parts.length > 2 ? parts[2] <= this.currentZ : this.kind !== 'draft'
206
+ }
207
+
208
+ match (attrs) {
209
+ return !!attrs && Object.keys(attrs).some((key) => this.isMark(key))
210
+ }
211
+ }
212
+
213
+ module.exports = {
214
+ thirdLevelMode,
215
+ thirdLevelKind,
216
+ revisionOf,
217
+ groupKey,
218
+ groupAttr,
219
+ sortMarkKeys,
220
+ revisionComparator,
221
+ mixedDraftGroups,
222
+ mixedDraftMessage,
223
+ markedRevisionOf,
224
+ markedRevisions,
225
+ dropReleaseMarks,
226
+ Matcher,
227
+ REVISION_RX
228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asciidoctor-rhrev",
3
- "version": "1.8.3",
3
+ "version": "1.9.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": {
@@ -28,6 +28,7 @@
28
28
  "antora-extension/antora-rhrev-html.js",
29
29
  "antora-extension/antora-rhrev-placeholder.js",
30
30
  "antora-extension/cell-text-marks.js",
31
+ "antora-extension/third-level.js",
31
32
  "README.adoc",
32
33
  "LICENSE"
33
34
  ]