@neo4j-antora/pdf-generator 0.1.0-rc.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/README.adoc ADDED
@@ -0,0 +1,131 @@
1
+ = PDF generation
2
+
3
+ Generates a PDF export from any Antora docset, via `@antora/assembler` +
4
+ `@antora/pdf-extension` and `asciidoctor-web-pdf` (a Chrome/Puppeteer + CSS
5
+ renderer) instead of the legacy Gradle + AsciidoctorJ mono-merge pipeline.
6
+ See `reusable-pdf-build.yml` in `.github/workflows/` for the CI entry point
7
+ that uses this package.
8
+
9
+ Published as a normal npm dependency - a docset adds it as a devDependency
10
+ and gets a real, versioned copy in its own `node_modules`, the same way it
11
+ already depends on `@neo4j-antora/roles-labels` or `@neo4j-antora/tabbed-nav`.
12
+ There's nothing to check out, symlink, or hand-configure: `@antora/pdf-
13
+ extension` is this package's own dependency (not something a docset lists
14
+ itself), so `npm install @neo4j-antora/pdf-generator` is the entire setup.
15
+
16
+ == Usage
17
+
18
+ A docset needs its own small playbook (like `preview.yml`/`publish.yml`,
19
+ since content sources/attributes differ per docset - not shared here) that
20
+ registers `@antora/pdf-extension` with this package's assembler config:
21
+
22
+ [source,yaml]
23
+ ----
24
+ antora:
25
+ extensions:
26
+ - require: '@antora/pdf-extension'
27
+ config_file: './node_modules/@neo4j-antora/pdf-generator/antora-assembler-pdf.yml'
28
+ ----
29
+
30
+ and a `package.json` script to run it, e.g. `"pdf": "antora pdf.yml"`.
31
+
32
+ == Layout
33
+
34
+ - `antora-assembler-pdf.yml` - Assembler config, pointed at from a docset's
35
+ own playbook (see above).
36
+ - `scripts/convert.js` - wraps the real `asciidoctor-web-pdf` binary as the
37
+ assembler's `build.command`. Resolves the renderer binary (via
38
+ `require.resolve` against its own `asciidoctor-pdf` dependency, regardless
39
+ of how npm hoists it) and the stylesheet/extension paths (via `__dirname`)
40
+ at runtime, so nothing here is hardcoded to a particular install layout.
41
+ Also works around a real bug (`ifndef::backend-pdf[]` not evaluating
42
+ correctly under this pipeline) for docsets using that idiom - a no-op for
43
+ any docset that doesn't.
44
+ - `pdf-theme/print.css` - print stylesheet targeting Asciidoctor's standard
45
+ HTML5 backend class names (`sect1`, `admonitionblock`, `listingblock`,
46
+ `tableblock`, `label`, ...), using colour/font tokens copied from
47
+ `docs-ui`'s `src/css/vars.css` (itself resolving to raw Needle design
48
+ system tokens in `@neo4j-ndl/base`'s `tokens/css/tokens.css`) and
49
+ `docs-ui`'s `src/css/labels.css`. Not a fork of the real site CSS wholesale
50
+ - Asciidoctor's default HTML5 output doesn't have Antora's `.doc` wrapper
51
+ markup most of the site CSS's selectors are written against, so most of
52
+ it wouldn't match anything here anyway. Fonts are vendored into
53
+ `pdf-theme/assets/fonts/` rather than read from a docset's own HTML
54
+ build output, so this theme has no dependency on that build having
55
+ already run.
56
+ - `extensions/` - `roles-labels-postprocessor.js` and
57
+ `table-footnotes-postprocessor.js` port the essential parts of the Antora
58
+ extensions of the same name (see "Known limitations" below);
59
+ `macros-adapter.js` and `remote-include-adapter.js` wrap the real
60
+ `@neo4j-documentation/macros`/`remote-include` packages, fixing a
61
+ pre-4.x-Asciidoctor.js API incompatibility in each at runtime (see their
62
+ own comments) rather than patching the installed packages - a nested
63
+ dependency's own `postinstall` isn't guaranteed to run once this package
64
+ is itself installed as a docset's dependency, so `patch-package` isn't a
65
+ reliable option here the way it is for a top-level project.
66
+ - `@asciidoctor/core` 4.x (needed by `asciidoctor-pdf`) vs. Antora's own 2.x:
67
+ no longer needs the isolated sibling npm project the git-checkout-based
68
+ version of this pipeline required - both land in the same install
69
+ correctly because npm nests conflicting versions of the same dependency
70
+ automatically; this package simply declares its own real dependencies and
71
+ lets normal npm resolution do the isolation.
72
+
73
+ == Known limitations
74
+
75
+ - **No footnotes render at all, anywhere in the PDF, because of the TOC.**
76
+ Isolated by direct testing against `asciidoctor-web-pdf` outside this whole
77
+ pipeline: passing `-a toc` alone (nothing else - no tables, no our own
78
+ extensions) is enough to break native Asciidoctor `footnote:[...]`
79
+ conversion entirely - no superscript reference, no `#footnotes` div, just
80
+ the footnote text dropped in-line as if the macro had never been
81
+ processed. Since the print theme enables `toc` for every docset, this
82
+ currently affects every PDF this pipeline produces, not just docsets using
83
+ footnotes inside tables. `extensions/table-footnotes-postprocessor.js`
84
+ (see above) is correctly written and wired in, but has nothing to do while
85
+ this is broken - there's no `#footnotes` div for it to move content out of.
86
+ Not yet root-caused further (asciidoctor-web-pdf itself, or its Vivliostyle
87
+ dependency) or fixed.
88
+ - `@neo4j-antora/roles-labels` and `@neo4j-antora/table-footnotes` are Antora
89
+ extensions (hook `pagesComposed`, operate on an Antora `ContentCatalog`) -
90
+ neither can run against this pipeline at all, structurally: the assembler
91
+ hands the merged `.adoc` to a completely separate, isolated Asciidoctor
92
+ conversion with no Antora generator context. `table-footnotes` is ported
93
+ as a plain Asciidoctor `Postprocessor` instead
94
+ (`extensions/table-footnotes-postprocessor.js`) - pure HTML restructuring,
95
+ no Antora-specific data needed, so the port is a straight copy.
96
+ `roles-labels-postprocessor.js` reuses `@neo4j-antora/roles-labels`'s own
97
+ shared `lib/process-labels.js` core directly (same synonym resolution,
98
+ version/product-suffix stripping, dataset attributes as the real HTML
99
+ site - not a hand-ported subset), with one deliberate behavioural
100
+ difference: it passes `skipDiscrete: false`, because `@antora/assembler`
101
+ marks *every* merged page's heading `discrete` to flatten section nesting
102
+ across the book, which isn't the authorial signal `discrete` is on a real
103
+ Antora HTML page (see `process-labels.js`'s own comment on that option).
104
+ - Classification of `reusable-docs-build.yml`'s default `antora-extensions`
105
+ list, checked against their actual registration mechanism:
106
+ `aliases-redirects`, `antora-modify-sitemaps`, `antora-page-list` all hook
107
+ Antora-only lifecycle events (site redirects, sitemap.xml, the HTML
108
+ page-list artifact) - inherently HTML/site-only, correctly and harmlessly
109
+ absent from PDF output, nothing to do. `antora-unlisted-pages`/
110
+ `selector-labels` not individually confirmed but almost certainly the same
111
+ (nav/site-UI concerns). `xref-hash-validator` hooks `contentClassified`/
112
+ `documentsConverted` but only validates/warns - doesn't transform content,
113
+ so its absence from PDF just means xrefs aren't validated in that context,
114
+ not a rendering gap.
115
+ - **TODO, not yet decided**: `@neo4j-antora/mark-terms` *is* a plain
116
+ Asciidoctor extension (`module.exports = function (registry) {...}`, no
117
+ Antora event hook) - structurally it'd just need `--extension`
118
+ registration, the same easy fix as `macros`/`remote-include`. Not done
119
+ yet, because it's a product decision, not just a technical one: mark-terms
120
+ adds a trademark/copyright mark on a term's *first use per page* (e.g. the
121
+ first "Neo4j" on each HTML page) - a PDF has no equivalent notion of "page"
122
+ the way a docset's HTML pages do, so "first use" would need redefining
123
+ (per PDF? per chapter? just once, ever?), or the whole approach swapped for
124
+ a single boilerplate copyright/trademark statement somewhere in the PDF
125
+ (e.g. the cover or a colophon page) instead of inline marks. Needs a call
126
+ on the right behaviour before doing the (otherwise straightforward)
127
+ registration work.
128
+ - This package's `print.css` label colours are hand-resolved from
129
+ `docs-ui`'s source files, not read from them live - if the Needle tokens or
130
+ `docs-ui`'s `vars.css`/`labels.css` change, this drifts until someone
131
+ notices and re-syncs it by hand.
@@ -0,0 +1,23 @@
1
+ component_version_filter:
2
+ names: '**'
3
+ assembly:
4
+ attributes:
5
+ allow-uri-read: ''
6
+ toc: ''
7
+ toc-title: Table of Contents
8
+ toclevels: 2
9
+ build:
10
+ # build.cwd (what a relative command resolves against) defaults to the
11
+ # *playbook's* directory (docs-dir) - the same directory `npm install` is
12
+ # run from for that docset, since that's where its own package.json lives.
13
+ # node_modules is therefore always right here, at a fixed, predictable
14
+ # depth, regardless of how deeply nested docs-dir is within the docset
15
+ # repo - unlike the old git-checkout-into-.pdf-tools convention this
16
+ # package replaces, no per-consumer path rewriting is needed. Once
17
+ # convert.js itself is running, it resolves everything else (the
18
+ # renderer, the stylesheet, the extensions) from its own location or via
19
+ # require.resolve against its own dependencies instead - see this
20
+ # package's own README.
21
+ command: node ./node_modules/@neo4j-antora/pdf-generator/scripts/convert.js --trace
22
+ keep_source: true
23
+ qualify_exports: true
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@neo4j-antora/pdf-generator",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Generates a PDF export from an Antora docset, styled to match the real Neo4j docs site, via @antora/assembler + @antora/pdf-extension and asciidoctor-web-pdf",
5
+ "main": "scripts/convert.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "postinstall": "node scripts/setup-vendor.js"
9
+ },
10
+ "keywords": [
11
+ "antora",
12
+ "pdf"
13
+ ],
14
+ "author": "Neo4j",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "@antora/pdf-extension": "1.0.0",
18
+ "@neo4j-antora/roles-labels": "0.1.14-rc.1",
19
+ "@neo4j-documentation/macros": "1.0.4",
20
+ "@neo4j-documentation/remote-include": "1.0.0",
21
+ "node-html-parser": "9.0.4"
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ <svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 75"><path d="M39.23 19c-10.58 0-17.68 6.16-17.68 18.11v8.52a8 8 0 013.45-.82 7.89 7.89 0 013.46.8v-8.54c0-7.75 4.28-11.73 10.8-11.73S50 29.32 50 37.07v18.62h6.89V37.07C56.91 25.05 49.81 19 39.23 19zm21.43 18.8c0-10.87 8-18.84 19.27-18.84s19.13 8 19.13 18.84v2.53H67.9c1 6.38 5.8 9.93 12 9.93 4.64 0 7.9-1.45 10-4.56h7.6c-2.75 6.66-9.27 10.94-17.6 10.94-11.27 0-19.24-7.97-19.24-18.84zm31.15-3.62c-1.38-5.73-6.08-8.84-11.88-8.84s-10.43 3.19-11.81 8.84zm10.93 3.62c0-10.86 8-18.83 19.27-18.83s19.27 8 19.27 18.83-8 18.84-19.27 18.84-19.27-7.97-19.27-18.84zm31.59 0c0-7.24-4.93-12.46-12.32-12.46S109.7 30.56 109.7 37.8s4.92 12.46 12.3 12.46 12.33-5.21 12.33-12.46zm46.31 25.02h.8c4.42 0 6.08-2 6.08-7V20.16h6.89v35.2c0 8.84-3.48 13.4-12.32 13.4h-1.45zm-3.44-3.68h-6.89v-8.49h-17.45A8.64 8.64 0 01145 46.2a7.72 7.72 0 01.94-8.16l15.66-20.55a8.65 8.65 0 0115.6 5.13v21.92h5.17v6.11h-5.17zM151.67 41.8a1.76 1.76 0 00-.32 1 1.72 1.72 0 001.73 1.73h17.23V22.45a1.7 1.7 0 00-1.19-1.68 2.36 2.36 0 00-.63-.09 1.63 1.63 0 00-1.36.73L151.67 41.8z"/><path d="M191 5.53a5.9 5.9 0 105.89 5.9 5.9 5.9 0 00-5.89-5.9zM24.7 47a5.84 5.84 0 00-3.54 1.2l-6.48-4.43a6 6 0 00.22-1.59A5.89 5.89 0 109 48a5.81 5.81 0 003.54-1.2L19 51.26a5.89 5.89 0 000 3.19l-6.48 4.43A5.81 5.81 0 009 57.68a5.9 5.9 0 105.89 5.89 6 6 0 00-.21-1.57l6.48-4.43a5.84 5.84 0 003.54 1.2 5.9 5.9 0 000-11.77z" fill="#018bff"/></svg>
@@ -0,0 +1,422 @@
1
+ /* Print-specific stylesheet shared by every docset's PDF export (see
2
+ docs-tools/pdf/README - checked out into each repo's build by
3
+ reusable-pdf-build.yml). Reuses brand tokens read from the real site.css
4
+ (build/site/assets/css/site.css) rather than forking that file, since
5
+ Asciidoctor's HTML5 backend (used by asciidoctor-web-pdf) does not produce
6
+ Antora's `.doc` wrapper markup.
7
+
8
+ Every asset path below is relative to this file's own location, not to
9
+ whatever repo/cwd the build runs in - a browser resolves CSS url()s
10
+ against the stylesheet's own file:// location, so this stays correct
11
+ wherever docs-tools gets checked out. Font files are vendored into
12
+ assets/fonts/ here (not read from a docset's own build output) so this
13
+ theme has no dependency on that docset's HTML build having already run. */
14
+ @font-face {
15
+ font-family: 'Roboto Mono';
16
+ font-weight: 400;
17
+ src: url('assets/fonts/roboto-mono-latin-400.woff2') format('woff2');
18
+ }
19
+ @font-face {
20
+ font-family: 'Roboto Mono';
21
+ font-weight: 500;
22
+ src: url('assets/fonts/roboto-mono-latin-500.woff2') format('woff2');
23
+ }
24
+
25
+ :root {
26
+ --brand-primary: #0a6190;
27
+ --brand-primary-strong: #02507b;
28
+ --brand-primary-weak: #e7fafb;
29
+ --brand-text: #1a1b1d;
30
+ --brand-text-weak: #4d5157;
31
+ --brand-text-weaker: #5e636a;
32
+ --brand-border: #e2e3e5;
33
+ --brand-border-strong: #bbbec3;
34
+ --brand-bg-weak: #fff;
35
+ --brand-bg-strong: #f5f6f6;
36
+ --brand-danger: #a6291f;
37
+ --brand-danger-bg: #fdf1ef;
38
+ --brand-warning: #8a5a00;
39
+ --brand-warning-bg: #fdf6e8;
40
+ --font-body: 'Public Sans', 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
41
+ --font-mono: 'Roboto Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
42
+ }
43
+
44
+ @page {
45
+ size: A4;
46
+ margin: 25mm 18mm 20mm 18mm;
47
+ }
48
+ /* Verso (even/left) pages: page number on the outer (left) edge */
49
+ @page :left {
50
+ @bottom-left {
51
+ content: counter(page);
52
+ font-family: var(--font-body);
53
+ font-size: 9pt;
54
+ color: var(--brand-text-weaker);
55
+ }
56
+ @bottom-right {
57
+ content: string(doc-title) " " string(doc-version);
58
+ font-family: var(--font-body);
59
+ font-size: 9pt;
60
+ color: var(--brand-text-weaker);
61
+ }
62
+ }
63
+ /* Recto (odd/right) pages: page number on the outer (right) edge */
64
+ @page :right {
65
+ @bottom-left {
66
+ content: string(doc-title) " " string(doc-version);
67
+ font-family: var(--font-body);
68
+ font-size: 9pt;
69
+ color: var(--brand-text-weaker);
70
+ }
71
+ @bottom-right {
72
+ content: counter(page);
73
+ font-family: var(--font-body);
74
+ font-size: 9pt;
75
+ color: var(--brand-text-weaker);
76
+ }
77
+ }
78
+ @page :first {
79
+ @bottom-left {
80
+ content: normal;
81
+ }
82
+ @bottom-right {
83
+ content: normal;
84
+ }
85
+ }
86
+
87
+ #cover.title-page h1 {
88
+ string-set: doc-title content();
89
+ }
90
+ #cover .details #revnumber {
91
+ string-set: doc-version content();
92
+ }
93
+
94
+ body.book {
95
+ font-family: var(--font-body);
96
+ color: var(--brand-text);
97
+ font-size: 10.5pt;
98
+ line-height: 1.5;
99
+ }
100
+
101
+ #cover.title-page {
102
+ text-align: left;
103
+ page-break-after: always;
104
+ }
105
+ #cover.title-page::before {
106
+ content: url('assets/neo4j-logo.svg');
107
+ display: block;
108
+ width: 110pt;
109
+ }
110
+ #cover.title-page h1 {
111
+ font-size: 32pt;
112
+ font-weight: 700;
113
+ color: var(--brand-primary-strong);
114
+ margin-top: 30%;
115
+ border-bottom: 2pt solid var(--brand-primary);
116
+ padding-bottom: 12pt;
117
+ }
118
+ #cover .details {
119
+ color: var(--brand-text-weak);
120
+ font-size: 11pt;
121
+ margin-top: 8pt;
122
+ }
123
+ #cover #revdate {
124
+ display: none;
125
+ }
126
+
127
+ /* Table of contents */
128
+ #toc {
129
+ page-break-after: always;
130
+ }
131
+ #toctitle {
132
+ font-family: var(--font-body);
133
+ color: var(--brand-primary-strong);
134
+ font-size: 22pt;
135
+ font-weight: 700;
136
+ border-bottom: 2pt solid var(--brand-primary);
137
+ padding-bottom: 10pt;
138
+ margin-bottom: 18pt;
139
+ }
140
+ #toc ul {
141
+ font-family: var(--font-body);
142
+ list-style-type: none;
143
+ margin-left: 0;
144
+ padding-left: 0;
145
+ }
146
+ #toc ul.sectlevel0 {
147
+ margin-left: 0;
148
+ }
149
+ #toc ul ul.sectlevel1 {
150
+ margin-left: 14pt;
151
+ }
152
+ #toc li {
153
+ margin-top: 6pt;
154
+ line-height: 1.4;
155
+ }
156
+ #toc a {
157
+ display: flex;
158
+ color: var(--brand-text);
159
+ text-decoration: none;
160
+ font-size: 10.5pt;
161
+ }
162
+ #toc ul.sectlevel0 > li > a {
163
+ font-weight: 600;
164
+ font-style: normal;
165
+ color: var(--brand-primary-strong);
166
+ }
167
+ #toc a::after {
168
+ content: leader('.') target-counter(attr(href), page);
169
+ color: var(--brand-text-weaker);
170
+ font-weight: 400;
171
+ }
172
+
173
+ .sect1 { break-before: page; }
174
+ .sect1:first-child { break-before: avoid; }
175
+
176
+ h1, h2, h3, h4, h5, h6 {
177
+ font-family: var(--font-body);
178
+ color: var(--brand-primary-strong);
179
+ font-weight: 700;
180
+ break-after: avoid;
181
+ }
182
+ h2 { font-size: 18pt; border-bottom: 1pt solid var(--brand-border); padding-bottom: 4pt; }
183
+ h3 { font-size: 14pt; }
184
+ h4 { font-size: 12pt; }
185
+
186
+ a { color: var(--brand-primary); text-decoration: none; }
187
+
188
+ /* code */
189
+ pre, code, kbd, tt {
190
+ font-family: var(--font-mono);
191
+ }
192
+ .listingblock {
193
+ break-inside: avoid-page;
194
+ margin: 1em 0;
195
+ }
196
+ .listingblock pre {
197
+ background: var(--brand-bg-strong);
198
+ border: 0.5pt solid var(--brand-border);
199
+ border-radius: 3pt;
200
+ padding: 8pt 10pt;
201
+ font-size: 9pt;
202
+ line-height: 1.4;
203
+ white-space: pre-wrap;
204
+ word-break: break-word;
205
+ }
206
+ .listingblock .title {
207
+ font-weight: 600;
208
+ color: var(--brand-text-weak);
209
+ font-size: 9.5pt;
210
+ margin-bottom: 4pt;
211
+ }
212
+
213
+ /* tables */
214
+ table.tableblock {
215
+ border-collapse: collapse;
216
+ width: 100%;
217
+ break-inside: auto;
218
+ font-size: 9.5pt;
219
+ }
220
+ table.tableblock th, table.tableblock td {
221
+ border: 0.5pt solid var(--brand-border);
222
+ padding: 5pt 7pt;
223
+ vertical-align: top;
224
+ }
225
+ table.tableblock thead th {
226
+ background: var(--brand-bg-strong);
227
+ color: var(--brand-text);
228
+ font-weight: 600;
229
+ }
230
+ tr { break-inside: avoid; }
231
+
232
+ /* admonitions */
233
+ .admonitionblock { break-inside: avoid-page; margin: 1em 0; }
234
+ .admonitionblock table { width: 100%; border: none; }
235
+ .admonitionblock td.icon { display: none; }
236
+ .admonitionblock td.content {
237
+ border-left: 3pt solid var(--brand-border-strong);
238
+ padding: 6pt 10pt;
239
+ background: var(--brand-bg-strong);
240
+ }
241
+ .admonitionblock .title {
242
+ font-weight: 700;
243
+ text-transform: uppercase;
244
+ font-size: 8.5pt;
245
+ letter-spacing: 0.03em;
246
+ color: var(--brand-text-weak);
247
+ display: block;
248
+ margin-bottom: 3pt;
249
+ }
250
+ .admonitionblock.warning td.content,
251
+ .admonitionblock.caution td.content {
252
+ border-left-color: var(--brand-warning);
253
+ background: var(--brand-warning-bg);
254
+ }
255
+ .admonitionblock.warning .title,
256
+ .admonitionblock.caution .title { color: var(--brand-warning); }
257
+ .admonitionblock.important td.content {
258
+ border-left-color: var(--brand-danger);
259
+ background: var(--brand-danger-bg);
260
+ }
261
+ .admonitionblock.important .title { color: var(--brand-danger); }
262
+ .admonitionblock.note td.content,
263
+ .admonitionblock.tip td.content {
264
+ border-left-color: var(--brand-primary);
265
+ background: var(--brand-primary-weak);
266
+ }
267
+ .admonitionblock.note .title,
268
+ .admonitionblock.tip .title { color: var(--brand-primary-strong); }
269
+
270
+ /* lists */
271
+ .ulist, .olist, .dlist { margin: 0.5em 0; }
272
+
273
+ /* role labels (badges added by @neo4j-antora/roles-labels for label:x[] macros
274
+ and :page-role:/[role=label--x] roles).
275
+
276
+ This is `docs-ui/src/css/labels.css` (the *source* file the real site.css is
277
+ built from - github.com/neo4j-documentation/docs-ui) copied rule-for-rule,
278
+ minus the handful of selectors scoped under Antora's `.doc` wrapper (which
279
+ plain Asciidoctor HTML5 output, used for the PDF, never has - see file
280
+ header) - not hand-approximated values. Its `var(--label-*)`/`var(--deprecated-*)`/
281
+ `var(--alpha-beta-*)`/`var(--success-color)` custom properties are themselves
282
+ defined in `docs-ui/src/css/vars.css` in terms of raw design tokens from the
283
+ Needle design system (`@neo4j-ndl/base`'s `tokens/css/tokens.css`); resolved
284
+ to literal values below since neither of those files is otherwise pulled in
285
+ here. Re-resolve from those two sources if this ever drifts. */
286
+ .label {
287
+ display: inline-block;
288
+ /* Vertical padding trimmed from the copied 0.2rem: consecutive inline
289
+ labels on adjacent lines (e.g. label:x[] label:y[] each on their own
290
+ line) had no visible gap between them otherwise - line-height alone
291
+ isn't enough since the pills' padding pushed their edges flush. */
292
+ padding: 0.1rem 0.8rem;
293
+ flex-shrink: 0;
294
+ border-radius: 9999px;
295
+ background: var(--brand-primary); /* --label-default-background-color: --theme-light-color-primary-text */
296
+ color: var(--brand-primary-weak); /* --label-default-color: --palette-baltic-10 */
297
+ font-weight: 600;
298
+ font-size: calc(0.8 * 0.875rem); /* --label-title-font-size: calc(0.8 * --typography-label-font-size) */
299
+ font-style: normal;
300
+ }
301
+ .tableblock .label {
302
+ margin-top: 0.2rem;
303
+ margin-bottom: 0.5rem;
304
+ }
305
+ span.label--added,
306
+ span.label--changed,
307
+ span.label--new,
308
+ span.label--featured,
309
+ span.label--renamed,
310
+ span.label--updated,
311
+ span.label--yes {
312
+ background: #3f7824; /* --label-success-color: --theme-light-color-success-bg-strong */
313
+ color: #e7fcd7; /* --label-success-background-color: --theme-light-color-success-bg-weak */
314
+ }
315
+ span.label--admin-only,
316
+ span.label--danger,
317
+ span.label--discontinued,
318
+ span.label--na,
319
+ span.label--no,
320
+ span.label--not-on-aura,
321
+ span.label.not-available,
322
+ span.label--removed,
323
+ span.label--warning,
324
+ span.label--breaking {
325
+ background: #bb2d00; /* --label-warning-background-color: --theme-light-color-danger-bg-strong */
326
+ color: #ffe9e7; /* --label-warning-color: --theme-light-color-danger-bg-weak */
327
+ }
328
+ span.label--deprecated {
329
+ background: #ffd600; /* --deprecated-background-color: --palette-lemon-30 */
330
+ color: #251b00; /* --deprecated-color: --palette-lemon-80 */
331
+ }
332
+ span.label--alpha,
333
+ span.label--beta,
334
+ span.label--beta-until {
335
+ background: #ba7a00; /* --alpha-beta-background-color: --palette-marigold-50 */
336
+ color: #fff0d2; /* --alpha-beta-color: --palette-marigold-10 */
337
+ }
338
+ span.label--procedure,
339
+ span.label--function,
340
+ span.label--unix,
341
+ span.label--mac-os,
342
+ span.label--linux,
343
+ span.label--windows,
344
+ span.label--syntax,
345
+ span.label--functionality,
346
+ span.label--cypher,
347
+ span.label--cluster-member-core,
348
+ span.label--cluster-member-read-replica,
349
+ span.label--cluster-member-single,
350
+ span.label--core,
351
+ span.label--apoc-core,
352
+ span.label--full,
353
+ span.label--apoc-full {
354
+ background: #8fe3e8; /* --label-os-background-color: --theme-dark-color-primary-text */
355
+ color: #081e2b; /* --label-os-color: --palette-baltic-70 */
356
+ }
357
+ span.label--labs,
358
+ span.label--labs-label {
359
+ color: var(--brand-primary-weak); /* --label-labs-color: --palette-baltic-10 */
360
+ background: #5a34aa; /* --label-labs-background-color: --palette-lavender-45 */
361
+ }
362
+ span.label--graph-academy {
363
+ background: #3f7824; /* --success-color: --theme-light-color-success-bg-strong */
364
+ }
365
+
366
+ div.labels {
367
+ display: flex;
368
+ align-self: center;
369
+ gap: 0.25rem;
370
+ line-height: 1.8; /* --doc-line-height */
371
+ font-family: var(--font-body); /* --body-font-family */
372
+ }
373
+ .flex-labels-container {
374
+ display: flex;
375
+ justify-content: space-between;
376
+ align-items: flex-start;
377
+ flex-direction: row-reverse;
378
+ }
379
+ .header-label-container {
380
+ display: flex;
381
+ flex-wrap: wrap;
382
+ }
383
+ .admonitionblock div.labels { padding: 0.5rem 0 1rem; }
384
+ .exampleblock div.labels { padding: 0.5rem; }
385
+ .header-label-container > div.labels {
386
+ justify-content: space-between;
387
+ margin-left: auto;
388
+ }
389
+ .header-label-container > div.labels.wrapped {
390
+ margin-left: 0;
391
+ margin-top: 0.5rem;
392
+ }
393
+ h1 > .header-label {
394
+ margin-top: 1.2rem;
395
+ }
396
+ .header-label-container > .header-label:first-of-type {
397
+ margin-left: auto;
398
+ }
399
+ .listing-block .content-labels,
400
+ .example-block .content-labels,
401
+ .content-labels {
402
+ margin-bottom: 0.2rem;
403
+ }
404
+ .paragraph.has-label {
405
+ padding-left: 0.4rem;
406
+ border-left: 2px solid var(--brand-border-strong); /* --palette-baltic-60, not otherwise used here */
407
+ }
408
+ .paragraph.has-label:has(> .labels > .label--new) {
409
+ border-left-color: #3f7824; /* --success-color */
410
+ }
411
+ .paragraph.has-label:has(> .labels > .label--deprecated) {
412
+ border-left-color: #251b00; /* --deprecated-color */
413
+ }
414
+ h2 > .flex-label {
415
+ float: inline-end;
416
+ line-height: 1.8;
417
+ margin-left: 0.2rem;
418
+ margin-top: 0.2rem;
419
+ }
420
+
421
+ /* TOC (hidden fixed div rendered separately by asciidoctor-web-pdf's own TOC mechanism) */
422
+ .toc-entry a { color: var(--brand-text); text-decoration: none; }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // Wraps the real asciidoctor-web-pdf binary as the assembler's build.command.
5
+ // Every path below is computed from this script's own location (__dirname) -
6
+ // never hardcoded, never dependent on where a consuming project's
7
+ // node_modules happens to hoist things - see this package's own README.
8
+
9
+ const { spawn } = require('node:child_process')
10
+ const path = require('node:path')
11
+
12
+ // asciidoctor-pdf needs @asciidoctor/core 4.x while Antora needs 2.x, and it
13
+ // doesn't declare @asciidoctor/core as its own dependency (only a peerDep on
14
+ // the `asciidoctor` wrapper) - npm has no signal to ever nest an isolated
15
+ // copy for it, so a normal dependency install can silently hoist Antora's
16
+ // 2.x copy in instead, which crashes at import time. `vendor/` is a real,
17
+ // pre-installed node_modules tree (asciidoctor + asciidoctor-pdf and their
18
+ // own transitive deps only) shipped as plain files with this package - not
19
+ // npm dependencies at all from a consumer's point of view - so it's immune
20
+ // to whatever else a docset's own install needs. The postprocessor/adapter
21
+ // extensions live inside vendor/ too, so their own `require('asciidoctor')`
22
+ // resolves the same isolated copy the renderer itself uses (required for
23
+ // `Postprocessor` - see roles-labels-postprocessor.js's own comment); their
24
+ // other requires (`node-html-parser`, `@neo4j-antora/roles-labels`, ...) fall
25
+ // through vendor/'s node_modules to the consumer's normal install, since
26
+ // those have no such conflict and should stay deduped normally.
27
+ const RENDERER = path.join(__dirname, '../vendor/node_modules/asciidoctor-pdf/bin/asciidoctor-web-pdf')
28
+ const STYLESHEET = path.join(__dirname, '../pdf-theme/print.css')
29
+ const ROLES_LABELS_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/roles-labels-postprocessor.js')
30
+ const TABLE_FOOTNOTES_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/table-footnotes-postprocessor.js')
31
+ const REMOTE_INCLUDE_ADAPTER = path.join(__dirname, '../vendor/extensions/remote-include-adapter.js')
32
+ const MACROS_ADAPTER = path.join(__dirname, '../vendor/extensions/macros-adapter.js')
33
+
34
+ const PAGE_BOUNDARY_RX = /(?=^:page-docname: .*$)/m
35
+ const GLOSSARY_MARKER_RX = /^\[discrete\.glossary#.*\]$/m
36
+
37
+ // Some docsets (e.g. docs-http-api) repeat a glossary include on several
38
+ // source pages, meant to be excluded from PDF via ifndef::backend-pdf[],
39
+ // which doesn't evaluate correctly under this pipeline (see this package's
40
+ // own README - the assembler resolves that attribute against its own
41
+ // internal re-parse context, not the real, final PDF conversion). This is a
42
+ // no-op for any docset with no `[discrete.glossary#...]` marker in its
43
+ // merged source.
44
+ function dedupeGlossary (adoc) {
45
+ const [preamble, ...pages] = adoc.split(PAGE_BOUNDARY_RX)
46
+ let glossaryChunk
47
+ const strippedPages = pages.map((page) => {
48
+ const match = GLOSSARY_MARKER_RX.exec(page)
49
+ if (!match) return page
50
+ if (!glossaryChunk) {
51
+ // Drop the "discrete" style so the glossary becomes a normal chapter
52
+ // section: included in the TOC and picked up by the print theme's
53
+ // `.sect1 { break-before: page }` rule like every other chapter.
54
+ glossaryChunk = page
55
+ .slice(match.index)
56
+ .trimEnd()
57
+ .replace(/^\[discrete\.glossary/, '[glossary')
58
+ }
59
+ // Keep a blank line before whatever follows (the next page's own
60
+ // `:page-docname:` metadata block, mid-document) - an undelimited
61
+ // single-paragraph admonition like [NOTE] only ends at a blank line, so
62
+ // trimming it away merges the next page's raw attribute lines straight
63
+ // into that paragraph's text instead of stopping it.
64
+ return page.slice(0, match.index).trimEnd() + '\n\n'
65
+ })
66
+ let result = preamble + strippedPages.join('')
67
+ if (glossaryChunk) result = result.trimEnd() + '\n\n' + glossaryChunk + '\n'
68
+ return result
69
+ }
70
+
71
+ function readStdin () {
72
+ const chunks = []
73
+ return new Promise((resolve, reject) => {
74
+ process.stdin.on('data', (chunk) => chunks.push(chunk))
75
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
76
+ process.stdin.on('error', reject)
77
+ })
78
+ }
79
+
80
+ // asciidoctor-web-pdf only reads these from the environment (no -a attribute or
81
+ // CLI flag equivalent - see lib/browser.js) and defaults to 30s, which a large
82
+ // docset can easily exceed. Give it more headroom here rather than relying on
83
+ // whoever runs the build to remember to export these.
84
+ const PUPPETEER_TIMEOUT_ENV = {
85
+ PUPPETEER_NAVIGATION_TIMEOUT: '180000',
86
+ PUPPETEER_RENDERING_TIMEOUT: '180000',
87
+ }
88
+
89
+ // antora-assembler-pdf.yml deliberately does NOT set a `stylesheet` attribute
90
+ // itself - it's added here instead, __dirname-computed, so the path is always
91
+ // correct wherever this package happens to be installed. CSS url()s resolve
92
+ // relative to the stylesheet's own location, but Asciidoctor's `stylesheet`
93
+ // attribute resolves relative to docdir/cwd - a different base entirely - so
94
+ // this can't just be a relative value in the playbook/assembler config.
95
+ const args = process.argv.slice(2)
96
+ const stdinMarkerIdx = args.lastIndexOf('-')
97
+ // roles-labels-postprocessor.js and table-footnotes-postprocessor.js port the
98
+ // essential parts of the Antora extensions of the same name (see those
99
+ // files); macros-adapter.js and remote-include-adapter.js wrap the real
100
+ // @neo4j-documentation packages - added here, __dirname-computed, for the
101
+ // same reason as the stylesheet above.
102
+ const extraArgs = [
103
+ '-a', `stylesheet=${STYLESHEET}`,
104
+ '--extension', ROLES_LABELS_POSTPROCESSOR,
105
+ '--extension', TABLE_FOOTNOTES_POSTPROCESSOR,
106
+ '--extension', REMOTE_INCLUDE_ADAPTER,
107
+ '--extension', MACROS_ADAPTER,
108
+ ]
109
+ const finalArgs =
110
+ stdinMarkerIdx === -1
111
+ ? [...args, ...extraArgs]
112
+ : [...args.slice(0, stdinMarkerIdx), ...extraArgs, ...args.slice(stdinMarkerIdx)]
113
+
114
+ readStdin().then((adoc) => {
115
+ const child = spawn(RENDERER, finalArgs, {
116
+ stdio: ['pipe', 'inherit', 'inherit'],
117
+ env: { ...PUPPETEER_TIMEOUT_ENV, ...process.env },
118
+ })
119
+ child.on('error', (err) => {
120
+ console.error(err)
121
+ process.exit(1)
122
+ })
123
+ child.on('close', (status) => process.exit(status ?? 1))
124
+ child.stdin.end(dedupeGlossary(adoc))
125
+ })
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // Runs as this package's own `postinstall` (see package.json) - installs
5
+ // asciidoctor-pdf + the exact Asciidoctor.js it needs (4.x) into vendor/,
6
+ // fresh, in whichever environment this package itself just got installed
7
+ // into. asciidoctor-pdf needs @asciidoctor/core 4.x while Antora needs 2.x,
8
+ // and asciidoctor-pdf doesn't declare @asciidoctor/core as its own
9
+ // dependency (only a peerDep on the `asciidoctor` wrapper) - npm has no
10
+ // signal to isolate it from a docset's own shared install, and can silently
11
+ // hoist Antora's incompatible 2.x copy in instead, which crashes at import
12
+ // time. A real, separate `npm install` in vendor/'s own directory - exactly
13
+ // what the old isolated `renderer/` project this replaces did by hand -
14
+ // fixes that; running it from this package's own `postinstall` makes it
15
+ // automatic instead of a manual convention a consumer has to know about.
16
+ //
17
+ // vendor/package.json is written here, at install time, rather than shipped
18
+ // as a static file in the published package: a *shipped* nested
19
+ // package.json triggers some npm install-time reorganization that quietly
20
+ // merges vendor/'s own subfolders into this package's root and drops the
21
+ // file entirely (confirmed directly, by inspecting exactly what a real `npm
22
+ // install` of a real packed tarball produces vs. what plain `tar -x` of that
23
+ // same tarball contains - the tarball itself was correct, so this is npm's
24
+ // install step specifically, not a packaging mistake). Writing it fresh here
25
+ // avoids whatever triggers that.
26
+ //
27
+ // A published package's own devDependencies (used for local development -
28
+ // see this package's package.json) are never installed for a consumer at
29
+ // all, so this can't just be `asciidoctor`/`asciidoctor-pdf` deps declared
30
+ // there instead - nothing would ever install them for anyone but a
31
+ // contributor working on this package directly.
32
+
33
+ const { spawnSync } = require('node:child_process')
34
+ const fs = require('node:fs')
35
+ const path = require('node:path')
36
+
37
+ const vendorDir = path.join(__dirname, '../vendor')
38
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
39
+
40
+ fs.writeFileSync(
41
+ path.join(vendorDir, 'package.json'),
42
+ JSON.stringify(
43
+ {
44
+ name: 'pdf-generator-vendor',
45
+ version: '1.0.0',
46
+ private: true,
47
+ dependencies: {
48
+ asciidoctor: '4.1.0',
49
+ 'asciidoctor-pdf': '1.0.2',
50
+ },
51
+ },
52
+ null,
53
+ 2
54
+ ) + '\n'
55
+ )
56
+
57
+ const result = spawnSync(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund'], {
58
+ cwd: vendorDir,
59
+ stdio: 'inherit',
60
+ })
61
+
62
+ if (result.error || result.status !== 0) {
63
+ console.error('@neo4j-antora/pdf-generator: failed to install its isolated asciidoctor-pdf renderer (see vendor/package.json)')
64
+ process.exit(result.status || 1)
65
+ }
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ // @neo4j-documentation/macros' `label:x[]` inline macro reads
4
+ // `attr.$positional` (pre-4.x Asciidoctor.js's Opal-bridge attribute
5
+ // wrapper) to get the macro's positional text argument - removed in
6
+ // Asciidoctor.js 4.x, which instead hands a plain object with 1-indexed
7
+ // string keys (e.g. `{"1": "Custom text"}`). Without a fix, `attr.$positional`
8
+ // is just `undefined`, so any custom inline label text (e.g.
9
+ // `label:new[Custom text]`) is silently dropped in favour of the default
10
+ // display text - it doesn't throw, so this is easy to miss.
11
+ //
12
+ // Rather than patch the vendored file with patch-package (its postinstall
13
+ // hook isn't guaranteed to run once this package is itself a nested
14
+ // dependency of a docset's install - npm doesn't run a dependency's own
15
+ // lifecycle scripts by default), this wraps `registry.inlineMacro` so any
16
+ // attrs object handed to a macro's `process` callback gets `$positional`
17
+ // backfilled from those numeric keys, matching the shape macros.js expects.
18
+ //
19
+ // Must intercept via a Proxy rather than binding/replacing `self.process`
20
+ // directly - the installed Asciidoctor.js engine here is a compiled/WASM
21
+ // binding, and a `.bind()`'d reference to its native `process` method
22
+ // silently never invokes the callback it's given, even though calling it
23
+ // unbound (`target.process(fn)`) works correctly. Verified directly against
24
+ // the unpatched package and the installed engine.
25
+ const macros = require('@neo4j-documentation/macros')
26
+
27
+ function backfillPositionalAttrs (attrs) {
28
+ if (!attrs || attrs.$positional) return
29
+ const positional = []
30
+ let i = 1
31
+ while (attrs[String(i)] !== undefined) {
32
+ positional.push(attrs[String(i)])
33
+ i++
34
+ }
35
+ if (positional.length) attrs.$positional = positional
36
+ }
37
+
38
+ function shimOpalPositionalAttrs (registry) {
39
+ const original = registry.inlineMacro.bind(registry)
40
+ registry.inlineMacro = function (name, fn) {
41
+ return original(name, function (...outerArgs) {
42
+ const target = this
43
+ const selfProxy = new Proxy(target, {
44
+ get (t, prop, receiver) {
45
+ if (prop === 'process') {
46
+ return function (processFn) {
47
+ return t.process(function (parent, macroTarget, attrs) {
48
+ backfillPositionalAttrs(attrs)
49
+ return processFn(parent, macroTarget, attrs)
50
+ })
51
+ }
52
+ }
53
+ return Reflect.get(t, prop, receiver)
54
+ },
55
+ })
56
+ return fn.apply(selfProxy, outerArgs)
57
+ })
58
+ }
59
+ }
60
+
61
+ module.exports.register = function (registry, context) {
62
+ shimOpalPositionalAttrs(registry)
63
+ macros.register(registry, context)
64
+ }
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ // @neo4j-documentation/remote-include exports its registration function
4
+ // directly (`module.exports = function () { this.includeProcessor(...) }`),
5
+ // which is what Antora's own extension loader expects, but not what
6
+ // asciidoctor-web-pdf's --extension loader does (it requires `lib.register`
7
+ // to be a function - see requireLibrary/_prepareExtensions in
8
+ // asciidoctor/lib/cli.js). This bridges the two conventions.
9
+ //
10
+ // It also works around a second, unrelated problem: the package's own
11
+ // includeProcessor body calls the pre-4.x Asciidoctor.js Opal-bridge API
12
+ // (`this.$option(...)`), removed in 4.x (now `this.option(...)`) - see
13
+ // macros-adapter.js for the same class of bug in a different package.
14
+ // Rather than patch the vendored file with patch-package (its postinstall
15
+ // hook isn't guaranteed to run once this package is itself a nested
16
+ // dependency of a docset's install - npm doesn't run a dependency's own
17
+ // lifecycle scripts by default), this wraps `registry.includeProcessor`
18
+ // so the callback's `this` transparently answers `$option` calls by
19
+ // forwarding to the real `option` method. Verified directly against the
20
+ // unpatched package and the installed Asciidoctor.js engine.
21
+
22
+ const remoteInclude = require('@neo4j-documentation/remote-include')
23
+
24
+ function shimOpalOptionApi (registry) {
25
+ const original = registry.includeProcessor.bind(registry)
26
+ registry.includeProcessor = function (fn) {
27
+ return original(function (...args) {
28
+ const target = this
29
+ const self = new Proxy(target, {
30
+ get (t, prop, receiver) {
31
+ if (prop === '$option') return t.option.bind(t)
32
+ return Reflect.get(t, prop, receiver)
33
+ },
34
+ })
35
+ return fn.apply(self, args)
36
+ })
37
+ }
38
+ }
39
+
40
+ module.exports.register = function (registry) {
41
+ shimOpalOptionApi(registry)
42
+ remoteInclude.call(registry)
43
+ }
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ // Reuses @neo4j-antora/roles-labels' own shared core (extensions/antora/
4
+ // roles-labels/lib/process-labels.js in docs-tools) so PDF labels are
5
+ // produced by the exact same logic as the real HTML site - synonym
6
+ // resolution, version/product-suffix stripping, dataset attributes, inline
7
+ // vs role handling included - rather than a hand-ported subset that can
8
+ // silently drift from it (see docs-tools/pdf/README).
9
+ //
10
+ // roles-labels itself can't be *registered* here - it hooks Antora's own
11
+ // `pagesComposed` event and operates on files in an Antora ContentCatalog,
12
+ // which never exist in this pipeline: asciidoctor-web-pdf runs a completely
13
+ // separate, isolated Asciidoctor conversion on the assembler's merged .adoc
14
+ // text, so roles-labels never gets a chance to see - let alone transform -
15
+ // this content. This is a Postprocessor instead (Asciidoctor's own extension
16
+ // point, run after conversion to HTML, before the PDF renderer sees it) that
17
+ // calls the same shared `processLabels` function roles-labels.js calls.
18
+
19
+ const { parse: parseHTML } = require('node-html-parser')
20
+ // Must come from the same package identity the running engine (loaded via
21
+ // `asciidoctor`, not `@asciidoctor/core` directly, by asciidoctor-web-pdf/
22
+ // the CLI) uses internally - requiring the class from a different copy of
23
+ // the module fails Registry's own instanceof-style check with "Invalid type
24
+ // for postprocessor extension" even though it's structurally identical.
25
+ const { Postprocessor } = require('asciidoctor')
26
+ const { processLabels } = require('@neo4j-antora/roles-labels/lib/process-labels')
27
+
28
+ // Minimal shim matching the { info(meta, msg, ...args), warn(...), debug(...) }
29
+ // shape processLabels expects from Antora's pino-based logger - this pipeline
30
+ // has no Antora logger to reuse.
31
+ function makeLogger () {
32
+ const log = (level) => (meta, msg, ...args) => {
33
+ const consoleMethod = level === 'debug' ? 'log' : level
34
+ // eslint-disable-next-line no-console
35
+ console[consoleMethod](`[roles-labels] ${msg}`.replace(/%s/g, () => args.shift()), meta)
36
+ }
37
+ return { info: log('info'), warn: log('warn'), error: log('error'), debug: log('debug') }
38
+ }
39
+
40
+ const logger = makeLogger()
41
+
42
+ class RolesLabelsPostprocessor extends Postprocessor {
43
+ process (document, output) {
44
+ if (!output.includes('label--')) return output
45
+ const root = parseHTML(output)
46
+ processLabels(root, {
47
+ src: { path: 'pdf-export' },
48
+ attributes: document.getAttributes(),
49
+ logger,
50
+ defaultLogLevel: 'info',
51
+ replaceInlineLabelText: false,
52
+ // Plain Asciidoctor HTML5 output (used here) never has Antora's
53
+ // `article.doc` wrapper - falls back to the parsed root itself, since
54
+ // there's no better place to hang page-wide dataset attributes.
55
+ docRootSelector: 'article.doc',
56
+ // @antora/assembler marks *every* merged page's heading `discrete` to
57
+ // flatten section nesting/IDs across the book - unlike on a real
58
+ // Antora page, that's never a signal the author marked this heading as
59
+ // a non-section (see process-labels.js's own comment on this option),
60
+ // so a role on it must still become a label, exactly as it would in
61
+ // the HTML this page was built from.
62
+ skipDiscrete: false,
63
+ })
64
+ return root.toString()
65
+ }
66
+ }
67
+
68
+ module.exports.register = function (registry) {
69
+ registry.postprocessor(RolesLabelsPostprocessor)
70
+ }
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+
3
+ // Ports @neo4j-antora/table-footnotes for the PDF pipeline: moving a table's
4
+ // own footnotes out of Asciidoctor's single document-wide #footnotes div and
5
+ // into a <tfoot> row on that specific table, instead of leaving them
6
+ // dumped at the very end of the whole document, disconnected from the table
7
+ // they came from.
8
+ //
9
+ // Same reason this can't just reuse table-footnotes.js directly as roles-
10
+ // labels-postprocessor.js: it hooks Antora's own `pagesComposed` event and
11
+ // operates on files in an Antora ContentCatalog, neither of which exist in
12
+ // this pipeline (see docs-tools/pdf/README) - asciidoctor-web-pdf runs a
13
+ // separate, isolated Asciidoctor conversion on the assembler's merged .adoc
14
+ // text that table-footnotes never gets a chance to see. This is a
15
+ // Postprocessor instead (Asciidoctor's own extension point, run after
16
+ // conversion to HTML), with the same DOM manipulation ported over near
17
+ // verbatim - it's pure HTML restructuring, no Antora-specific data needed.
18
+
19
+ const { parse: parseHTML } = require('node-html-parser')
20
+ // Must come from the same package identity the running engine uses
21
+ // internally, not a separately-resolved copy - see roles-labels-
22
+ // postprocessor.js for why requiring the class from the wrong copy of the
23
+ // module fails Registry's own type check.
24
+ const { Postprocessor } = require('asciidoctor')
25
+
26
+ function createElement (el, className = '') {
27
+ return parseHTML(`<${el}${className ? ` class="${className}"` : ''}></${el}>`)
28
+ }
29
+
30
+ class TableFootnotesPostprocessor extends Postprocessor {
31
+ process (_document, output) {
32
+ if (!output.includes('id="footnotes"')) return output
33
+ const root = parseHTML(output)
34
+ const footnotesDiv = root.getElementById('footnotes')
35
+ const tables = root.querySelectorAll('table')
36
+ if (!footnotesDiv || tables.length === 0) return output
37
+
38
+ tables.forEach((table) => {
39
+ const tableFootnotes = table.querySelectorAll('tbody a.footnote')
40
+ if (tableFootnotes.length === 0) return
41
+
42
+ const cols = table.querySelectorAll('colgroup col').length
43
+ const tFoot = createElement('tfoot')
44
+ const footnoteRow = createElement('tr')
45
+ tFoot.firstElementChild.appendChild(footnoteRow)
46
+ const footnoteCell = createElement('td', 'tableblock footnote-cell')
47
+ footnoteCell.firstElementChild.setAttribute('colspan', cols)
48
+
49
+ // For each footnote reference in this table, find the matching
50
+ // footnote definition (by id, from its href) in the document-wide
51
+ // footnotes div, and move it into this table's own footer.
52
+ tableFootnotes.forEach((footnote) => {
53
+ const footnoteId = footnote.getAttribute('href').replace('#', '')
54
+ const matchingFootnote = footnotesDiv.querySelector(`#${footnoteId}`)
55
+ if (!matchingFootnote) return
56
+ footnoteCell.firstElementChild.appendChild(matchingFootnote)
57
+ })
58
+
59
+ footnoteRow.firstElementChild.appendChild(footnoteCell)
60
+ table.appendChild(tFoot)
61
+ })
62
+
63
+ // Remove the document-wide footnotes div if every footnote in it ended
64
+ // up moved into a table footer.
65
+ if (footnotesDiv.querySelectorAll('div.footnote').length === 0) {
66
+ footnotesDiv.remove()
67
+ }
68
+ return root.toString()
69
+ }
70
+ }
71
+
72
+ module.exports.register = function (registry) {
73
+ registry.postprocessor(TableFootnotesPostprocessor)
74
+ }