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 +25 -0
- package/README.adoc +138 -0
- package/antora-extension/antora-rhrev-asciidoc.js +495 -0
- package/antora-extension/antora-rhrev-assembler-pagerhref.js +79 -0
- package/antora-extension/antora-rhrev-html.js +364 -0
- package/antora-extension/antora-rhrev-placeholder.js +12 -0
- package/package.json +33 -0
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> </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 || ' '}</td>`)
|
|
319
|
+
html.push(`<td>${firstRowRight || ' '}</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 || ' '}</td>`)
|
|
249
|
+
html.push(`<td>${firstRowRight || ' '}</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
|
+
}
|