asciidoctor-rhrev 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+
4
+ This project includes code from Asciidoctor PDF by OpenDevise Inc. and the Asciidoctor Project,
5
+ licensed under the MIT License:
6
+
7
+ Copyright (c) 2025 白一百 baiyibai
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.adoc ADDED
@@ -0,0 +1,138 @@
1
+ = rhrev - Asciidoctor PDF Revision History Extension
2
+
3
+ rhrev is a comprehensive revision history tracking system for the AsciiDoc and Antora ecosystems which automatically creates a grouped, formatted table of document changes.
4
+
5
+ https://baiyibai-antora.gitlab.io
6
+
7
+ It supports several converters/backends:
8
+
9
+ * asciidoctor-pdf
10
+ +
11
+ image::resources/ruby-pdf-output.png[width=50%]
12
+ +
13
+ xref:./tests/block/test-block-ordering-comprehensive.pdf[Comprehensive Block Ordering Test]
14
+ * asciidoctor
15
+ +
16
+ image::resources/ruby-html5-output.png[width=50%]
17
+ +
18
+ * Antora
19
+ +
20
+ image::resources/nodejs-html5-output.png[width=50%]
21
+ +
22
+ * Antora-Assembler to asciidoctor-pdf
23
+
24
+ == Features
25
+
26
+ * *Automatic revision tracking* with version-based grouping
27
+ * *Dynamic version labels* using `:version-label:` attribute
28
+ * *Localization support* for multiple languages
29
+ * *Export to AsciiDoc* for inclusion in other documents
30
+ * *Flexible placement* (auto or manual via macro)
31
+ * *Extensive customization* options for styling and formatting
32
+ * [PDF] *Clickable page references* that link to actual document locations
33
+ * [PDF] *Flexible xrefstyle support* (full, short, basic) for all block types
34
+ * [PDF] *Custom captions* for Examples, Tables, Listings, and Figures
35
+ * *FAST*
36
+
37
+ == Inspiration
38
+
39
+ This extension draws inspiration from:
40
+
41
+ * **DITA-like automatic revision histories** - Darwin Information Typing Architecture (DITA) provides automatic change tracking and revision metadata management in technical documentation systems
42
+ * **LaTeX revision tracking tools** - LaTeX packages like `changes`, `trackchanges`, and custom revision history macros that automate change documentation in academic and technical publications
43
+ * **Document management systems** - Enterprise documentation platforms that maintain automatic audit trails and change histories
44
+
45
+ The goal was to bring similar automated revision tracking capabilities to the Asciidoctor ecosystem, with the added benefit of clickable page references and flexible customization options tailored for PDF technical documentation.
46
+
47
+ == Quick Start
48
+
49
+ === Installation
50
+
51
+ The asciidoctor-pdf-rhrev extension is available as a Ruby Gem.
52
+
53
+ https://rubygems.org/gems/asciidoctor-pdf-rhrev[^]
54
+
55
+ Complete these steps to use:
56
+
57
+ . Install the required Gems:
58
+ +
59
+ [source,bash]
60
+ ----
61
+ gem install asciidoctor
62
+ gem install asciidoctor-pdf
63
+ gem install asciidoctor-pdf-rhrev
64
+ ----
65
+ +
66
+ . Render your document:
67
+ +
68
+ [source,bash]
69
+ ----
70
+ asciidoctor-pdf -r asciidoctor-pdf-rhrev document.adoc
71
+ ----
72
+
73
+ === Basic Usage
74
+
75
+ [source,asciidoc]
76
+ ----
77
+ = My Document
78
+ :doctype: book
79
+ :revnumber: 1.1
80
+ :rhrev:
81
+ :version-label: Version
82
+ // 1.0 Metadata
83
+ :1-1-prevrev: 1.0
84
+ :1-1-prevrevdate: 2024-02-01
85
+ :rhrev1-1-all: Feature update
86
+
87
+ [rhrev1-1="Updated introduction"]
88
+ == Introduction
89
+
90
+ Content here...
91
+ ----
92
+
93
+ == Documentation
94
+
95
+ See link:USAGE.adoc[] for complete documentation including:
96
+
97
+ * Configuration options
98
+ * Advanced features
99
+ * Localization
100
+ * Export functionality
101
+ * xrefstyle support
102
+ * Examples and use cases
103
+
104
+ == Testing
105
+
106
+ Run the test suite:
107
+
108
+ [source,bash]
109
+ ----
110
+ ./run-tests.sh
111
+ ----
112
+
113
+ This will generate PDFs for all test cases in the `tests/` directory.
114
+
115
+ === Ruby GEM Build Instructions (for 白一百 baiyibai)
116
+
117
+ [source,bash]
118
+ ----
119
+ gem build asciidoctor-pdf-rhrev.gemspec
120
+ gem install ./asciidoctor-pdf-rhrev-1.0.0.gem
121
+ ----
122
+
123
+ == License
124
+
125
+ MIT License. See the link:LICENSE[] file for details.
126
+
127
+ == Contributing
128
+
129
+ Contributions welcome! Please ensure all tests pass before submitting changes.
130
+
131
+ == Copyright
132
+
133
+ Copyright (C) 2025-present 白一百 baiyibai
134
+
135
+ * The image _The «Public Domain Mark» icon from Creative Commons with transparent background._ is in the public domain and taken from: +
136
+ https://commons.wikimedia.org/wiki/File:Cc-public_domain_mark.svg[^]
137
+
138
+ For the full text of the license, see the link:LICENSE[] file.
@@ -0,0 +1,495 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Antora extension for revision history (rhrev) block macro.
5
+ *
6
+ * Provides HTML5 backend support for revision history tables.
7
+ * Auto-injects revision history at document start unless :rhrev: macro or manual mode.
8
+ *
9
+ * Usage in antora-playbook.yml:
10
+ * antora:
11
+ * extensions:
12
+ * - require: ./antora-extension/antora-rhrev-asciidoc.js
13
+ */
14
+
15
+ // Catalog for storing revision entries
16
+ class RevisionCatalog {
17
+ constructor() {
18
+ this.entries = {} // Block-level entries: { '1-1': [...], '1-2': [...] }
19
+ this.allEntries = {} // Document-level -all entries: { '1-1': 'text', '1-2': 'text' }
20
+ this.sequence = 0
21
+ }
22
+
23
+ addEntry(revision, anchor, change, metadata = {}) {
24
+ if (!this.entries[revision]) {
25
+ this.entries[revision] = []
26
+ }
27
+
28
+ this.entries[revision].push({
29
+ anchor,
30
+ change,
31
+ title: metadata.title || null,
32
+ sequence: this.sequence++,
33
+ ...metadata
34
+ })
35
+ }
36
+
37
+ addAllEntry(revision, change) {
38
+ this.allEntries[revision] = change
39
+ }
40
+
41
+ sortedRevisions() {
42
+ // Collect all unique revisions from entries and allEntries
43
+ const revisions = new Set([
44
+ ...Object.keys(this.entries),
45
+ ...Object.keys(this.allEntries)
46
+ ])
47
+
48
+ return Array.from(revisions).sort((a, b) => {
49
+ const [aMajor, aMinor] = a.split('-').map(Number)
50
+ const [bMajor, bMinor] = b.split('-').map(Number)
51
+
52
+ if (aMajor !== bMajor) return bMajor - aMajor
53
+ return bMinor - aMinor
54
+ })
55
+ }
56
+ }
57
+
58
+ // Helper functions
59
+ function isInitialRelease(revnumber) {
60
+ return revnumber === '1.0' || revnumber === '1'
61
+ }
62
+
63
+ function formatPrevRev(prevTitle, prevDate, doc) {
64
+ const includeDateAttr = doc.getAttribute('rhrev-customization-prevrev-include-date')
65
+ const includeDate = includeDateAttr !== undefined
66
+
67
+ if (includeDate && prevDate) {
68
+ const dateFormat = doc.getAttribute('rhrev-customization-date-format') || '%Y-%m-%d'
69
+ // Simple date formatting (Node.js doesn't have strftime, keep it simple)
70
+ return `${prevTitle} ${prevDate}`
71
+ }
72
+
73
+ return prevTitle
74
+ }
75
+
76
+ function preprocessAttributeContent(text) {
77
+ // Handle line continuations (backslash at end of line)
78
+ return text.replace(/\\\s*\n\s*/g, ' ')
79
+ }
80
+
81
+ function formatChangeAsHtmlList(text) {
82
+ if (!text) return ''
83
+
84
+ const processed = preprocessAttributeContent(text)
85
+
86
+ // Split by asterisks to create list items
87
+ const tokens = processed.split(/\s(?=\*+\s)/)
88
+ const items = []
89
+
90
+ if (tokens.length === 1 && !processed.includes('*')) {
91
+ // Single item without asterisk - wrap in list anyway
92
+ items.push({ level: 1, content: processed })
93
+ } else {
94
+ tokens.forEach(token => {
95
+ if (!token.trim()) return
96
+
97
+ const match = token.trim().match(/^(\*+)\s+(.+)/)
98
+ if (match) {
99
+ items.push({ level: match[1].length, content: match[2] })
100
+ } else {
101
+ items.push({ level: 1, content: token.trim() })
102
+ }
103
+ })
104
+ }
105
+
106
+ // Always generate HTML list
107
+ const html = ['<ul>']
108
+ items.forEach(item => {
109
+ html.push(`<li>${item.content}</li>`)
110
+ })
111
+ html.push('</ul>')
112
+
113
+ return html.join('\n')
114
+ }
115
+
116
+ function collectAllEntries(doc) {
117
+ const catalog = new RevisionCatalog()
118
+ const prefix = doc.getAttribute('revhistoryprefix') || 'rhrev'
119
+
120
+ // Collect document-level entries (-all)
121
+ for (let major = 0; major <= 9; major++) {
122
+ for (let minor = 0; minor <= 9; minor++) {
123
+ const revision = `${major}-${minor}`
124
+ const prevrevAttr = `${revision}-prevrev`
125
+
126
+ if (doc.getAttribute(prevrevAttr)) {
127
+ const allAttrName = `${prefix}${revision}-all`
128
+ const allValue = doc.getAttribute(allAttrName)
129
+
130
+ if (allValue) {
131
+ catalog.addAllEntry(revision, allValue)
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // Collect block-level entries by scanning document
138
+ const contexts = ['section', 'floating_title', 'example', 'listing', 'table', 'image']
139
+
140
+ function scanBlocks(blocks) {
141
+ if (!blocks) return
142
+
143
+ blocks.forEach(block => {
144
+ const blockId = block.getId()
145
+ const blockContext = block.getContext()
146
+
147
+ if (contexts.includes(blockContext) && blockId) {
148
+ // Try both getAttributes() and direct attribute access
149
+ const attrs = block.getAttributes()
150
+
151
+ // Also check if attributes are exposed differently
152
+ // Some Asciidoctor.js versions expose them via getAttribute(name) only
153
+ Object.keys(attrs).forEach(key => {
154
+ const value = attrs[key]
155
+
156
+ if (key.startsWith(prefix) && !key.includes('-all') && !key.endsWith('-cover')) {
157
+ const revision = key.substring(prefix.length)
158
+
159
+ catalog.addEntry(revision, blockId, String(value), {
160
+ title: block.getTitle(),
161
+ context: blockContext
162
+ })
163
+ }
164
+ })
165
+ }
166
+
167
+ // Recurse into nested blocks
168
+ if (block.getBlocks) {
169
+ scanBlocks(block.getBlocks())
170
+ }
171
+ })
172
+ }
173
+
174
+ scanBlocks(doc.getBlocks())
175
+
176
+ return catalog
177
+ }
178
+
179
+ function generateInitialReleaseHtml(doc) {
180
+ const discrete = doc.hasAttribute('rhrev-discrete')
181
+ const html = []
182
+
183
+ // Wrapper
184
+ if (discrete) {
185
+ html.push('<div class="rhrev-section">')
186
+ } else {
187
+ html.push('<section id="revision-history">')
188
+ }
189
+
190
+ // Title
191
+ const customTitle = doc.getAttribute('rhrev-customization-title')
192
+ if (customTitle !== undefined && customTitle === '') {
193
+ // Empty title, suppress
194
+ } else if (customTitle) {
195
+ const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
196
+ const resolvedTitle = String(customTitle).replace('{version-label}', versionLabel)
197
+ html.push(`<h2>${resolvedTitle}</h2>`)
198
+ } else {
199
+ const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
200
+ html.push(`<h2>${versionLabel} History</h2>`)
201
+ }
202
+
203
+ // Simple table
204
+ const initialText = doc.getAttribute('rhrev-customization-initial-release-text') ||
205
+ doc.getAttribute('rhrev-localization-initial-release') ||
206
+ 'Initial release'
207
+
208
+ const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
209
+ const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
210
+ const majorChangesText = doc.getAttribute('rhrev-localization-major-changes') || 'Major changes since'
211
+ const versionLabel = doc.getAttribute('version-label') || 'Revision'
212
+ const revnumber = doc.getAttribute('revnumber') || '1.0'
213
+
214
+ // Build table with frame/grid classes
215
+ const frame = doc.getAttribute('rhrev-table-frame') || 'all'
216
+ const grid = doc.getAttribute('rhrev-table-grid') || 'all'
217
+ html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
218
+
219
+ // Caption as title div
220
+ if (!doc.hasAttribute('rhrev-disable-caption')) {
221
+ const caption = doc.getAttribute('rhrev-table-caption')
222
+ if (caption) {
223
+ html.push(`<div class="title">${caption}</div>`)
224
+ }
225
+ }
226
+
227
+ html.push('<colgroup>')
228
+ columnWidths.forEach(w => {
229
+ html.push(`<col style="width: ${w}%;">`)
230
+ })
231
+ html.push('</colgroup>')
232
+ html.push('<tbody>')
233
+ html.push('<tr class="rhrev-header">')
234
+ html.push(`<th class="tableblock halign-left valign-top">${sectionLabel}</th>`)
235
+ html.push(`<th class="tableblock halign-left valign-top"><strong>${majorChangesText} ${versionLabel} ${revnumber}</strong></th>`)
236
+ html.push('</tr>')
237
+ html.push('<tr>')
238
+ html.push('<td>&nbsp;</td>')
239
+ html.push(`<td>${initialText}</td>`)
240
+ html.push('</tr>')
241
+ html.push('</tbody>')
242
+ html.push('</table>')
243
+
244
+ // Close wrapper
245
+ html.push(discrete ? '</div>' : '</section>')
246
+
247
+ return html.join('\n')
248
+ }
249
+
250
+ function generateHtmlOutput(doc, catalog, currentPage = '') {
251
+ const discrete = doc.hasAttribute('rhrev-discrete')
252
+ const html = []
253
+
254
+ // Wrapper
255
+ if (discrete) {
256
+ html.push('<div class="rhrev-section">')
257
+ } else {
258
+ html.push('<section id="revision-history">')
259
+ }
260
+
261
+ // Title
262
+ const customTitle = doc.getAttribute('rhrev-customization-title')
263
+ if (customTitle !== undefined && customTitle === '') {
264
+ // Empty title, suppress
265
+ } else if (customTitle) {
266
+ const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
267
+ const resolvedTitle = String(customTitle).replace('{version-label}', versionLabel)
268
+ html.push(`<h2>${resolvedTitle}</h2>`)
269
+ } else {
270
+ const versionLabel = String(doc.getAttribute('version-label') || 'Revision')
271
+ html.push(`<h2>${versionLabel} History</h2>`)
272
+ }
273
+
274
+ // Table
275
+ html.push(generateHtmlTable(doc, catalog, currentPage))
276
+
277
+ // Close wrapper
278
+ html.push(discrete ? '</div>' : '</section>')
279
+
280
+ return html.join('\n')
281
+ }
282
+
283
+ function generateHtmlTable(doc, catalog, currentPage = '') {
284
+ const columnWidths = String(doc.getAttribute('rhrev-table-column-width') || '30,70').split(',').map(w => w.trim())
285
+ const html = []
286
+
287
+ // Build table with frame/grid classes
288
+ const frame = doc.getAttribute('rhrev-table-frame') || 'all'
289
+ const grid = doc.getAttribute('rhrev-table-grid') || 'all'
290
+ html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
291
+
292
+ // Caption as title div
293
+ if (!doc.hasAttribute('rhrev-disable-caption')) {
294
+ const caption = doc.getAttribute('rhrev-table-caption')
295
+ if (caption) {
296
+ html.push(`<div class="title">${caption}</div>`)
297
+ }
298
+ }
299
+
300
+ html.push('<colgroup>')
301
+ columnWidths.forEach(w => {
302
+ html.push(`<col style="width: ${w}%;">`)
303
+ })
304
+ html.push('</colgroup>')
305
+ html.push('<tbody>')
306
+
307
+ // Custom first row
308
+ const firstRow = doc.getAttribute('rhrev-customization-first-row')
309
+ const firstRowLeft = doc.getAttribute('rhrev-customization-first-row-left')
310
+ const firstRowRight = doc.getAttribute('rhrev-customization-first-row-right')
311
+
312
+ if (firstRow) {
313
+ html.push('<tr class="rhrev-custom-first-row">')
314
+ html.push(`<td colspan="2">${firstRow}</td>`)
315
+ html.push('</tr>')
316
+ } else if (firstRowLeft || firstRowRight) {
317
+ html.push('<tr class="rhrev-custom-first-row">')
318
+ html.push(`<td>${firstRowLeft || '&nbsp;'}</td>`)
319
+ html.push(`<td>${firstRowRight || '&nbsp;'}</td>`)
320
+ html.push('</tr>')
321
+ }
322
+
323
+ const sectionLabel = doc.getAttribute('rhrev-localization-location') || 'Section'
324
+ const majorChangesText = doc.getAttribute('rhrev-localization-major-changes') || 'Major changes since'
325
+ const allText = doc.getAttribute('rhrev-localization-all') || 'All'
326
+
327
+ catalog.sortedRevisions().forEach(revision => {
328
+ const prevrevAttr = `${revision}-prevrev`
329
+ const prevrevdateAttr = `${revision}-prevrevdate`
330
+ const revisionLabel = String(doc.getAttribute('version-label') || 'Revision')
331
+ const prevTitle = String(doc.getAttribute(prevrevAttr) || revision.replace('-', '.'))
332
+ const prevDate = String(doc.getAttribute(prevrevdateAttr) || '')
333
+ const prevRevText = formatPrevRev(prevTitle, prevDate, doc)
334
+
335
+ // Build header text
336
+ let headerText
337
+ if (prevTitle.trim().toLowerCase().startsWith(revisionLabel.toLowerCase())) {
338
+ headerText = `${majorChangesText} ${prevRevText}`
339
+ } else {
340
+ headerText = `${majorChangesText} ${revisionLabel} ${prevRevText}`
341
+ }
342
+
343
+ // Header row for this revision
344
+ html.push('<tr class="rhrev-header">')
345
+ html.push(`<th class="tableblock halign-left valign-top">${sectionLabel}</th>`)
346
+ html.push(`<th class="tableblock halign-left valign-top"><strong>${headerText}</strong></th>`)
347
+ html.push('</tr>')
348
+
349
+ // All entry
350
+ const allChange = catalog.allEntries[revision]
351
+ if (allChange) {
352
+ html.push('<tr>')
353
+ html.push(`<td>${allText}</td>`)
354
+ html.push(`<td>${formatChangeAsHtmlList(allChange)}</td>`)
355
+ html.push('</tr>')
356
+ }
357
+
358
+ // Block entries
359
+ const entries = catalog.entries[revision] || []
360
+ entries.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
361
+
362
+ entries.forEach(entry => {
363
+ const anchor = entry.anchor
364
+ const title = entry.title || anchor.replace(/[_-]/g, ' ').split(' ').map(w =>
365
+ w.charAt(0).toUpperCase() + w.slice(1)
366
+ ).join(' ')
367
+
368
+ html.push('<tr>')
369
+
370
+ // Build link - if entry is from a different page, include page path
371
+ let linkHref = `#${anchor}`
372
+ if (entry.page && entry.page !== currentPage) {
373
+ // Cross-page link: convert "modules/ROOT/pages/chapter-1.adoc" to "chapter-1.html#anchor"
374
+ const pagePath = entry.page.replace('modules/ROOT/pages/', '').replace('.adoc', '.html')
375
+ linkHref = `${pagePath}#${anchor}`
376
+ }
377
+
378
+ html.push(`<td><a href="${linkHref}">${title}</a></td>`)
379
+ html.push(`<td>${formatChangeAsHtmlList(entry.change)}</td>`)
380
+ html.push('</tr>')
381
+ })
382
+ })
383
+
384
+ html.push('</tbody>')
385
+ html.push('</table>')
386
+
387
+ return html.join('\n')
388
+ }
389
+
390
+ // Main registration function
391
+ module.exports.register = function (registry, context = {}) {
392
+ // Register block macro for rhrev::[]
393
+ registry.blockMacro('rhrev', function () {
394
+ const self = this
395
+
396
+ self.process(function (parent, target, attrs) {
397
+ const doc = parent.getDocument()
398
+
399
+ // Check if manual mode
400
+ if (doc.getAttribute('rhrev') === 'manual') {
401
+ return self.createBlock(parent, 'paragraph', '')
402
+ }
403
+
404
+ // Check initial release handling
405
+ const revnumber = doc.getAttribute('revnumber') || '1.0'
406
+ const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
407
+
408
+ if (isInitialRelease(revnumber)) {
409
+ if (initialHandling === 'skip') {
410
+ return self.createBlock(parent, 'paragraph', '')
411
+ }
412
+
413
+ if (initialHandling === 'initial-release') {
414
+ const html = generateInitialReleaseHtml(doc)
415
+ return self.createBlock(parent, 'pass', html)
416
+ }
417
+ }
418
+
419
+ // Try to use global catalog from Antora pipeline extension
420
+ // Otherwise fall back to collecting from current document only
421
+ let catalog = global.antoraRhrevCatalog
422
+
423
+ if (!catalog || !catalog.entries) {
424
+ // Fallback: collect from current document only (standalone mode)
425
+ catalog = collectAllEntries(doc)
426
+ }
427
+
428
+ if (catalog.sortedRevisions().length === 0) {
429
+ return self.createBlock(parent, 'paragraph', '')
430
+ }
431
+
432
+ // Get current page path for cross-page link resolution
433
+ const currentPage = doc.getAttribute('page-component-src-relative') ||
434
+ doc.getAttribute('docfile') || ''
435
+
436
+ const html = generateHtmlOutput(doc, catalog, currentPage)
437
+ return self.createBlock(parent, 'pass', html)
438
+ })
439
+ })
440
+
441
+ // Register treeprocessor for auto-injection
442
+ registry.treeProcessor(function () {
443
+ const self = this
444
+
445
+ self.process(function (doc) {
446
+ if (!doc.hasAttribute('rhrev')) {
447
+ return doc
448
+ }
449
+
450
+ const rhrevValue = doc.getAttribute('rhrev')
451
+ if (rhrevValue === 'macro' || rhrevValue === 'manual') {
452
+ return doc
453
+ }
454
+
455
+ // Auto-inject revision history at document start
456
+ const blocks = doc.getBlocks()
457
+
458
+ // Create rhrev block
459
+ const rhrevBlock = self.createBlock(doc, 'pass', '')
460
+
461
+ // Check initial release handling
462
+ const revnumber = doc.getAttribute('revnumber') || '1.0'
463
+ const initialHandling = doc.getAttribute('rhrev-initial-handling') || 'initial-release'
464
+
465
+ let html
466
+ if (isInitialRelease(revnumber)) {
467
+ if (initialHandling === 'skip') {
468
+ return doc
469
+ }
470
+
471
+ if (initialHandling === 'initial-release') {
472
+ html = generateInitialReleaseHtml(doc)
473
+ } else {
474
+ const catalog = collectAllEntries(doc)
475
+ html = generateHtmlOutput(doc, catalog)
476
+ }
477
+ } else {
478
+ const catalog = collectAllEntries(doc)
479
+
480
+ if (catalog.sortedRevisions().length === 0) {
481
+ return doc
482
+ }
483
+
484
+ html = generateHtmlOutput(doc, catalog)
485
+ }
486
+
487
+ rhrevBlock.setContent(html)
488
+
489
+ // Insert at beginning
490
+ blocks.unshift(rhrevBlock)
491
+
492
+ return doc
493
+ })
494
+ })
495
+ }
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Antora extension to rewrite pagerhref: macros in assembled documents to provide page numbers for asciidoctor-pdf
5
+ *
6
+ * Converts pagerhref targets from xref format to Antora ID format:
7
+ * pagerhref:chapter-1.adoc[] -> pagerhref:chapter-1[]
8
+ * pagerhref:chapter-1.adoc#section-1-1[] -> pagerhref:chapter-1----section-1-1[]
9
+ *
10
+ * This matches how Antora handles xrefs during assembly.
11
+ *
12
+ * Usage in antora-playbook.yml:
13
+ * antora:
14
+ * extensions:
15
+ * - require: ./{the-path-to-the-file}/antora-rhrev-assembler-pagerhref.js
16
+ * quiet: true # Set to false for debug output
17
+ */
18
+
19
+ const PAGERHREF_RX = /pagerhref:([^\[]+)\[([^\]]*)\]/g
20
+
21
+ module.exports.register = function ({ config: extensionConfig = {} }) {
22
+ const logger = this.getLogger('pagerhref-extension')
23
+
24
+ // Config from playbook extension options (quiet: true is default)
25
+ const quiet = extensionConfig.quiet !== undefined ? extensionConfig.quiet : true
26
+
27
+ if (!quiet) {
28
+ logger.debug('pagerhref-extension: REGISTERED')
29
+ }
30
+
31
+ // Hook into contentClassified - after content is parsed but before conversion
32
+ this.on('contentClassified', ({ contentCatalog }) => {
33
+ if (!contentCatalog) return
34
+
35
+ // Process all page files
36
+ const pageFiles = contentCatalog.findBy({ family: 'page' }) || []
37
+
38
+ pageFiles.forEach(file => {
39
+ if (!file.contents) return
40
+
41
+ const contents = file.contents.toString()
42
+ if (!contents.includes('pagerhref:')) return
43
+
44
+ let modified = false
45
+ const newContents = contents.replace(PAGERHREF_RX, (match, target, attrs) => {
46
+ // Convert .adoc# to ---- (e.g., chapter-1.adoc#section -> chapter-1----section)
47
+ // Using ---- instead of ::: to avoid confusing Asciidoctor's HTML parser
48
+ if (target.includes('.adoc#')) {
49
+ const newTarget = target.replace('.adoc#', '----')
50
+ if (!quiet) {
51
+ logger.debug(`Rewriting pagerhref: ${target} -> ${newTarget}`)
52
+ }
53
+ modified = true
54
+ return `pagerhref:${newTarget}[${attrs}]`
55
+ }
56
+
57
+ // Strip .adoc suffix (e.g., chapter-1.adoc -> chapter-1)
58
+ if (target.endsWith('.adoc')) {
59
+ const newTarget = target.slice(0, -5)
60
+ if (!quiet) {
61
+ logger.debug(`Rewriting pagerhref: ${target} -> ${newTarget}`)
62
+ }
63
+ modified = true
64
+ return `pagerhref:${newTarget}[${attrs}]`
65
+ }
66
+
67
+ // Already in correct format
68
+ return match
69
+ })
70
+
71
+ if (modified) {
72
+ file.contents = Buffer.from(newContents)
73
+ if (!quiet) {
74
+ logger.debug(`Updated pagerhref macros in ${file.src?.relative}`)
75
+ }
76
+ }
77
+ })
78
+ })
79
+ }
@@ -0,0 +1,364 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Antora pipeline extension for revision history (rhrev).
5
+ *
6
+ * Hooks into contentClassified to scan ALL pages in the content catalog
7
+ * and build a global revision history that spans across pages.
8
+ * Then injects the revision history into pages that have rhrev::[] macro.
9
+ */
10
+
11
+ const RHREV_MACRO_RX = /^rhrev::(\[.*?\])?$/gm
12
+
13
+ module.exports.register = function ({ config: extensionConfig = {} }) {
14
+ const logger = this.getLogger('rhrev-extension')
15
+ const quiet = extensionConfig.quiet !== undefined ? extensionConfig.quiet : true
16
+
17
+ if (!quiet) {
18
+ logger.debug('rhrev-extension: REGISTERED')
19
+ }
20
+
21
+ // Hook into contentClassified - we can still modify raw content
22
+ this.on('contentClassified', ({ contentCatalog, siteAsciiDocConfig }) => {
23
+ if (!contentCatalog) return
24
+
25
+ if (!quiet) {
26
+ logger.debug('rhrev-extension: contentClassified event fired')
27
+ }
28
+
29
+ // Find all pages in the catalog
30
+ const pageFiles = contentCatalog.findBy({ family: 'page' }) || []
31
+
32
+ if (!quiet) {
33
+ logger.debug(`rhrev-extension: Found ${pageFiles.length} page files`)
34
+ }
35
+
36
+ // Build global catalog by scanning all pages
37
+ const globalCatalog = buildGlobalCatalog(pageFiles, logger, quiet)
38
+
39
+ if (!quiet) {
40
+ logger.debug(`rhrev-extension: Global catalog has ${Object.keys(globalCatalog.entries).length} revisions`)
41
+ logger.debug(`rhrev-extension: Total block entries: ${Object.values(globalCatalog.entries).reduce((sum, arr) => sum + arr.length, 0)}`)
42
+ }
43
+
44
+ // Store the catalog globally so the Asciidoctor extension can access it
45
+ // This allows proper backend-specific rendering (HTML vs PDF)
46
+ global.antoraRhrevCatalog = globalCatalog
47
+
48
+ if (!quiet) {
49
+ logger.debug('rhrev-extension: Global catalog stored for Asciidoctor extension')
50
+ }
51
+ })
52
+ }
53
+
54
+ function buildGlobalCatalog(pageFiles, logger, quiet) {
55
+ const catalog = {
56
+ entries: {}, // { '1-1': [...], '1-2': [...] }
57
+ allEntries: {}, // { '1-1': 'text', '1-2': 'text' }
58
+ sequence: 0,
59
+
60
+ // Add sortedRevisions method for compatibility with Asciidoctor extension
61
+ sortedRevisions() {
62
+ const allRevisions = [
63
+ ...Object.keys(this.entries),
64
+ ...Object.keys(this.allEntries)
65
+ ]
66
+ const uniqueRevisions = [...new Set(allRevisions)]
67
+
68
+ return uniqueRevisions.sort((a, b) => {
69
+ const [aMajor, aMinor] = a.split('-').map(Number)
70
+ const [bMajor, bMinor] = b.split('-').map(Number)
71
+
72
+ if (aMajor !== bMajor) return bMajor - aMajor
73
+ return bMinor - aMinor
74
+ })
75
+ }
76
+ }
77
+
78
+ // Get component attributes from first file's src.origin (antora.yml)
79
+ const firstFile = pageFiles[0]
80
+ if (!firstFile) return catalog
81
+
82
+ // Component attributes are in src.origin.descriptor.asciidoc.attributes
83
+ const componentAttrs = firstFile.src?.origin?.descriptor?.asciidoc?.attributes || {}
84
+ const prefix = componentAttrs.revhistoryprefix || 'rhrev'
85
+
86
+ if (!quiet) {
87
+ logger.debug(`rhrev-extension: Using prefix '${prefix}'`)
88
+ }
89
+
90
+ // Collect document-level entries (-all)
91
+ for (let major = 0; major <= 9; major++) {
92
+ for (let minor = 0; minor <= 9; minor++) {
93
+ const revision = `${major}-${minor}`
94
+ const prevrevAttr = `${revision}-prevrev`
95
+
96
+ if (componentAttrs[prevrevAttr]) {
97
+ const allAttrName = `${prefix}${revision}-all`
98
+ const allValue = componentAttrs[allAttrName]
99
+
100
+ if (allValue) {
101
+ catalog.allEntries[revision] = allValue
102
+
103
+ if (!quiet) {
104
+ logger.debug(`rhrev-extension: Found -all entry for ${revision}`)
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // Collect block-level entries from ALL pages
112
+ pageFiles.forEach(file => {
113
+ if (!file.contents) return
114
+
115
+ const content = file.contents.toString()
116
+
117
+ if (!quiet && content.includes(prefix)) {
118
+ logger.debug(`rhrev-extension: Scanning ${file.src?.relative} for block attributes`)
119
+ }
120
+
121
+ // Match block attribute lines: [rhrev1-1="text"]
122
+ const blockAttrRx = new RegExp(`\\[${prefix}([0-9]+-[0-9]+)="([^"]+)"\\]`, 'g')
123
+ let match
124
+
125
+ while ((match = blockAttrRx.exec(content)) !== null) {
126
+ const revision = match[1]
127
+ const changeText = match[2]
128
+
129
+ // Look for anchor/title IMMEDIATELY after the attribute (within next 200 chars)
130
+ const matchEnd = match.index + match[0].length
131
+ const nextChunk = content.substring(matchEnd, matchEnd + 200)
132
+
133
+ // Look for [#anchor], .Title, or === Section in that order
134
+ const anchorMatch = nextChunk.match(/^\s*\[#([^\]]+)\]/)
135
+ const titleMatch = nextChunk.match(/^\s*\.([^\n]+)/)
136
+ const sectionMatch = nextChunk.match(/^\s*(==+)\s+([^\n]+)/)
137
+
138
+ let anchor = null
139
+ let title = null
140
+
141
+ if (anchorMatch) {
142
+ anchor = anchorMatch[1]
143
+ // Still need to find title after anchor
144
+ const afterAnchor = nextChunk.substring(anchorMatch[0].length)
145
+ const titleAfterAnchor = afterAnchor.match(/^\s*\.([^\n]+)/)
146
+ const sectionAfterAnchor = afterAnchor.match(/^\s*(==+)\s+([^\n]+)/)
147
+ title = titleAfterAnchor ? titleAfterAnchor[1] : (sectionAfterAnchor ? sectionAfterAnchor[2] : anchor)
148
+ } else if (sectionMatch) {
149
+ title = sectionMatch[2]
150
+ // Generate anchor from section title (Asciidoctor's default behavior)
151
+ anchor = '_' + title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
152
+ } else if (titleMatch) {
153
+ title = titleMatch[1]
154
+ // No explicit anchor, will use title as fallback
155
+ }
156
+
157
+ if (!quiet) {
158
+ logger.debug(`rhrev-extension: Found ${revision} -> anchor=${anchor}, title=${title} in ${file.src?.relative}`)
159
+ }
160
+
161
+ if (anchor) {
162
+ if (!catalog.entries[revision]) {
163
+ catalog.entries[revision] = []
164
+ }
165
+
166
+ catalog.entries[revision].push({
167
+ anchor,
168
+ change: changeText,
169
+ title,
170
+ sequence: catalog.sequence++,
171
+ page: file.src?.relative || file.path
172
+ })
173
+
174
+ if (!quiet) {
175
+ logger.debug(`rhrev-extension: Found block entry ${revision} -> ${anchor} in ${file.src?.relative}`)
176
+ }
177
+ } else if (!quiet) {
178
+ logger.debug(`rhrev-extension: Could not determine anchor for ${revision} in ${file.src?.relative}`)
179
+ }
180
+ }
181
+ })
182
+
183
+ return catalog
184
+ }
185
+
186
+ function generateRevisionHistoryHtml(file, catalog, logger, quiet) {
187
+ // Get attributes from component descriptor (antora.yml)
188
+ const attrs = file.src?.origin?.descriptor?.asciidoc?.attributes || {}
189
+
190
+ const columnWidths = String(attrs['rhrev-table-column-width'] || '30,70').split(',').map(w => w.trim())
191
+ const frame = attrs['rhrev-table-frame'] || 'all'
192
+ const grid = attrs['rhrev-table-grid'] || 'all'
193
+ const sectionLabel = attrs['rhrev-localization-location'] || 'Section'
194
+ const majorChangesText = attrs['rhrev-localization-major-changes'] || 'Major changes since'
195
+ const allText = attrs['rhrev-localization-all'] || 'All'
196
+ const versionLabel = attrs['version-label'] || 'Revision'
197
+ const discrete = attrs['rhrev-discrete'] !== undefined
198
+
199
+ const html = []
200
+
201
+ // Wrapper
202
+ if (discrete) {
203
+ html.push('<div class="rhrev-section">')
204
+ } else {
205
+ html.push('<section id="revision-history">')
206
+ }
207
+
208
+ // Title
209
+ const customTitle = attrs['rhrev-customization-title']
210
+ if (customTitle !== undefined && customTitle === '') {
211
+ // Empty title, suppress
212
+ } else if (customTitle) {
213
+ const resolvedTitle = String(customTitle).replace('{version-label}', versionLabel)
214
+ html.push(`<h2>${resolvedTitle}</h2>`)
215
+ } else {
216
+ html.push(`<h2>${versionLabel} History</h2>`)
217
+ }
218
+
219
+ // Table
220
+ html.push(`<table class="tableblock frame-${frame} grid-${grid} stretch">`)
221
+
222
+ // Caption
223
+ if (!attrs['rhrev-disable-caption']) {
224
+ const caption = attrs['rhrev-table-caption']
225
+ if (caption) {
226
+ html.push(`<div class="title">${caption}</div>`)
227
+ }
228
+ }
229
+
230
+ html.push('<colgroup>')
231
+ columnWidths.forEach(w => {
232
+ html.push(`<col style="width: ${w}%;">`)
233
+ })
234
+ html.push('</colgroup>')
235
+ html.push('<tbody>')
236
+
237
+ // Custom first row
238
+ const firstRow = attrs['rhrev-customization-first-row']
239
+ const firstRowLeft = attrs['rhrev-customization-first-row-left']
240
+ const firstRowRight = attrs['rhrev-customization-first-row-right']
241
+
242
+ if (firstRow) {
243
+ html.push('<tr class="rhrev-custom-first-row">')
244
+ html.push(`<td colspan="2">${firstRow}</td>`)
245
+ html.push('</tr>')
246
+ } else if (firstRowLeft || firstRowRight) {
247
+ html.push('<tr class="rhrev-custom-first-row">')
248
+ html.push(`<td>${firstRowLeft || '&nbsp;'}</td>`)
249
+ html.push(`<td>${firstRowRight || '&nbsp;'}</td>`)
250
+ html.push('</tr>')
251
+ }
252
+
253
+ // Get sorted revisions
254
+ const allRevisions = [
255
+ ...Object.keys(catalog.entries),
256
+ ...Object.keys(catalog.allEntries)
257
+ ]
258
+ const uniqueRevisions = [...new Set(allRevisions)]
259
+
260
+ const sortedRevisions = uniqueRevisions.sort((a, b) => {
261
+ const [aMajor, aMinor] = a.split('-').map(Number)
262
+ const [bMajor, bMinor] = b.split('-').map(Number)
263
+
264
+ if (aMajor !== bMajor) return bMajor - aMajor
265
+ return bMinor - aMinor
266
+ })
267
+
268
+ // Generate revision rows
269
+ sortedRevisions.forEach(revision => {
270
+ const prevrevAttr = `${revision}-prevrev`
271
+ const prevTitle = String(attrs[prevrevAttr] || revision.replace('-', '.'))
272
+ const prevrevdateAttr = `${revision}-prevrevdate`
273
+ const prevDate = attrs[prevrevdateAttr] || ''
274
+
275
+ // Build header text
276
+ let headerText
277
+ if (prevTitle.trim().toLowerCase().startsWith(versionLabel.toLowerCase())) {
278
+ headerText = `${majorChangesText} ${prevTitle}`
279
+ } else {
280
+ headerText = `${majorChangesText} ${versionLabel} ${prevTitle}`
281
+ }
282
+
283
+ // Header row
284
+ html.push('<tr class="rhrev-header">')
285
+ html.push(`<th class="tableblock halign-left valign-top">${sectionLabel}</th>`)
286
+ html.push(`<th class="tableblock halign-left valign-top"><strong>${headerText}</strong></th>`)
287
+ html.push('</tr>')
288
+
289
+ // All entry
290
+ const allChange = catalog.allEntries[revision]
291
+ if (allChange) {
292
+ html.push('<tr>')
293
+ html.push(`<td>${allText}</td>`)
294
+ html.push(`<td>${formatChangeAsHtmlList(allChange)}</td>`)
295
+ html.push('</tr>')
296
+ }
297
+
298
+ // Block entries
299
+ const entries = catalog.entries[revision] || []
300
+ entries.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
301
+
302
+ entries.forEach(entry => {
303
+ html.push('<tr>')
304
+
305
+ // Build link - if entry is from a different page, include page path
306
+ let linkHref = `#${entry.anchor}`
307
+ if (entry.page && entry.page !== file.src?.relative) {
308
+ // Cross-page link: convert "modules/ROOT/pages/chapter-1.adoc" to "chapter-1.html#anchor"
309
+ const pagePath = entry.page.replace('modules/ROOT/pages/', '').replace('.adoc', '.html')
310
+ linkHref = `${pagePath}#${entry.anchor}`
311
+ }
312
+
313
+ html.push(`<td><a href="${linkHref}">${entry.title || entry.anchor}</a></td>`)
314
+ html.push(`<td>${formatChangeAsHtmlList(entry.change)}</td>`)
315
+ html.push('</tr>')
316
+ })
317
+ })
318
+
319
+ html.push('</tbody>')
320
+ html.push('</table>')
321
+
322
+ // Close wrapper
323
+ html.push(discrete ? '</div>' : '</section>')
324
+
325
+ if (!quiet) {
326
+ logger.debug(`rhrev-extension: Generated HTML with ${sortedRevisions.length} revisions`)
327
+ }
328
+
329
+ return html.join('\n')
330
+ }
331
+
332
+ function formatChangeAsHtmlList(text) {
333
+ if (!text) return ''
334
+
335
+ // Handle line continuations
336
+ const processed = String(text).replace(/\\\s*\n\s*/g, ' ')
337
+
338
+ // Split by asterisks
339
+ const tokens = processed.split(/\s(?=\*+\s)/)
340
+ const items = []
341
+
342
+ if (tokens.length === 1 && !processed.includes('*')) {
343
+ items.push({ level: 1, content: processed })
344
+ } else {
345
+ tokens.forEach(token => {
346
+ if (!token.trim()) return
347
+
348
+ const match = token.trim().match(/^(\*+)\s+(.+)/)
349
+ if (match) {
350
+ items.push({ level: match[1].length, content: match[2] })
351
+ } else {
352
+ items.push({ level: 1, content: token.trim() })
353
+ }
354
+ })
355
+ }
356
+
357
+ const html = ['<ul>']
358
+ items.forEach(item => {
359
+ html.push(`<li>${item.content}</li>`)
360
+ })
361
+ html.push('</ul>')
362
+
363
+ return html.join('\n')
364
+ }
@@ -0,0 +1,12 @@
1
+ // This simply adds a blank paragraph as a temporary fix to avoid an Antora/AsciiDoctor debug message from displaying.
2
+
3
+ module.exports.register = function (registry) {
4
+ // rhrev
5
+ registry.blockMacro('rhrev', function () {
6
+ const self = this;
7
+ self.process(function (parent, target, attrs) {
8
+ // Create a simple paragraph placeholder
9
+ return self.createBlock(parent, 'paragraph', '');
10
+ });
11
+ });
12
+ };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "asciidoctor-rhrev",
3
+ "version": "1.0.1",
4
+ "description": "Revision history block macro extension for Asciidoctor.js and Antora (HTML5 backend)",
5
+ "main": "antora-extension/antora-rhrev-asciidoc.js",
6
+ "scripts": {
7
+ "build": "antora antora-playbook.yml"
8
+ },
9
+ "keywords": [
10
+ "asciidoctor",
11
+ "antora",
12
+ "revision-history",
13
+ "documentation",
14
+ "versioning"
15
+ ],
16
+ "author": "白一百 baiyibai",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://gitlab.com/baiyibai-asciidoc/rhrev-asciidoctor-automatic-revision-history.git"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "files": [
26
+ "antora-extension/antora-rhrev-asciidoc.js",
27
+ "antora-extension/antora-rhrev-assembler-pagerhref.js",
28
+ "antora-extension/antora-rhrev-html.js",
29
+ "antora-extension/antora-rhrev-placeholder.js",
30
+ "README.adoc",
31
+ "LICENSE"
32
+ ]
33
+ }