From b7166ea232076584dff8668331b979c057b3a704 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Wed, 23 Sep 2026 17:08:08 +0100 Subject: [PATCH 01/21] Add @neo4j-antora/pdf-generator and hook up PDF generation CI Introduces a real PDF export pipeline for Antora docsets (DOCOPS-183), built as an installable npm package rather than a manual checkout convention. @neo4j-antora/pdf-generator bundles the theme, convert.js, postprocessors, and its own isolated Asciidoctor.js install (needed because asciidoctor-pdf requires @asciidoctor/core 4.x while Antora needs 2.x - handled automatically via the package's own postinstall, not a sibling project a docset has to know about). It self-registers as an Antora extension with a default config, so a docset's playbook only needs: antora: extensions: - require: '@neo4j-antora/pdf-generator' (also therefore usable directly as `antora --extension @neo4j-antora/pdf-generator`). Depends on the real, published @neo4j-antora/roles-labels@0.1.14 (#134) for label handling shared identically with the HTML build - no hand-ported subset to drift out of sync. docs/pdf.yml is this repo's own test docset's playbook (content sources/attributes differ per docset, so not shared, same as preview.yml/publish.yml). docs/package.json adds pdf-generator as a normal dependency and a build:pdf script. .github/workflows/reusable-docs-pdf-build.yml and docs-generate-pdf.yml give any docset a CI entry point: install dependencies (pulling in pdf-generator and its own postinstall), force the Antora version to 3.2.0+ (needed for @antora/pdf-extension's assembler, regardless of what a docset's own package.json pins for its HTML build), run the PDF build, and upload the result as a build artifact. Verified end-to-end: a real npm install of the published package (cache cleared, not a symlink) into this exact branch state produces a correct PDF - role-based and inline labels, synonym/version-suffix resolution, custom inline label text all render exactly as they do on the real HTML site. Currently points at pdf-generator@0.1.0, not yet published (only prerelease tags 0.1.0-rc.1/rc.2 exist) - needs publishing before this lands. --- .github/workflows/docs-generate-pdf.yml | 32 ++ .github/workflows/reusable-docs-pdf-build.yml | 122 +++++ .gitignore | 5 + docs/package.json | 2 + docs/pdf.yml | 57 +++ pdf-generator/README.adoc | 131 ++++++ pdf-generator/antora-assembler-pdf.yml | 23 + pdf-generator/extension.js | 29 ++ pdf-generator/package.json | 23 + .../assets/fonts/roboto-mono-latin-400.woff2 | Bin 0 -> 16328 bytes .../assets/fonts/roboto-mono-latin-500.woff2 | Bin 0 -> 16380 bytes pdf-generator/pdf-theme/assets/neo4j-logo.svg | 1 + pdf-generator/pdf-theme/print.css | 422 ++++++++++++++++++ pdf-generator/scripts/convert.js | 125 ++++++ pdf-generator/scripts/setup-vendor.js | 65 +++ .../vendor/extensions/macros-adapter.js | 64 +++ .../extensions/remote-include-adapter.js | 43 ++ .../extensions/roles-labels-postprocessor.js | 70 +++ .../table-footnotes-postprocessor.js | 74 +++ 19 files changed, 1288 insertions(+) create mode 100644 .github/workflows/docs-generate-pdf.yml create mode 100644 .github/workflows/reusable-docs-pdf-build.yml create mode 100644 docs/pdf.yml create mode 100644 pdf-generator/README.adoc create mode 100644 pdf-generator/antora-assembler-pdf.yml create mode 100644 pdf-generator/extension.js create mode 100644 pdf-generator/package.json create mode 100644 pdf-generator/pdf-theme/assets/fonts/roboto-mono-latin-400.woff2 create mode 100644 pdf-generator/pdf-theme/assets/fonts/roboto-mono-latin-500.woff2 create mode 100644 pdf-generator/pdf-theme/assets/neo4j-logo.svg create mode 100644 pdf-generator/pdf-theme/print.css create mode 100644 pdf-generator/scripts/convert.js create mode 100644 pdf-generator/scripts/setup-vendor.js create mode 100644 pdf-generator/vendor/extensions/macros-adapter.js create mode 100644 pdf-generator/vendor/extensions/remote-include-adapter.js create mode 100644 pdf-generator/vendor/extensions/roles-labels-postprocessor.js create mode 100644 pdf-generator/vendor/extensions/table-footnotes-postprocessor.js diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml new file mode 100644 index 00000000..8705418e --- /dev/null +++ b/.github/workflows/docs-generate-pdf.yml @@ -0,0 +1,32 @@ +name: "Generate PDF" + +permissions: + contents: read + +# Mirrors docs-generate-html.yml's trigger shape (workflow_dispatch with a +# build-ref input). Deliberately simpler for now: PDF export always builds +# from pdf.yml regardless of environment (no publish-pdf.yml/dev-vs-prod +# split yet - see reusable-docs-pdf-build.yml's pdf-playbook input), and +# there's no publish hand-off step - it just uploads the PDF as a build +# artifact until a real publish target exists. Uses a relative path (this +# workflow lives in the same repo as reusable-docs-pdf-build.yml, testing it +# against docs-tools' own docs/ as the fixture) rather than a versioned +# `neo4j/docs-tools/...@v2`-style reference, the same way docs-generate- +# html.yml does for reusable-docs-build.yml. +on: + workflow_dispatch: + inputs: + build-ref: + description: 'The git ref to build from' + type: string + required: true + +jobs: + + docs-build-pdf: + name: Generate PDF + uses: ./.github/workflows/reusable-docs-pdf-build.yml + with: + docs-dir: 'docs' + build-ref: ${{ inputs.build-ref || github.ref_name }} + fetch-depth: 0 diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml new file mode 100644 index 00000000..54d780bd --- /dev/null +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -0,0 +1,122 @@ +name: Generate PDF + +permissions: + contents: read + pull-requests: none + +on: + workflow_call: + inputs: + build-ref: + description: 'The git ref (branch or tag) to checkout' + required: false + type: string + default: '' + fetch-depth: + description: 'The git fetch depth: 1 to fetch just the ref (branch or tag), 0 for full history' + required: false + type: number + default: 1 + docs-dir: + description: 'The working directory to run jobs in' + required: false + type: string + default: '.' + node-version: + description: 'The Node.js version to use' + required: false + type: string + default: '24' + antora-version: + description: 'The Antora version to install (must be >=3.2.0 - @antora/pdf-extension needs the assembler it bundles from that version on)' + required: false + type: string + default: '3.2.0' + pdf-playbook: + description: 'Antora playbook file, relative to docs-dir, that registers @neo4j-antora/pdf-generator - a small repo-specific file alongside preview.yml/publish.yml (content sources/attributes differ per docset, so not shared).' + required: false + type: string + default: 'pdf.yml' + retain-artifacts: + description: 'The number of days to retain artifacts' + type: number + default: 28 + +jobs: + + build-pdf: + runs-on: ubuntu-latest + + env: + NODE_VERSION: ${{ inputs.node-version }} + ANTORA_VERSION: ${{ inputs.antora-version }} + BUILD_REF: ${{ inputs.build-ref || '' }} + FETCH_DEPTH: ${{ inputs.fetch-depth }} + DOCS_DIR: ${{ inputs.docs-dir }} + PDF_PLAYBOOK: ${{ inputs.pdf-playbook }} + + steps: + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ env.BUILD_REF }} + fetch-depth: ${{ env.FETCH_DEPTH }} + + - name: Use Node.js version ${{ env.NODE_VERSION }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ env.NODE_VERSION }} + + # @neo4j-antora/pdf-generator is a normal npm dependency the docset's own + # package.json declares (see its own README) - it brings in + # @antora/pdf-extension, the PDF theme/renderer/postprocessors, and its + # own isolated Asciidoctor.js install (via its own postinstall) all by + # itself. Nothing to check out or path-patch here any more. + - name: Install docset dependencies + working-directory: ${{ env.DOCS_DIR }} + run: | + npm install --omit=dev + # Ensure a correct version of Antora, the same way reusable-docs-build.yml + # does for the HTML build - @antora/pdf-extension needs the assembler + # Antora 3.2.0+ bundles, regardless of what a docset's own package.json + # currently pins. + npm uninstall @antora/cli @antora/site-generator-default || true + npm install "antora@${ANTORA_VERSION}" + + - name: Run Antora PDF build + id: run-pdf-build + working-directory: ${{ env.DOCS_DIR }} + continue-on-error: true + env: + # Same fix as reusable-docs-build.yml's "Run package script" step: + # antora requires local-path extensions (e.g. a sibling extensions/ + # dir) directly by their real filesystem path, bypassing node_modules + # resolution entirely. NODE_PATH gives Node an extra place to look, + # so those extensions can still find hoisted deps (e.g. vinyl) that + # are only installed under docs-dir's node_modules. + NODE_PATH: ${{ github.workspace }}/${{ env.DOCS_DIR }}/node_modules + run: npx antora "$PDF_PLAYBOOK" --stacktrace --log-format=pretty + + - name: Get the dir that contains the PDF + if: steps.run-pdf-build.outcome == 'success' + working-directory: ${{ env.DOCS_DIR }} + id: get-pdf-path + run: | + pdf_path=$(find build/site -iname '*.pdf' | head -1) + if [ -z "$pdf_path" ]; then + echo "::error::No PDF found under build/site - check the Antora log above" + exit 1 + fi + echo "pdf-path=$pdf_path" >> "$GITHUB_OUTPUT" + + - name: Upload PDF artifact + if: steps.run-pdf-build.outcome == 'success' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: pdf + path: ${{ env.DOCS_DIR }}/${{ steps.get-pdf-path.outputs.pdf-path }} + retention-days: ${{ inputs.retain-artifacts }} + + - name: Fail job if PDF build failed + if: always() && steps.run-pdf-build.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index 3d377c31..8d6b2e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,11 @@ package-lock.json node_modules/ .env +# pdf-generator/scripts/setup-vendor.js writes this at install time (see its +# own comment) - node_modules/ and package-lock.json above already cover the +# rest of what that install produces +pdf-generator/vendor/package.json + # Claude — ignore everything except the shared slash commands .claude/* !.claude/commands/ diff --git a/docs/package.json b/docs/package.json index 73e0d262..573fd2fe 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,6 +13,7 @@ "postbuild": "node server.js", "build:preview": "antora preview.yml --stacktrace --log-format=pretty", "build:publish": "npm run clean && antora publish.yml --stacktrace --log-format=pretty", + "build:pdf": "antora pdf.yml --stacktrace --log-format=pretty", "verify:preview": "antora --stacktrace --fetch preview.yml --log-format=json --log-level=info --log-file ./build/log/log.json", "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json" }, @@ -26,6 +27,7 @@ "@neo4j-antora/aliases-redirects": "^0.2.7", "@neo4j-antora/antora-add-notes": "^0.3.2", "@neo4j-antora/mark-terms": "^1.1.4", + "@neo4j-antora/pdf-generator": "0.1.0", "@neo4j-antora/roles-labels": "^0.1.8", "@neo4j-antora/selector-labels": "^0.1.1", "@neo4j-antora/table-footnotes": "^1.0.1", diff --git a/docs/pdf.yml b/docs/pdf.yml new file mode 100644 index 00000000..03b1e8f9 --- /dev/null +++ b/docs/pdf.yml @@ -0,0 +1,57 @@ +site: + title: Docs Tools + start_page: docs-tools:ROOT:index.adoc + url: https://neo4j.com/docs/ + +content: + sources: + - url: ../ + start_path: docs + branches: [ 'HEAD' ] + +ui: + bundle: + url: https://static-content.neo4j.com/build/ui-bundle.zip + snapshot: true + +urls: + html_extension_style: indexify + +output: + dir: ./build/site + +antora: + extensions: + - require: "../extensions/antora/tabbed-nav" + - require: '@neo4j-antora/pdf-generator' + +asciidoc: + attributes: + # tabs + page-tabs: tools@ + page-tabs-index: 100 + # page-attributes are used by the ui-bundle and by extensions + page-theme: docs + page-type: Docs + page-search-type: Docs + page-search-site: Reference Docs + page-canonical-root: /docs + page-terms-to-mark: Neo4j, Cypher, test term + page-pagination: true + page-no-canonical: true + page-origin-private: true # change to false to display 'Raise an issue' links + page-hide-toc: false + page-mixpanel: 4bfb2414ab973c741b6f067bf06d5575 + # legacy attributes - do not change these + includePDF: false + nonhtmloutput: "" + experimental: '' + # update the copyright value with the first commit in a new year + copyright: 2026 + # icon attributes + check-mark: icon:check[] + cross-mark: icon:times[] + # neo4j.com attributes. Always use when linking to neo4j.com URLs + neo4j-base-uri: https://neo4j.com + neo4j-docs-base-uri: '{neo4j-base-uri}/docs' + common-license-page-uri: '{neo4j-docs-base-uri}/license' diff --git a/pdf-generator/README.adoc b/pdf-generator/README.adoc new file mode 100644 index 00000000..d693022b --- /dev/null +++ b/pdf-generator/README.adoc @@ -0,0 +1,131 @@ += PDF generation + +Generates a PDF export from any Antora docset, via `@antora/assembler` + +`@antora/pdf-extension` and `asciidoctor-web-pdf` (a Chrome/Puppeteer + CSS +renderer) instead of the legacy Gradle + AsciidoctorJ mono-merge pipeline. +See `reusable-pdf-build.yml` in `.github/workflows/` for the CI entry point +that uses this package. + +Published as a normal npm dependency - a docset adds it as a devDependency +and gets a real, versioned copy in its own `node_modules`, the same way it +already depends on `@neo4j-antora/roles-labels` or `@neo4j-antora/tabbed-nav`. +There's nothing to check out, symlink, or hand-configure: `@antora/pdf- +extension` is this package's own dependency (not something a docset lists +itself), so `npm install @neo4j-antora/pdf-generator` is the entire setup. + +== Usage + +A docset needs its own small playbook (like `preview.yml`/`publish.yml`, +since content sources/attributes differ per docset - not shared here) that +registers `@antora/pdf-extension` with this package's assembler config: + +[source,yaml] +---- +antora: + extensions: + - require: '@antora/pdf-extension' + config_file: './node_modules/@neo4j-antora/pdf-generator/antora-assembler-pdf.yml' +---- + +and a `package.json` script to run it, e.g. `"pdf": "antora pdf.yml"`. + +== Layout + +- `antora-assembler-pdf.yml` - Assembler config, pointed at from a docset's + own playbook (see above). +- `scripts/convert.js` - wraps the real `asciidoctor-web-pdf` binary as the + assembler's `build.command`. Resolves the renderer binary (via + `require.resolve` against its own `asciidoctor-pdf` dependency, regardless + of how npm hoists it) and the stylesheet/extension paths (via `__dirname`) + at runtime, so nothing here is hardcoded to a particular install layout. + Also works around a real bug (`ifndef::backend-pdf[]` not evaluating + correctly under this pipeline) for docsets using that idiom - a no-op for + any docset that doesn't. +- `pdf-theme/print.css` - print stylesheet targeting Asciidoctor's standard + HTML5 backend class names (`sect1`, `admonitionblock`, `listingblock`, + `tableblock`, `label`, ...), using colour/font tokens copied from + `docs-ui`'s `src/css/vars.css` (itself resolving to raw Needle design + system tokens in `@neo4j-ndl/base`'s `tokens/css/tokens.css`) and + `docs-ui`'s `src/css/labels.css`. Not a fork of the real site CSS wholesale + - Asciidoctor's default HTML5 output doesn't have Antora's `.doc` wrapper + markup most of the site CSS's selectors are written against, so most of + it wouldn't match anything here anyway. Fonts are vendored into + `pdf-theme/assets/fonts/` rather than read from a docset's own HTML + build output, so this theme has no dependency on that build having + already run. +- `extensions/` - `roles-labels-postprocessor.js` and + `table-footnotes-postprocessor.js` port the essential parts of the Antora + extensions of the same name (see "Known limitations" below); + `macros-adapter.js` and `remote-include-adapter.js` wrap the real + `@neo4j-documentation/macros`/`remote-include` packages, fixing a + pre-4.x-Asciidoctor.js API incompatibility in each at runtime (see their + own comments) rather than patching the installed packages - a nested + dependency's own `postinstall` isn't guaranteed to run once this package + is itself installed as a docset's dependency, so `patch-package` isn't a + reliable option here the way it is for a top-level project. +- `@asciidoctor/core` 4.x (needed by `asciidoctor-pdf`) vs. Antora's own 2.x: + no longer needs the isolated sibling npm project the git-checkout-based + version of this pipeline required - both land in the same install + correctly because npm nests conflicting versions of the same dependency + automatically; this package simply declares its own real dependencies and + lets normal npm resolution do the isolation. + +== Known limitations + +- **No footnotes render at all, anywhere in the PDF, because of the TOC.** + Isolated by direct testing against `asciidoctor-web-pdf` outside this whole + pipeline: passing `-a toc` alone (nothing else - no tables, no our own + extensions) is enough to break native Asciidoctor `footnote:[...]` + conversion entirely - no superscript reference, no `#footnotes` div, just + the footnote text dropped in-line as if the macro had never been + processed. Since the print theme enables `toc` for every docset, this + currently affects every PDF this pipeline produces, not just docsets using + footnotes inside tables. `extensions/table-footnotes-postprocessor.js` + (see above) is correctly written and wired in, but has nothing to do while + this is broken - there's no `#footnotes` div for it to move content out of. + Not yet root-caused further (asciidoctor-web-pdf itself, or its Vivliostyle + dependency) or fixed. +- `@neo4j-antora/roles-labels` and `@neo4j-antora/table-footnotes` are Antora + extensions (hook `pagesComposed`, operate on an Antora `ContentCatalog`) - + neither can run against this pipeline at all, structurally: the assembler + hands the merged `.adoc` to a completely separate, isolated Asciidoctor + conversion with no Antora generator context. `table-footnotes` is ported + as a plain Asciidoctor `Postprocessor` instead + (`extensions/table-footnotes-postprocessor.js`) - pure HTML restructuring, + no Antora-specific data needed, so the port is a straight copy. + `roles-labels-postprocessor.js` reuses `@neo4j-antora/roles-labels`'s own + shared `lib/process-labels.js` core directly (same synonym resolution, + version/product-suffix stripping, dataset attributes as the real HTML + site - not a hand-ported subset), with one deliberate behavioural + difference: it passes `skipDiscrete: false`, because `@antora/assembler` + marks *every* merged page's heading `discrete` to flatten section nesting + across the book, which isn't the authorial signal `discrete` is on a real + Antora HTML page (see `process-labels.js`'s own comment on that option). +- Classification of `reusable-docs-build.yml`'s default `antora-extensions` + list, checked against their actual registration mechanism: + `aliases-redirects`, `antora-modify-sitemaps`, `antora-page-list` all hook + Antora-only lifecycle events (site redirects, sitemap.xml, the HTML + page-list artifact) - inherently HTML/site-only, correctly and harmlessly + absent from PDF output, nothing to do. `antora-unlisted-pages`/ + `selector-labels` not individually confirmed but almost certainly the same + (nav/site-UI concerns). `xref-hash-validator` hooks `contentClassified`/ + `documentsConverted` but only validates/warns - doesn't transform content, + so its absence from PDF just means xrefs aren't validated in that context, + not a rendering gap. +- **TODO, not yet decided**: `@neo4j-antora/mark-terms` *is* a plain + Asciidoctor extension (`module.exports = function (registry) {...}`, no + Antora event hook) - structurally it'd just need `--extension` + registration, the same easy fix as `macros`/`remote-include`. Not done + yet, because it's a product decision, not just a technical one: mark-terms + adds a trademark/copyright mark on a term's *first use per page* (e.g. the + first "Neo4j" on each HTML page) - a PDF has no equivalent notion of "page" + the way a docset's HTML pages do, so "first use" would need redefining + (per PDF? per chapter? just once, ever?), or the whole approach swapped for + a single boilerplate copyright/trademark statement somewhere in the PDF + (e.g. the cover or a colophon page) instead of inline marks. Needs a call + on the right behaviour before doing the (otherwise straightforward) + registration work. +- This package's `print.css` label colours are hand-resolved from + `docs-ui`'s source files, not read from them live - if the Needle tokens or + `docs-ui`'s `vars.css`/`labels.css` change, this drifts until someone + notices and re-syncs it by hand. diff --git a/pdf-generator/antora-assembler-pdf.yml b/pdf-generator/antora-assembler-pdf.yml new file mode 100644 index 00000000..8b8903ed --- /dev/null +++ b/pdf-generator/antora-assembler-pdf.yml @@ -0,0 +1,23 @@ +component_version_filter: + names: '**' +assembly: + attributes: + allow-uri-read: '' + toc: '' + toc-title: Table of Contents + toclevels: 2 +build: + # build.cwd (what a relative command resolves against) defaults to the + # *playbook's* directory (docs-dir) - the same directory `npm install` is + # run from for that docset, since that's where its own package.json lives. + # node_modules is therefore always right here, at a fixed, predictable + # depth, regardless of how deeply nested docs-dir is within the docset + # repo - unlike the old git-checkout-into-.pdf-tools convention this + # package replaces, no per-consumer path rewriting is needed. Once + # convert.js itself is running, it resolves everything else (the + # renderer, the stylesheet, the extensions) from its own location or via + # require.resolve against its own dependencies instead - see this + # package's own README. + command: node ./node_modules/@neo4j-antora/pdf-generator/scripts/convert.js --trace + keep_source: true + qualify_exports: true diff --git a/pdf-generator/extension.js b/pdf-generator/extension.js new file mode 100644 index 00000000..f64d8c49 --- /dev/null +++ b/pdf-generator/extension.js @@ -0,0 +1,29 @@ +'use strict' + +const path = require('node:path') +const pdfExtension = require('@antora/pdf-extension') + +const DEFAULT_CONFIG_FILE = path.join(__dirname, 'antora-assembler-pdf.yml') + +// Lets a docset just do `- require: '@neo4j-antora/pdf-generator'` (or +// `antora --extension @neo4j-antora/pdf-generator`) instead of also having to +// know and specify this package's own antora-assembler-pdf.yml as +// @antora/pdf-extension's own configFile - this *is* @antora/pdf-extension, +// just pre-wired with this package's assembler config as the default. A +// docset that wants to override individual assembler settings can still pass +// its own `config:` in the playbook as normal (e.g. a different +// configFile); anything it doesn't set falls back to this default. +// +// Takes a single destructured `{ config }` parameter deliberately, matching +// the exact shape @antora/pdf-extension's own register() uses - Antora's +// generator-context inspects a register function's source to decide how to +// invoke it (see @antora/site-generator/lib/generator-context.js), and a +// destructured single parameter is the path that gets `this` bound to the +// generator context and a merged `{config, ...playbookVars}` object passed +// as the one argument - confirmed directly against the installed Antora, +// including that @antora/pdf-extension's own optional second `providers` +// parameter is never actually populated by a real Antora invocation either. +module.exports.register = function ({ config = {} } = {}) { + const resolvedConfig = Object.assign({ configFile: DEFAULT_CONFIG_FILE }, config) + return pdfExtension.register.call(this, { config: resolvedConfig }) +} diff --git a/pdf-generator/package.json b/pdf-generator/package.json new file mode 100644 index 00000000..58254a9b --- /dev/null +++ b/pdf-generator/package.json @@ -0,0 +1,23 @@ +{ + "name": "@neo4j-antora/pdf-generator", + "version": "0.1.0", + "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", + "main": "extension.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "postinstall": "node scripts/setup-vendor.js" + }, + "keywords": [ + "antora", + "pdf" + ], + "author": "Neo4j", + "license": "MIT", + "dependencies": { + "@antora/pdf-extension": "1.0.0", + "@neo4j-antora/roles-labels": "0.1.14", + "@neo4j-documentation/macros": "1.0.4", + "@neo4j-documentation/remote-include": "1.0.0", + "node-html-parser": "9.0.4" + } +} diff --git a/pdf-generator/pdf-theme/assets/fonts/roboto-mono-latin-400.woff2 b/pdf-generator/pdf-theme/assets/fonts/roboto-mono-latin-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..53d4b505c2eb685b2b57d30efebb71a40b096276 GIT binary patch literal 16328 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 KgMMWtibn&Whq566 literal 0 HcmV?d00001 diff --git a/pdf-generator/pdf-theme/assets/neo4j-logo.svg b/pdf-generator/pdf-theme/assets/neo4j-logo.svg new file mode 100644 index 00000000..b2114231 --- /dev/null +++ b/pdf-generator/pdf-theme/assets/neo4j-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pdf-generator/pdf-theme/print.css b/pdf-generator/pdf-theme/print.css new file mode 100644 index 00000000..07b1b1cc --- /dev/null +++ b/pdf-generator/pdf-theme/print.css @@ -0,0 +1,422 @@ +/* Print-specific stylesheet shared by every docset's PDF export (see + docs-tools/pdf/README - checked out into each repo's build by + reusable-pdf-build.yml). Reuses brand tokens read from the real site.css + (build/site/assets/css/site.css) rather than forking that file, since + Asciidoctor's HTML5 backend (used by asciidoctor-web-pdf) does not produce + Antora's `.doc` wrapper markup. + + Every asset path below is relative to this file's own location, not to + whatever repo/cwd the build runs in - a browser resolves CSS url()s + against the stylesheet's own file:// location, so this stays correct + wherever docs-tools gets checked out. Font files are vendored into + assets/fonts/ here (not read from a docset's own build output) so this + theme has no dependency on that docset's HTML build having already run. */ +@font-face { + font-family: 'Roboto Mono'; + font-weight: 400; + src: url('assets/fonts/roboto-mono-latin-400.woff2') format('woff2'); +} +@font-face { + font-family: 'Roboto Mono'; + font-weight: 500; + src: url('assets/fonts/roboto-mono-latin-500.woff2') format('woff2'); +} + +:root { + --brand-primary: #0a6190; + --brand-primary-strong: #02507b; + --brand-primary-weak: #e7fafb; + --brand-text: #1a1b1d; + --brand-text-weak: #4d5157; + --brand-text-weaker: #5e636a; + --brand-border: #e2e3e5; + --brand-border-strong: #bbbec3; + --brand-bg-weak: #fff; + --brand-bg-strong: #f5f6f6; + --brand-danger: #a6291f; + --brand-danger-bg: #fdf1ef; + --brand-warning: #8a5a00; + --brand-warning-bg: #fdf6e8; + --font-body: 'Public Sans', 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-mono: 'Roboto Mono', Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +@page { + size: A4; + margin: 25mm 18mm 20mm 18mm; +} +/* Verso (even/left) pages: page number on the outer (left) edge */ +@page :left { + @bottom-left { + content: counter(page); + font-family: var(--font-body); + font-size: 9pt; + color: var(--brand-text-weaker); + } + @bottom-right { + content: string(doc-title) " " string(doc-version); + font-family: var(--font-body); + font-size: 9pt; + color: var(--brand-text-weaker); + } +} +/* Recto (odd/right) pages: page number on the outer (right) edge */ +@page :right { + @bottom-left { + content: string(doc-title) " " string(doc-version); + font-family: var(--font-body); + font-size: 9pt; + color: var(--brand-text-weaker); + } + @bottom-right { + content: counter(page); + font-family: var(--font-body); + font-size: 9pt; + color: var(--brand-text-weaker); + } +} +@page :first { + @bottom-left { + content: normal; + } + @bottom-right { + content: normal; + } +} + +#cover.title-page h1 { + string-set: doc-title content(); +} +#cover .details #revnumber { + string-set: doc-version content(); +} + +body.book { + font-family: var(--font-body); + color: var(--brand-text); + font-size: 10.5pt; + line-height: 1.5; +} + +#cover.title-page { + text-align: left; + page-break-after: always; +} +#cover.title-page::before { + content: url('assets/neo4j-logo.svg'); + display: block; + width: 110pt; +} +#cover.title-page h1 { + font-size: 32pt; + font-weight: 700; + color: var(--brand-primary-strong); + margin-top: 30%; + border-bottom: 2pt solid var(--brand-primary); + padding-bottom: 12pt; +} +#cover .details { + color: var(--brand-text-weak); + font-size: 11pt; + margin-top: 8pt; +} +#cover #revdate { + display: none; +} + +/* Table of contents */ +#toc { + page-break-after: always; +} +#toctitle { + font-family: var(--font-body); + color: var(--brand-primary-strong); + font-size: 22pt; + font-weight: 700; + border-bottom: 2pt solid var(--brand-primary); + padding-bottom: 10pt; + margin-bottom: 18pt; +} +#toc ul { + font-family: var(--font-body); + list-style-type: none; + margin-left: 0; + padding-left: 0; +} +#toc ul.sectlevel0 { + margin-left: 0; +} +#toc ul ul.sectlevel1 { + margin-left: 14pt; +} +#toc li { + margin-top: 6pt; + line-height: 1.4; +} +#toc a { + display: flex; + color: var(--brand-text); + text-decoration: none; + font-size: 10.5pt; +} +#toc ul.sectlevel0 > li > a { + font-weight: 600; + font-style: normal; + color: var(--brand-primary-strong); +} +#toc a::after { + content: leader('.') target-counter(attr(href), page); + color: var(--brand-text-weaker); + font-weight: 400; +} + +.sect1 { break-before: page; } +.sect1:first-child { break-before: avoid; } + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-body); + color: var(--brand-primary-strong); + font-weight: 700; + break-after: avoid; +} +h2 { font-size: 18pt; border-bottom: 1pt solid var(--brand-border); padding-bottom: 4pt; } +h3 { font-size: 14pt; } +h4 { font-size: 12pt; } + +a { color: var(--brand-primary); text-decoration: none; } + +/* code */ +pre, code, kbd, tt { + font-family: var(--font-mono); +} +.listingblock { + break-inside: avoid-page; + margin: 1em 0; +} +.listingblock pre { + background: var(--brand-bg-strong); + border: 0.5pt solid var(--brand-border); + border-radius: 3pt; + padding: 8pt 10pt; + font-size: 9pt; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.listingblock .title { + font-weight: 600; + color: var(--brand-text-weak); + font-size: 9.5pt; + margin-bottom: 4pt; +} + +/* tables */ +table.tableblock { + border-collapse: collapse; + width: 100%; + break-inside: auto; + font-size: 9.5pt; +} +table.tableblock th, table.tableblock td { + border: 0.5pt solid var(--brand-border); + padding: 5pt 7pt; + vertical-align: top; +} +table.tableblock thead th { + background: var(--brand-bg-strong); + color: var(--brand-text); + font-weight: 600; +} +tr { break-inside: avoid; } + +/* admonitions */ +.admonitionblock { break-inside: avoid-page; margin: 1em 0; } +.admonitionblock table { width: 100%; border: none; } +.admonitionblock td.icon { display: none; } +.admonitionblock td.content { + border-left: 3pt solid var(--brand-border-strong); + padding: 6pt 10pt; + background: var(--brand-bg-strong); +} +.admonitionblock .title { + font-weight: 700; + text-transform: uppercase; + font-size: 8.5pt; + letter-spacing: 0.03em; + color: var(--brand-text-weak); + display: block; + margin-bottom: 3pt; +} +.admonitionblock.warning td.content, +.admonitionblock.caution td.content { + border-left-color: var(--brand-warning); + background: var(--brand-warning-bg); +} +.admonitionblock.warning .title, +.admonitionblock.caution .title { color: var(--brand-warning); } +.admonitionblock.important td.content { + border-left-color: var(--brand-danger); + background: var(--brand-danger-bg); +} +.admonitionblock.important .title { color: var(--brand-danger); } +.admonitionblock.note td.content, +.admonitionblock.tip td.content { + border-left-color: var(--brand-primary); + background: var(--brand-primary-weak); +} +.admonitionblock.note .title, +.admonitionblock.tip .title { color: var(--brand-primary-strong); } + +/* lists */ +.ulist, .olist, .dlist { margin: 0.5em 0; } + +/* role labels (badges added by @neo4j-antora/roles-labels for label:x[] macros + and :page-role:/[role=label--x] roles). + + This is `docs-ui/src/css/labels.css` (the *source* file the real site.css is + built from - github.com/neo4j-documentation/docs-ui) copied rule-for-rule, + minus the handful of selectors scoped under Antora's `.doc` wrapper (which + plain Asciidoctor HTML5 output, used for the PDF, never has - see file + header) - not hand-approximated values. Its `var(--label-*)`/`var(--deprecated-*)`/ + `var(--alpha-beta-*)`/`var(--success-color)` custom properties are themselves + defined in `docs-ui/src/css/vars.css` in terms of raw design tokens from the + Needle design system (`@neo4j-ndl/base`'s `tokens/css/tokens.css`); resolved + to literal values below since neither of those files is otherwise pulled in + here. Re-resolve from those two sources if this ever drifts. */ +.label { + display: inline-block; + /* Vertical padding trimmed from the copied 0.2rem: consecutive inline + labels on adjacent lines (e.g. label:x[] label:y[] each on their own + line) had no visible gap between them otherwise - line-height alone + isn't enough since the pills' padding pushed their edges flush. */ + padding: 0.1rem 0.8rem; + flex-shrink: 0; + border-radius: 9999px; + background: var(--brand-primary); /* --label-default-background-color: --theme-light-color-primary-text */ + color: var(--brand-primary-weak); /* --label-default-color: --palette-baltic-10 */ + font-weight: 600; + font-size: calc(0.8 * 0.875rem); /* --label-title-font-size: calc(0.8 * --typography-label-font-size) */ + font-style: normal; +} +.tableblock .label { + margin-top: 0.2rem; + margin-bottom: 0.5rem; +} +span.label--added, +span.label--changed, +span.label--new, +span.label--featured, +span.label--renamed, +span.label--updated, +span.label--yes { + background: #3f7824; /* --label-success-color: --theme-light-color-success-bg-strong */ + color: #e7fcd7; /* --label-success-background-color: --theme-light-color-success-bg-weak */ +} +span.label--admin-only, +span.label--danger, +span.label--discontinued, +span.label--na, +span.label--no, +span.label--not-on-aura, +span.label.not-available, +span.label--removed, +span.label--warning, +span.label--breaking { + background: #bb2d00; /* --label-warning-background-color: --theme-light-color-danger-bg-strong */ + color: #ffe9e7; /* --label-warning-color: --theme-light-color-danger-bg-weak */ +} +span.label--deprecated { + background: #ffd600; /* --deprecated-background-color: --palette-lemon-30 */ + color: #251b00; /* --deprecated-color: --palette-lemon-80 */ +} +span.label--alpha, +span.label--beta, +span.label--beta-until { + background: #ba7a00; /* --alpha-beta-background-color: --palette-marigold-50 */ + color: #fff0d2; /* --alpha-beta-color: --palette-marigold-10 */ +} +span.label--procedure, +span.label--function, +span.label--unix, +span.label--mac-os, +span.label--linux, +span.label--windows, +span.label--syntax, +span.label--functionality, +span.label--cypher, +span.label--cluster-member-core, +span.label--cluster-member-read-replica, +span.label--cluster-member-single, +span.label--core, +span.label--apoc-core, +span.label--full, +span.label--apoc-full { + background: #8fe3e8; /* --label-os-background-color: --theme-dark-color-primary-text */ + color: #081e2b; /* --label-os-color: --palette-baltic-70 */ +} +span.label--labs, +span.label--labs-label { + color: var(--brand-primary-weak); /* --label-labs-color: --palette-baltic-10 */ + background: #5a34aa; /* --label-labs-background-color: --palette-lavender-45 */ +} +span.label--graph-academy { + background: #3f7824; /* --success-color: --theme-light-color-success-bg-strong */ +} + +div.labels { + display: flex; + align-self: center; + gap: 0.25rem; + line-height: 1.8; /* --doc-line-height */ + font-family: var(--font-body); /* --body-font-family */ +} +.flex-labels-container { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-direction: row-reverse; +} +.header-label-container { + display: flex; + flex-wrap: wrap; +} +.admonitionblock div.labels { padding: 0.5rem 0 1rem; } +.exampleblock div.labels { padding: 0.5rem; } +.header-label-container > div.labels { + justify-content: space-between; + margin-left: auto; +} +.header-label-container > div.labels.wrapped { + margin-left: 0; + margin-top: 0.5rem; +} +h1 > .header-label { + margin-top: 1.2rem; +} +.header-label-container > .header-label:first-of-type { + margin-left: auto; +} +.listing-block .content-labels, +.example-block .content-labels, +.content-labels { + margin-bottom: 0.2rem; +} +.paragraph.has-label { + padding-left: 0.4rem; + border-left: 2px solid var(--brand-border-strong); /* --palette-baltic-60, not otherwise used here */ +} +.paragraph.has-label:has(> .labels > .label--new) { + border-left-color: #3f7824; /* --success-color */ +} +.paragraph.has-label:has(> .labels > .label--deprecated) { + border-left-color: #251b00; /* --deprecated-color */ +} +h2 > .flex-label { + float: inline-end; + line-height: 1.8; + margin-left: 0.2rem; + margin-top: 0.2rem; +} + +/* TOC (hidden fixed div rendered separately by asciidoctor-web-pdf's own TOC mechanism) */ +.toc-entry a { color: var(--brand-text); text-decoration: none; } diff --git a/pdf-generator/scripts/convert.js b/pdf-generator/scripts/convert.js new file mode 100644 index 00000000..a3e590cb --- /dev/null +++ b/pdf-generator/scripts/convert.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +'use strict' + +// Wraps the real asciidoctor-web-pdf binary as the assembler's build.command. +// Every path below is computed from this script's own location (__dirname) - +// never hardcoded, never dependent on where a consuming project's +// node_modules happens to hoist things - see this package's own README. + +const { spawn } = require('node:child_process') +const path = require('node:path') + +// asciidoctor-pdf needs @asciidoctor/core 4.x while Antora needs 2.x, and it +// doesn't declare @asciidoctor/core as its own dependency (only a peerDep on +// the `asciidoctor` wrapper) - npm has no signal to ever nest an isolated +// copy for it, so a normal dependency install can silently hoist Antora's +// 2.x copy in instead, which crashes at import time. `vendor/` is a real, +// pre-installed node_modules tree (asciidoctor + asciidoctor-pdf and their +// own transitive deps only) shipped as plain files with this package - not +// npm dependencies at all from a consumer's point of view - so it's immune +// to whatever else a docset's own install needs. The postprocessor/adapter +// extensions live inside vendor/ too, so their own `require('asciidoctor')` +// resolves the same isolated copy the renderer itself uses (required for +// `Postprocessor` - see roles-labels-postprocessor.js's own comment); their +// other requires (`node-html-parser`, `@neo4j-antora/roles-labels`, ...) fall +// through vendor/'s node_modules to the consumer's normal install, since +// those have no such conflict and should stay deduped normally. +const RENDERER = path.join(__dirname, '../vendor/node_modules/asciidoctor-pdf/bin/asciidoctor-web-pdf') +const STYLESHEET = path.join(__dirname, '../pdf-theme/print.css') +const ROLES_LABELS_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/roles-labels-postprocessor.js') +const TABLE_FOOTNOTES_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/table-footnotes-postprocessor.js') +const REMOTE_INCLUDE_ADAPTER = path.join(__dirname, '../vendor/extensions/remote-include-adapter.js') +const MACROS_ADAPTER = path.join(__dirname, '../vendor/extensions/macros-adapter.js') + +const PAGE_BOUNDARY_RX = /(?=^:page-docname: .*$)/m +const GLOSSARY_MARKER_RX = /^\[discrete\.glossary#.*\]$/m + +// Some docsets (e.g. docs-http-api) repeat a glossary include on several +// source pages, meant to be excluded from PDF via ifndef::backend-pdf[], +// which doesn't evaluate correctly under this pipeline (see this package's +// own README - the assembler resolves that attribute against its own +// internal re-parse context, not the real, final PDF conversion). This is a +// no-op for any docset with no `[discrete.glossary#...]` marker in its +// merged source. +function dedupeGlossary (adoc) { + const [preamble, ...pages] = adoc.split(PAGE_BOUNDARY_RX) + let glossaryChunk + const strippedPages = pages.map((page) => { + const match = GLOSSARY_MARKER_RX.exec(page) + if (!match) return page + if (!glossaryChunk) { + // Drop the "discrete" style so the glossary becomes a normal chapter + // section: included in the TOC and picked up by the print theme's + // `.sect1 { break-before: page }` rule like every other chapter. + glossaryChunk = page + .slice(match.index) + .trimEnd() + .replace(/^\[discrete\.glossary/, '[glossary') + } + // Keep a blank line before whatever follows (the next page's own + // `:page-docname:` metadata block, mid-document) - an undelimited + // single-paragraph admonition like [NOTE] only ends at a blank line, so + // trimming it away merges the next page's raw attribute lines straight + // into that paragraph's text instead of stopping it. + return page.slice(0, match.index).trimEnd() + '\n\n' + }) + let result = preamble + strippedPages.join('') + if (glossaryChunk) result = result.trimEnd() + '\n\n' + glossaryChunk + '\n' + return result +} + +function readStdin () { + const chunks = [] + return new Promise((resolve, reject) => { + process.stdin.on('data', (chunk) => chunks.push(chunk)) + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + process.stdin.on('error', reject) + }) +} + +// asciidoctor-web-pdf only reads these from the environment (no -a attribute or +// CLI flag equivalent - see lib/browser.js) and defaults to 30s, which a large +// docset can easily exceed. Give it more headroom here rather than relying on +// whoever runs the build to remember to export these. +const PUPPETEER_TIMEOUT_ENV = { + PUPPETEER_NAVIGATION_TIMEOUT: '180000', + PUPPETEER_RENDERING_TIMEOUT: '180000', +} + +// antora-assembler-pdf.yml deliberately does NOT set a `stylesheet` attribute +// itself - it's added here instead, __dirname-computed, so the path is always +// correct wherever this package happens to be installed. CSS url()s resolve +// relative to the stylesheet's own location, but Asciidoctor's `stylesheet` +// attribute resolves relative to docdir/cwd - a different base entirely - so +// this can't just be a relative value in the playbook/assembler config. +const args = process.argv.slice(2) +const stdinMarkerIdx = args.lastIndexOf('-') +// roles-labels-postprocessor.js and table-footnotes-postprocessor.js port the +// essential parts of the Antora extensions of the same name (see those +// files); macros-adapter.js and remote-include-adapter.js wrap the real +// @neo4j-documentation packages - added here, __dirname-computed, for the +// same reason as the stylesheet above. +const extraArgs = [ + '-a', `stylesheet=${STYLESHEET}`, + '--extension', ROLES_LABELS_POSTPROCESSOR, + '--extension', TABLE_FOOTNOTES_POSTPROCESSOR, + '--extension', REMOTE_INCLUDE_ADAPTER, + '--extension', MACROS_ADAPTER, +] +const finalArgs = + stdinMarkerIdx === -1 + ? [...args, ...extraArgs] + : [...args.slice(0, stdinMarkerIdx), ...extraArgs, ...args.slice(stdinMarkerIdx)] + +readStdin().then((adoc) => { + const child = spawn(RENDERER, finalArgs, { + stdio: ['pipe', 'inherit', 'inherit'], + env: { ...PUPPETEER_TIMEOUT_ENV, ...process.env }, + }) + child.on('error', (err) => { + console.error(err) + process.exit(1) + }) + child.on('close', (status) => process.exit(status ?? 1)) + child.stdin.end(dedupeGlossary(adoc)) +}) diff --git a/pdf-generator/scripts/setup-vendor.js b/pdf-generator/scripts/setup-vendor.js new file mode 100644 index 00000000..242ed515 --- /dev/null +++ b/pdf-generator/scripts/setup-vendor.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +'use strict' + +// Runs as this package's own `postinstall` (see package.json) - installs +// asciidoctor-pdf + the exact Asciidoctor.js it needs (4.x) into vendor/, +// fresh, in whichever environment this package itself just got installed +// into. asciidoctor-pdf needs @asciidoctor/core 4.x while Antora needs 2.x, +// and asciidoctor-pdf doesn't declare @asciidoctor/core as its own +// dependency (only a peerDep on the `asciidoctor` wrapper) - npm has no +// signal to isolate it from a docset's own shared install, and can silently +// hoist Antora's incompatible 2.x copy in instead, which crashes at import +// time. A real, separate `npm install` in vendor/'s own directory - exactly +// what the old isolated `renderer/` project this replaces did by hand - +// fixes that; running it from this package's own `postinstall` makes it +// automatic instead of a manual convention a consumer has to know about. +// +// vendor/package.json is written here, at install time, rather than shipped +// as a static file in the published package: a *shipped* nested +// package.json triggers some npm install-time reorganization that quietly +// merges vendor/'s own subfolders into this package's root and drops the +// file entirely (confirmed directly, by inspecting exactly what a real `npm +// install` of a real packed tarball produces vs. what plain `tar -x` of that +// same tarball contains - the tarball itself was correct, so this is npm's +// install step specifically, not a packaging mistake). Writing it fresh here +// avoids whatever triggers that. +// +// A published package's own devDependencies (used for local development - +// see this package's package.json) are never installed for a consumer at +// all, so this can't just be `asciidoctor`/`asciidoctor-pdf` deps declared +// there instead - nothing would ever install them for anyone but a +// contributor working on this package directly. + +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const vendorDir = path.join(__dirname, '../vendor') +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' + +fs.writeFileSync( + path.join(vendorDir, 'package.json'), + JSON.stringify( + { + name: 'pdf-generator-vendor', + version: '1.0.0', + private: true, + dependencies: { + asciidoctor: '4.1.0', + 'asciidoctor-pdf': '1.0.2', + }, + }, + null, + 2 + ) + '\n' +) + +const result = spawnSync(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund'], { + cwd: vendorDir, + stdio: 'inherit', +}) + +if (result.error || result.status !== 0) { + console.error('@neo4j-antora/pdf-generator: failed to install its isolated asciidoctor-pdf renderer (see vendor/package.json)') + process.exit(result.status || 1) +} diff --git a/pdf-generator/vendor/extensions/macros-adapter.js b/pdf-generator/vendor/extensions/macros-adapter.js new file mode 100644 index 00000000..eb64fef2 --- /dev/null +++ b/pdf-generator/vendor/extensions/macros-adapter.js @@ -0,0 +1,64 @@ +'use strict' + +// @neo4j-documentation/macros' `label:x[]` inline macro reads +// `attr.$positional` (pre-4.x Asciidoctor.js's Opal-bridge attribute +// wrapper) to get the macro's positional text argument - removed in +// Asciidoctor.js 4.x, which instead hands a plain object with 1-indexed +// string keys (e.g. `{"1": "Custom text"}`). Without a fix, `attr.$positional` +// is just `undefined`, so any custom inline label text (e.g. +// `label:new[Custom text]`) is silently dropped in favour of the default +// display text - it doesn't throw, so this is easy to miss. +// +// Rather than patch the vendored file with patch-package (its postinstall +// hook isn't guaranteed to run once this package is itself a nested +// dependency of a docset's install - npm doesn't run a dependency's own +// lifecycle scripts by default), this wraps `registry.inlineMacro` so any +// attrs object handed to a macro's `process` callback gets `$positional` +// backfilled from those numeric keys, matching the shape macros.js expects. +// +// Must intercept via a Proxy rather than binding/replacing `self.process` +// directly - the installed Asciidoctor.js engine here is a compiled/WASM +// binding, and a `.bind()`'d reference to its native `process` method +// silently never invokes the callback it's given, even though calling it +// unbound (`target.process(fn)`) works correctly. Verified directly against +// the unpatched package and the installed engine. +const macros = require('@neo4j-documentation/macros') + +function backfillPositionalAttrs (attrs) { + if (!attrs || attrs.$positional) return + const positional = [] + let i = 1 + while (attrs[String(i)] !== undefined) { + positional.push(attrs[String(i)]) + i++ + } + if (positional.length) attrs.$positional = positional +} + +function shimOpalPositionalAttrs (registry) { + const original = registry.inlineMacro.bind(registry) + registry.inlineMacro = function (name, fn) { + return original(name, function (...outerArgs) { + const target = this + const selfProxy = new Proxy(target, { + get (t, prop, receiver) { + if (prop === 'process') { + return function (processFn) { + return t.process(function (parent, macroTarget, attrs) { + backfillPositionalAttrs(attrs) + return processFn(parent, macroTarget, attrs) + }) + } + } + return Reflect.get(t, prop, receiver) + }, + }) + return fn.apply(selfProxy, outerArgs) + }) + } +} + +module.exports.register = function (registry, context) { + shimOpalPositionalAttrs(registry) + macros.register(registry, context) +} diff --git a/pdf-generator/vendor/extensions/remote-include-adapter.js b/pdf-generator/vendor/extensions/remote-include-adapter.js new file mode 100644 index 00000000..86155142 --- /dev/null +++ b/pdf-generator/vendor/extensions/remote-include-adapter.js @@ -0,0 +1,43 @@ +'use strict' + +// @neo4j-documentation/remote-include exports its registration function +// directly (`module.exports = function () { this.includeProcessor(...) }`), +// which is what Antora's own extension loader expects, but not what +// asciidoctor-web-pdf's --extension loader does (it requires `lib.register` +// to be a function - see requireLibrary/_prepareExtensions in +// asciidoctor/lib/cli.js). This bridges the two conventions. +// +// It also works around a second, unrelated problem: the package's own +// includeProcessor body calls the pre-4.x Asciidoctor.js Opal-bridge API +// (`this.$option(...)`), removed in 4.x (now `this.option(...)`) - see +// macros-adapter.js for the same class of bug in a different package. +// Rather than patch the vendored file with patch-package (its postinstall +// hook isn't guaranteed to run once this package is itself a nested +// dependency of a docset's install - npm doesn't run a dependency's own +// lifecycle scripts by default), this wraps `registry.includeProcessor` +// so the callback's `this` transparently answers `$option` calls by +// forwarding to the real `option` method. Verified directly against the +// unpatched package and the installed Asciidoctor.js engine. + +const remoteInclude = require('@neo4j-documentation/remote-include') + +function shimOpalOptionApi (registry) { + const original = registry.includeProcessor.bind(registry) + registry.includeProcessor = function (fn) { + return original(function (...args) { + const target = this + const self = new Proxy(target, { + get (t, prop, receiver) { + if (prop === '$option') return t.option.bind(t) + return Reflect.get(t, prop, receiver) + }, + }) + return fn.apply(self, args) + }) + } +} + +module.exports.register = function (registry) { + shimOpalOptionApi(registry) + remoteInclude.call(registry) +} diff --git a/pdf-generator/vendor/extensions/roles-labels-postprocessor.js b/pdf-generator/vendor/extensions/roles-labels-postprocessor.js new file mode 100644 index 00000000..d7b2f7a3 --- /dev/null +++ b/pdf-generator/vendor/extensions/roles-labels-postprocessor.js @@ -0,0 +1,70 @@ +'use strict' + +// Reuses @neo4j-antora/roles-labels' own shared core (extensions/antora/ +// roles-labels/lib/process-labels.js in docs-tools) so PDF labels are +// produced by the exact same logic as the real HTML site - synonym +// resolution, version/product-suffix stripping, dataset attributes, inline +// vs role handling included - rather than a hand-ported subset that can +// silently drift from it (see docs-tools/pdf/README). +// +// roles-labels itself can't be *registered* here - it hooks Antora's own +// `pagesComposed` event and operates on files in an Antora ContentCatalog, +// which never exist in this pipeline: asciidoctor-web-pdf runs a completely +// separate, isolated Asciidoctor conversion on the assembler's merged .adoc +// text, so roles-labels never gets a chance to see - let alone transform - +// this content. This is a Postprocessor instead (Asciidoctor's own extension +// point, run after conversion to HTML, before the PDF renderer sees it) that +// calls the same shared `processLabels` function roles-labels.js calls. + +const { parse: parseHTML } = require('node-html-parser') +// Must come from the same package identity the running engine (loaded via +// `asciidoctor`, not `@asciidoctor/core` directly, by asciidoctor-web-pdf/ +// the CLI) uses internally - requiring the class from a different copy of +// the module fails Registry's own instanceof-style check with "Invalid type +// for postprocessor extension" even though it's structurally identical. +const { Postprocessor } = require('asciidoctor') +const { processLabels } = require('@neo4j-antora/roles-labels/lib/process-labels') + +// Minimal shim matching the { info(meta, msg, ...args), warn(...), debug(...) } +// shape processLabels expects from Antora's pino-based logger - this pipeline +// has no Antora logger to reuse. +function makeLogger () { + const log = (level) => (meta, msg, ...args) => { + const consoleMethod = level === 'debug' ? 'log' : level + // eslint-disable-next-line no-console + console[consoleMethod](`[roles-labels] ${msg}`.replace(/%s/g, () => args.shift()), meta) + } + return { info: log('info'), warn: log('warn'), error: log('error'), debug: log('debug') } +} + +const logger = makeLogger() + +class RolesLabelsPostprocessor extends Postprocessor { + process (document, output) { + if (!output.includes('label--')) return output + const root = parseHTML(output) + processLabels(root, { + src: { path: 'pdf-export' }, + attributes: document.getAttributes(), + logger, + defaultLogLevel: 'info', + replaceInlineLabelText: false, + // Plain Asciidoctor HTML5 output (used here) never has Antora's + // `article.doc` wrapper - falls back to the parsed root itself, since + // there's no better place to hang page-wide dataset attributes. + docRootSelector: 'article.doc', + // @antora/assembler marks *every* merged page's heading `discrete` to + // flatten section nesting/IDs across the book - unlike on a real + // Antora page, that's never a signal the author marked this heading as + // a non-section (see process-labels.js's own comment on this option), + // so a role on it must still become a label, exactly as it would in + // the HTML this page was built from. + skipDiscrete: false, + }) + return root.toString() + } +} + +module.exports.register = function (registry) { + registry.postprocessor(RolesLabelsPostprocessor) +} diff --git a/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js b/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js new file mode 100644 index 00000000..ff7b7561 --- /dev/null +++ b/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js @@ -0,0 +1,74 @@ +'use strict' + +// Ports @neo4j-antora/table-footnotes for the PDF pipeline: moving a table's +// own footnotes out of Asciidoctor's single document-wide #footnotes div and +// into a row on that specific table, instead of leaving them +// dumped at the very end of the whole document, disconnected from the table +// they came from. +// +// Same reason this can't just reuse table-footnotes.js directly as roles- +// labels-postprocessor.js: it hooks Antora's own `pagesComposed` event and +// operates on files in an Antora ContentCatalog, neither of which exist in +// this pipeline (see docs-tools/pdf/README) - asciidoctor-web-pdf runs a +// separate, isolated Asciidoctor conversion on the assembler's merged .adoc +// text that table-footnotes never gets a chance to see. This is a +// Postprocessor instead (Asciidoctor's own extension point, run after +// conversion to HTML), with the same DOM manipulation ported over near +// verbatim - it's pure HTML restructuring, no Antora-specific data needed. + +const { parse: parseHTML } = require('node-html-parser') +// Must come from the same package identity the running engine uses +// internally, not a separately-resolved copy - see roles-labels- +// postprocessor.js for why requiring the class from the wrong copy of the +// module fails Registry's own type check. +const { Postprocessor } = require('asciidoctor') + +function createElement (el, className = '') { + return parseHTML(`<${el}${className ? ` class="${className}"` : ''}>`) +} + +class TableFootnotesPostprocessor extends Postprocessor { + process (_document, output) { + if (!output.includes('id="footnotes"')) return output + const root = parseHTML(output) + const footnotesDiv = root.getElementById('footnotes') + const tables = root.querySelectorAll('table') + if (!footnotesDiv || tables.length === 0) return output + + tables.forEach((table) => { + const tableFootnotes = table.querySelectorAll('tbody a.footnote') + if (tableFootnotes.length === 0) return + + const cols = table.querySelectorAll('colgroup col').length + const tFoot = createElement('tfoot') + const footnoteRow = createElement('tr') + tFoot.firstElementChild.appendChild(footnoteRow) + const footnoteCell = createElement('td', 'tableblock footnote-cell') + footnoteCell.firstElementChild.setAttribute('colspan', cols) + + // For each footnote reference in this table, find the matching + // footnote definition (by id, from its href) in the document-wide + // footnotes div, and move it into this table's own footer. + tableFootnotes.forEach((footnote) => { + const footnoteId = footnote.getAttribute('href').replace('#', '') + const matchingFootnote = footnotesDiv.querySelector(`#${footnoteId}`) + if (!matchingFootnote) return + footnoteCell.firstElementChild.appendChild(matchingFootnote) + }) + + footnoteRow.firstElementChild.appendChild(footnoteCell) + table.appendChild(tFoot) + }) + + // Remove the document-wide footnotes div if every footnote in it ended + // up moved into a table footer. + if (footnotesDiv.querySelectorAll('div.footnote').length === 0) { + footnotesDiv.remove() + } + return root.toString() + } +} + +module.exports.register = function (registry) { + registry.postprocessor(TableFootnotesPostprocessor) +} From 3bc0cb7784596ac5c890e312d16aad6368e6ca14 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Wed, 23 Sep 2026 17:21:47 +0100 Subject: [PATCH 02/21] Drop the dedicated pdf.yml - reuse an existing playbook via --extension @neo4j-antora/pdf-generator is a real, self-registering Antora extension, so it doesn't need its own playbook file at all: passing it as a one-off `antora --extension @neo4j-antora/pdf-generator` CLI flag works against any playbook a docset already has. Simpler for docset authors (no new file to add or keep in sync) and avoids a subtle duplication - generating a PDF always regenerates the full HTML site alongside it in the same Antora run (assembler/pdf-extension hook into the normal site-generation pipeline rather than replacing it), so having a whole separate playbook for it was never buying independence anyway. - docs/pdf.yml removed; docs/package.json's build:pdf/verify:pdf scripts now run preview.yml with the --extension flag instead. - reusable-docs-pdf-build.yml's pdf-playbook input renamed to antora-playbook, defaulting to publish.yml (what a real PDF export should reflect) instead of a dedicated pdf.yml default. - Fixed a real bug this surfaced: the "find the PDF" step was hardcoded to build/site, which breaks for publish.yml (outputs to build/docs) - now searches build/ generally, excluding build/assembler/'s own intermediate copies specifically (which otherwise falsely match too). - docs-generate-pdf.yml (this repo's own self-test) explicitly passes antora-playbook: preview.yml, since docs/ is a synthetic fixture and preview.yml (not publish.yml) is what actually registers tabbed-nav and carries the test content this pipeline has been verified against. Deliberately keeping the PDF build as its own separate CI step rather than folding --extension into the real HTML publish workflow, even though that would avoid rebuilding the HTML twice - isolates the real site publish from PDF-pipeline failures while it's still young (footnotes are already a known-broken case). Re-verified locally: `npm run build:pdf` against this exact branch state (real npm install, prerelease package for now) produces correct PDFs for both versions in this repo's multi-version test fixture. --- .github/workflows/docs-generate-pdf.yml | 13 +++-- .github/workflows/reusable-docs-pdf-build.yml | 17 +++--- docs/package.json | 5 +- docs/pdf.yml | 57 ------------------- 4 files changed, 22 insertions(+), 70 deletions(-) delete mode 100644 docs/pdf.yml diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index 8705418e..58883fd6 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -4,15 +4,19 @@ permissions: contents: read # Mirrors docs-generate-html.yml's trigger shape (workflow_dispatch with a -# build-ref input). Deliberately simpler for now: PDF export always builds -# from pdf.yml regardless of environment (no publish-pdf.yml/dev-vs-prod -# split yet - see reusable-docs-pdf-build.yml's pdf-playbook input), and -# there's no publish hand-off step - it just uploads the PDF as a build +# build-ref input). Deliberately simpler for now: no dev-vs-prod split yet, +# and there's no publish hand-off step - it just uploads the PDF as a build # artifact until a real publish target exists. Uses a relative path (this # workflow lives in the same repo as reusable-docs-pdf-build.yml, testing it # against docs-tools' own docs/ as the fixture) rather than a versioned # `neo4j/docs-tools/...@v2`-style reference, the same way docs-generate- # html.yml does for reusable-docs-build.yml. +# +# Uses preview.yml rather than reusable-docs-pdf-build.yml's own +# publish.yml default - this repo's docs/ is a synthetic test fixture, and +# preview.yml (not publish.yml) is the one that actually registers +# tabbed-nav and carries the label/footnote/etc. test content this pipeline +# has been verified against. on: workflow_dispatch: inputs: @@ -30,3 +34,4 @@ jobs: docs-dir: 'docs' build-ref: ${{ inputs.build-ref || github.ref_name }} fetch-depth: 0 + antora-playbook: 'preview.yml' diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 54d780bd..78ac6329 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -32,11 +32,11 @@ on: required: false type: string default: '3.2.0' - pdf-playbook: - description: 'Antora playbook file, relative to docs-dir, that registers @neo4j-antora/pdf-generator - a small repo-specific file alongside preview.yml/publish.yml (content sources/attributes differ per docset, so not shared).' + antora-playbook: + description: 'Antora playbook file, relative to docs-dir, to build the PDF from - @neo4j-antora/pdf-generator is passed as a one-off --extension flag rather than needing its own dedicated playbook, so this is the same playbook already used for a normal build (defaults to publish.yml, matching what a real PDF export should reflect - override to preview.yml or another playbook if a docset wants draft content instead).' required: false type: string - default: 'pdf.yml' + default: 'publish.yml' retain-artifacts: description: 'The number of days to retain artifacts' type: number @@ -53,7 +53,7 @@ jobs: BUILD_REF: ${{ inputs.build-ref || '' }} FETCH_DEPTH: ${{ inputs.fetch-depth }} DOCS_DIR: ${{ inputs.docs-dir }} - PDF_PLAYBOOK: ${{ inputs.pdf-playbook }} + ANTORA_PLAYBOOK: ${{ inputs.antora-playbook }} steps: @@ -95,16 +95,19 @@ jobs: # so those extensions can still find hoisted deps (e.g. vinyl) that # are only installed under docs-dir's node_modules. NODE_PATH: ${{ github.workspace }}/${{ env.DOCS_DIR }}/node_modules - run: npx antora "$PDF_PLAYBOOK" --stacktrace --log-format=pretty + run: npx antora "$ANTORA_PLAYBOOK" --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty - name: Get the dir that contains the PDF if: steps.run-pdf-build.outcome == 'success' working-directory: ${{ env.DOCS_DIR }} id: get-pdf-path run: | - pdf_path=$(find build/site -iname '*.pdf' | head -1) + # Excludes build/assembler/ - the assembler's own intermediate working + # copy, not the real deliverable - since output.dir (where the real + # one lands) varies by playbook (e.g. build/site vs build/docs). + pdf_path=$(find build -path '*/assembler/*' -prune -o -iname '*.pdf' -print | head -1) if [ -z "$pdf_path" ]; then - echo "::error::No PDF found under build/site - check the Antora log above" + echo "::error::No PDF found under build/ - check the Antora log above" exit 1 fi echo "pdf-path=$pdf_path" >> "$GITHUB_OUTPUT" diff --git a/docs/package.json b/docs/package.json index 573fd2fe..b6a11c1f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,9 +13,10 @@ "postbuild": "node server.js", "build:preview": "antora preview.yml --stacktrace --log-format=pretty", "build:publish": "npm run clean && antora publish.yml --stacktrace --log-format=pretty", - "build:pdf": "antora pdf.yml --stacktrace --log-format=pretty", + "build:pdf": "antora preview.yml --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty", "verify:preview": "antora --stacktrace --fetch preview.yml --log-format=json --log-level=info --log-file ./build/log/log.json", - "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json" + "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json", + "verify:pdf": "antora --stacktrace --fetch preview.yml --extension @neo4j-antora/pdf-generator --log-format=json --log-level=info --log-file ./build/log/log.json" }, "keywords": [ "antora", diff --git a/docs/pdf.yml b/docs/pdf.yml deleted file mode 100644 index 03b1e8f9..00000000 --- a/docs/pdf.yml +++ /dev/null @@ -1,57 +0,0 @@ -site: - title: Docs Tools - start_page: docs-tools:ROOT:index.adoc - url: https://neo4j.com/docs/ - -content: - sources: - - url: ../ - start_path: docs - branches: [ 'HEAD' ] - -ui: - bundle: - url: https://static-content.neo4j.com/build/ui-bundle.zip - snapshot: true - -urls: - html_extension_style: indexify - -output: - dir: ./build/site - -antora: - extensions: - - require: "../extensions/antora/tabbed-nav" - - require: '@neo4j-antora/pdf-generator' - -asciidoc: - attributes: - # tabs - page-tabs: tools@ - page-tabs-index: 100 - # page-attributes are used by the ui-bundle and by extensions - page-theme: docs - page-type: Docs - page-search-type: Docs - page-search-site: Reference Docs - page-canonical-root: /docs - page-terms-to-mark: Neo4j, Cypher, test term - page-pagination: true - page-no-canonical: true - page-origin-private: true # change to false to display 'Raise an issue' links - page-hide-toc: false - page-mixpanel: 4bfb2414ab973c741b6f067bf06d5575 - # legacy attributes - do not change these - includePDF: false - nonhtmloutput: "" - experimental: '' - # update the copyright value with the first commit in a new year - copyright: 2026 - # icon attributes - check-mark: icon:check[] - cross-mark: icon:times[] - # neo4j.com attributes. Always use when linking to neo4j.com URLs - neo4j-base-uri: https://neo4j.com - neo4j-docs-base-uri: '{neo4j-base-uri}/docs' - common-license-page-uri: '{neo4j-docs-base-uri}/license' From 091ae0d2116e02803c0708a0b88701bb42abbf8b Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Wed, 23 Sep 2026 17:24:17 +0100 Subject: [PATCH 03/21] Use publish.yml consistently for PDF generation, not preview.yml Matches the convention already used when discussing this elsewhere (docs-template's own build:pdf script) - a PDF export should reflect published content, and this repo's own self-test docset doesn't need a special case: the label/footnote test content lives in the same source pages either playbook builds from, so publish.yml already exercises it. Removes the now-redundant explicit antora-playbook override in docs-generate-pdf.yml (matches reusable-docs-pdf-build.yml's own default). Re-verified locally against publish.yml: correct PDF output, and the PDF-path lookup correctly finds it under build/docs/ (publish.yml's own output.dir, different from preview.yml's build/site). --- .github/workflows/docs-generate-pdf.yml | 7 ------- docs/package.json | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index 58883fd6..0719fa5e 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -11,12 +11,6 @@ permissions: # against docs-tools' own docs/ as the fixture) rather than a versioned # `neo4j/docs-tools/...@v2`-style reference, the same way docs-generate- # html.yml does for reusable-docs-build.yml. -# -# Uses preview.yml rather than reusable-docs-pdf-build.yml's own -# publish.yml default - this repo's docs/ is a synthetic test fixture, and -# preview.yml (not publish.yml) is the one that actually registers -# tabbed-nav and carries the label/footnote/etc. test content this pipeline -# has been verified against. on: workflow_dispatch: inputs: @@ -34,4 +28,3 @@ jobs: docs-dir: 'docs' build-ref: ${{ inputs.build-ref || github.ref_name }} fetch-depth: 0 - antora-playbook: 'preview.yml' diff --git a/docs/package.json b/docs/package.json index b6a11c1f..6234d086 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,10 +13,10 @@ "postbuild": "node server.js", "build:preview": "antora preview.yml --stacktrace --log-format=pretty", "build:publish": "npm run clean && antora publish.yml --stacktrace --log-format=pretty", - "build:pdf": "antora preview.yml --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty", + "build:pdf": "antora publish.yml --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty", "verify:preview": "antora --stacktrace --fetch preview.yml --log-format=json --log-level=info --log-file ./build/log/log.json", "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json", - "verify:pdf": "antora --stacktrace --fetch preview.yml --extension @neo4j-antora/pdf-generator --log-format=json --log-level=info --log-file ./build/log/log.json" + "verify:pdf": "antora --stacktrace --fetch publish.yml --extension @neo4j-antora/pdf-generator --log-format=json --log-level=info --log-file ./build/log/log.json" }, "keywords": [ "antora", From fdfbd591bad2bac05ea3825709a8b9ec60094a44 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 11:56:03 +0100 Subject: [PATCH 04/21] Mirror reusable-docs-build.yml's package-script convention reusable-docs-pdf-build.yml no longer takes an antora-playbook input at all - it takes package-script (default verify:publish), same convention and same trust model as reusable-docs-build.yml's own input: the docset is trusted to already have a valid script there. @neo4j-antora/pdf-generator is appended as a one-off `--extension` flag via `npm run "$PACKAGE_SCRIPT" -- --extension ...`, the same mechanism reusable-docs-build.yml already uses to inject @neo4j-antora/tabbed-nav onto verify:publish. This means a docset needs no dedicated PDF playbook *or* package.json script at all - it just reuses whatever it already has. Removed docs/package.json's now-redundant build:pdf/verify:pdf scripts accordingly, and updated pdf-generator's own README to document the --extension flag as the primary usage pattern (the declarative playbook `require:` form still works and is documented as an alternative). Considered folding this directly into reusable-docs-build.yml itself (since checkout/install/antora-version/package-script are all already identical, and the only real gap is finding+uploading the PDF artifact) but decided against it - reusable-docs-build.yml is used across many other repos already, and keeping PDF generation as its own separate, isolated workflow avoids adding untested complexity to shared, widely-used infrastructure while this pipeline is still young. Re-verified locally: `npm run verify:publish -- --extension @neo4j-antora/pdf-generator` (the exact command the workflow now runs) produces a clean log (zero warn/error/fatal) and a correct PDF. --- .github/workflows/reusable-docs-pdf-build.yml | 18 +++++++---- docs/package.json | 6 ++-- pdf-generator/README.adoc | 30 +++++++++++++++---- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 78ac6329..674d7cbf 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -32,11 +32,11 @@ on: required: false type: string default: '3.2.0' - antora-playbook: - description: 'Antora playbook file, relative to docs-dir, to build the PDF from - @neo4j-antora/pdf-generator is passed as a one-off --extension flag rather than needing its own dedicated playbook, so this is the same playbook already used for a normal build (defaults to publish.yml, matching what a real PDF export should reflect - override to preview.yml or another playbook if a docset wants draft content instead).' + package-script: + description: 'The name of the script to run in package.json, same convention as reusable-docs-build.yml''s own package-script input - the docset is trusted to already have a valid script here (defaults to verify:publish, matching what a real PDF export should reflect). @neo4j-antora/pdf-generator is injected as a one-off --extension flag onto whatever that script already does, so it needs no dedicated PDF playbook or script of its own.' required: false type: string - default: 'publish.yml' + default: 'verify:publish' retain-artifacts: description: 'The number of days to retain artifacts' type: number @@ -53,7 +53,7 @@ jobs: BUILD_REF: ${{ inputs.build-ref || '' }} FETCH_DEPTH: ${{ inputs.fetch-depth }} DOCS_DIR: ${{ inputs.docs-dir }} - ANTORA_PLAYBOOK: ${{ inputs.antora-playbook }} + PACKAGE_SCRIPT: ${{ inputs.package-script }} steps: @@ -83,7 +83,13 @@ jobs: npm uninstall @antora/cli @antora/site-generator-default || true npm install "antora@${ANTORA_VERSION}" - - name: Run Antora PDF build + # Reuses whichever script the docset already runs for a normal build + # (default verify:publish) rather than needing its own dedicated PDF + # script or playbook - @neo4j-antora/pdf-generator is appended as a + # trailing CLI flag, the same way reusable-docs-build.yml appends + # --extension @neo4j-antora/tabbed-nav onto $PACKAGE_SCRIPT via `npm run + # ... --`, rather than the docset's own script needing to know about it. + - name: Run PDF build id: run-pdf-build working-directory: ${{ env.DOCS_DIR }} continue-on-error: true @@ -95,7 +101,7 @@ jobs: # so those extensions can still find hoisted deps (e.g. vinyl) that # are only installed under docs-dir's node_modules. NODE_PATH: ${{ github.workspace }}/${{ env.DOCS_DIR }}/node_modules - run: npx antora "$ANTORA_PLAYBOOK" --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty + run: npm run "$PACKAGE_SCRIPT" -- --extension @neo4j-antora/pdf-generator - name: Get the dir that contains the PDF if: steps.run-pdf-build.outcome == 'success' diff --git a/docs/package.json b/docs/package.json index 6234d086..5bf5db5d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,10 +13,8 @@ "postbuild": "node server.js", "build:preview": "antora preview.yml --stacktrace --log-format=pretty", "build:publish": "npm run clean && antora publish.yml --stacktrace --log-format=pretty", - "build:pdf": "antora publish.yml --extension @neo4j-antora/pdf-generator --stacktrace --log-format=pretty", "verify:preview": "antora --stacktrace --fetch preview.yml --log-format=json --log-level=info --log-file ./build/log/log.json", - "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json", - "verify:pdf": "antora --stacktrace --fetch publish.yml --extension @neo4j-antora/pdf-generator --log-format=json --log-level=info --log-file ./build/log/log.json" + "verify:publish": "antora --stacktrace --fetch publish.yml --log-format=json --log-level=info --log-file ./build/log/log.json" }, "keywords": [ "antora", @@ -35,7 +33,7 @@ "@neo4j-antora/xref-hash-validator": "^0.1.4", "@neo4j-documentation/macros": "^1.0.4", "@neo4j-documentation/remote-include": "^1.0.0", - "antora": "3.1.14", + "antora": "^3.2.0", "node-html-parser": "9.0.0" }, "devDependencies": { diff --git a/pdf-generator/README.adoc b/pdf-generator/README.adoc index d693022b..9b95854c 100644 --- a/pdf-generator/README.adoc +++ b/pdf-generator/README.adoc @@ -15,19 +15,37 @@ itself), so `npm install @neo4j-antora/pdf-generator` is the entire setup. == Usage -A docset needs its own small playbook (like `preview.yml`/`publish.yml`, -since content sources/attributes differ per docset - not shared here) that -registers `@antora/pdf-extension` with this package's assembler config: +This package self-registers as a real Antora extension (it wraps +`@antora/pdf-extension`, pre-wired with its own assembler config as the +default - see `extension.js`), so it needs no dedicated playbook or +`package.json` script of its own. Pass it as a one-off `--extension` flag +onto whatever playbook/script a docset already uses for a normal build: + +[source,shell] +---- +antora publish.yml --extension @neo4j-antora/pdf-generator +# or, reusing an existing package.json script: +npm run verify:publish -- --extension @neo4j-antora/pdf-generator +---- + +Generating a PDF this way always regenerates the full HTML site alongside +it in the same Antora run (`@antora/assembler`/`@antora/pdf-extension` hook +into normal site generation rather than replacing it) - there's no way to +get "just the PDF" from a single run. + +A docset can still register it declaratively in a playbook instead, if +preferred: [source,yaml] ---- antora: extensions: - - require: '@antora/pdf-extension' - config_file: './node_modules/@neo4j-antora/pdf-generator/antora-assembler-pdf.yml' + - require: '@neo4j-antora/pdf-generator' ---- -and a `package.json` script to run it, e.g. `"pdf": "antora pdf.yml"`. +Either form picks up this package's own `antora-assembler-pdf.yml` as the +default `configFile` - pass a `config:` block in the playbook form to +override individual assembler settings if needed. == Layout From 091e0be2097ce103edbd841cc07d52bae3cc0616 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 12:25:47 +0100 Subject: [PATCH 05/21] Pass the real default Antora extensions to the PDF build, not just pdf-generator package-script's own command (e.g. verify:publish) has no extensions baked in itself - reusable-docs-build.yml supplies them as CLI flags at call time, so calling that same script directly here was silently running the HTML regenerated alongside the PDF (same Antora run, see README) without roles-labels, xref-hash-validator, aliases-redirects, etc. at all. Adds antora-extensions (defaulting to the same list reusable-docs-build.yml uses, plus @neo4j-antora/pdf-generator itself) and antora-extensions-exclude, mirroring reusable-docs-build.yml's own inputs and its "Remove excluded extensions" step verbatim - accepted as a deliberate near-duplicate for now rather than trying to share the list across both workflows, which would need either modifying the widely-used reusable-docs-build.yml or a more complex nested-workflow- call restructuring; not worth the risk/complexity for this. docs-generate-pdf.yml (this repo's own self-test) now passes antora-extensions-exclude for the three extensions this repo's own docs/package.json doesn't actually install (antora-modify-sitemaps, antora-page-list, antora-unlisted-pages) - discovered because running the real default list locally throws Cannot find module for exactly these three; same reason reusable-docs-build.yml's own exclude input exists; no docset installs every extension in the full list. Re-verified locally with the exact extension list and exclusions this workflow now produces: clean log (only expected info-level messages from roles-labels/table-footnotes/aliases-redirects/selector-labels actually running) and a correct PDF. --- .github/workflows/docs-generate-pdf.yml | 5 ++ .github/workflows/reusable-docs-pdf-build.yml | 55 +++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index 0719fa5e..d9920ac2 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -28,3 +28,8 @@ jobs: docs-dir: 'docs' build-ref: ${{ inputs.build-ref || github.ref_name }} fetch-depth: 0 + # This repo's own docs/package.json doesn't install every extension in + # reusable-docs-pdf-build.yml's antora-extensions default list - same + # reason reusable-docs-build.yml has this same input: no docset + # installs every one of them. + antora-extensions-exclude: '@neo4j-antora/antora-modify-sitemaps @neo4j-antora/antora-page-list @neo4j-antora/antora-unlisted-pages' diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 674d7cbf..764cc31a 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -37,6 +37,26 @@ on: required: false type: string default: 'verify:publish' + antora-extensions: + description: 'Antora extensions to pass to the build script. Defaults to the same list reusable-docs-build.yml uses for a real publish build, plus @neo4j-antora/pdf-generator itself - package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' + required: false + type: string + default: ' + @neo4j-antora/aliases-redirects + @neo4j-antora/antora-modify-sitemaps + @neo4j-antora/antora-page-list + @neo4j-antora/antora-unlisted-pages + @neo4j-antora/roles-labels + @neo4j-antora/selector-labels + @neo4j-antora/table-footnotes + @neo4j-antora/xref-hash-validator + @neo4j-antora/pdf-generator + ' + antora-extensions-exclude: + description: 'Antora extensions to NOT pass to the build script, same convention as reusable-docs-build.yml''s own input of the same name - not every docset installs every extension in the antora-extensions default list.' + required: false + type: string + default: 'na' retain-artifacts: description: 'The number of days to retain artifacts' type: number @@ -54,6 +74,8 @@ jobs: FETCH_DEPTH: ${{ inputs.fetch-depth }} DOCS_DIR: ${{ inputs.docs-dir }} PACKAGE_SCRIPT: ${{ inputs.package-script }} + ANTORA_EXTENSIONS: ${{ inputs.antora-extensions }} + ANTORA_EXTENSIONS_EXCLUDE: ${{ inputs.antora-extensions-exclude }} steps: @@ -83,12 +105,35 @@ jobs: npm uninstall @antora/cli @antora/site-generator-default || true npm install "antora@${ANTORA_VERSION}" + # Mirrors reusable-docs-build.yml's own "Remove excluded extensions" + + # "Create list of extensions for the Antora CLI" steps - not every + # docset installs every extension in the antora-extensions default list, + # so antora-extensions-exclude lets a caller drop the ones it doesn't + # have before turning the rest into --extension X --extension Y ... flags. + - name: Remove excluded extensions + run: | + for extension in $ANTORA_EXTENSIONS; do + if [[ $ANTORA_EXTENSIONS_EXCLUDE =~ "$extension" ]]; then + continue + fi + filtered_extensions=" ${extension} ${filtered_extensions}" + done + echo "ANTORA_FILTERED_EXTENSIONS=${filtered_extensions}" >> $GITHUB_ENV + + - name: Create list of extensions for the Antora CLI + run: | + for extension in $ANTORA_FILTERED_EXTENSIONS; do + antora_cli_extensions="--extension ${extension} ${antora_cli_extensions}" + done + echo "ANTORA_CLI_EXTENSIONS=${antora_cli_extensions}" >> $GITHUB_ENV + # Reuses whichever script the docset already runs for a normal build # (default verify:publish) rather than needing its own dedicated PDF - # script or playbook - @neo4j-antora/pdf-generator is appended as a - # trailing CLI flag, the same way reusable-docs-build.yml appends - # --extension @neo4j-antora/tabbed-nav onto $PACKAGE_SCRIPT via `npm run - # ... --`, rather than the docset's own script needing to know about it. + # script or playbook - the extension list above (which includes + # @neo4j-antora/pdf-generator itself) is appended as trailing CLI flags, + # the same way reusable-docs-build.yml appends its own extensions onto + # $PACKAGE_SCRIPT via `npm run ... --`, rather than the docset's own + # script needing to know about any of them. - name: Run PDF build id: run-pdf-build working-directory: ${{ env.DOCS_DIR }} @@ -101,7 +146,7 @@ jobs: # so those extensions can still find hoisted deps (e.g. vinyl) that # are only installed under docs-dir's node_modules. NODE_PATH: ${{ github.workspace }}/${{ env.DOCS_DIR }}/node_modules - run: npm run "$PACKAGE_SCRIPT" -- --extension @neo4j-antora/pdf-generator + run: npm run "$PACKAGE_SCRIPT" -- $ANTORA_CLI_EXTENSIONS - name: Get the dir that contains the PDF if: steps.run-pdf-build.outcome == 'success' From 6b2b4cfc7b445ef916711f5dcad4707ed65ed0e2 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 12:26:20 +0100 Subject: [PATCH 06/21] Temporarily trigger on push for CI verification (revert after) --- .github/workflows/docs-generate-pdf.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index d9920ac2..dde02d6d 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -18,6 +18,9 @@ on: description: 'The git ref to build from' type: string required: true + push: + branches: + - pdf-generator-package-clean jobs: From e49c8010fe2f9f8582479f6ca7d327d33c2ebe10 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 12:27:02 +0100 Subject: [PATCH 07/21] Temporarily point at published RC for CI verification (revert after) --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 5bf5db5d..12411b1c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -26,7 +26,7 @@ "@neo4j-antora/aliases-redirects": "^0.2.7", "@neo4j-antora/antora-add-notes": "^0.3.2", "@neo4j-antora/mark-terms": "^1.1.4", - "@neo4j-antora/pdf-generator": "0.1.0", + "@neo4j-antora/pdf-generator": "0.1.0-rc.2", "@neo4j-antora/roles-labels": "^0.1.8", "@neo4j-antora/selector-labels": "^0.1.1", "@neo4j-antora/table-footnotes": "^1.0.1", From 10da5c1efabe92dd7166c544fd513ce760cd0333 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 12:28:31 +0100 Subject: [PATCH 08/21] Revert temporary CI-verification changes (push trigger + RC pin), verification done --- .github/workflows/docs-generate-pdf.yml | 3 --- docs/package.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index dde02d6d..d9920ac2 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -18,9 +18,6 @@ on: description: 'The git ref to build from' type: string required: true - push: - branches: - - pdf-generator-package-clean jobs: diff --git a/docs/package.json b/docs/package.json index 12411b1c..5bf5db5d 100644 --- a/docs/package.json +++ b/docs/package.json @@ -26,7 +26,7 @@ "@neo4j-antora/aliases-redirects": "^0.2.7", "@neo4j-antora/antora-add-notes": "^0.3.2", "@neo4j-antora/mark-terms": "^1.1.4", - "@neo4j-antora/pdf-generator": "0.1.0-rc.2", + "@neo4j-antora/pdf-generator": "0.1.0", "@neo4j-antora/roles-labels": "^0.1.8", "@neo4j-antora/selector-labels": "^0.1.1", "@neo4j-antora/table-footnotes": "^1.0.1", From 2f672dd852ade76a936763785279f2a20227cfc1 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 13:36:04 +0100 Subject: [PATCH 09/21] Decouple pdf-generator install from docs/package.json dependencies @neo4j-antora/pdf-generator was listed in docs/package.json's regular dependencies, which made every npm install against docs/ (including the unrelated HTML PR-check build) require it to resolve. Remove it from package.json and have reusable-docs-pdf-build.yml install it directly via a new pdf-generator-version input, the same way it already force-installs a specific Antora version. --- .github/workflows/reusable-docs-pdf-build.yml | 21 ++++++++++++++----- docs/package.json | 1 - pdf-generator/README.adoc | 18 ++++++++++------ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 764cc31a..ff4d108c 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -32,6 +32,11 @@ on: required: false type: string default: '3.2.0' + pdf-generator-version: + description: 'The @neo4j-antora/pdf-generator version to install. Not a dependency of the docset''s own package.json - it is only ever needed for this PDF-specific build, so declaring it there would make *every* install in the repo (including totally unrelated jobs, e.g. a plain HTML PR check) fail whenever this version is not yet published, the same way reusable-docs-build.yml installs a specific Antora version itself rather than trusting whatever a docset''s own package.json happens to pin.' + required: false + type: string + default: '0.1.0' package-script: description: 'The name of the script to run in package.json, same convention as reusable-docs-build.yml''s own package-script input - the docset is trusted to already have a valid script here (defaults to verify:publish, matching what a real PDF export should reflect). @neo4j-antora/pdf-generator is injected as a one-off --extension flag onto whatever that script already does, so it needs no dedicated PDF playbook or script of its own.' required: false @@ -73,6 +78,7 @@ jobs: BUILD_REF: ${{ inputs.build-ref || '' }} FETCH_DEPTH: ${{ inputs.fetch-depth }} DOCS_DIR: ${{ inputs.docs-dir }} + PDF_GENERATOR_VERSION: ${{ inputs.pdf-generator-version }} PACKAGE_SCRIPT: ${{ inputs.package-script }} ANTORA_EXTENSIONS: ${{ inputs.antora-extensions }} ANTORA_EXTENSIONS_EXCLUDE: ${{ inputs.antora-extensions-exclude }} @@ -89,11 +95,6 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - # @neo4j-antora/pdf-generator is a normal npm dependency the docset's own - # package.json declares (see its own README) - it brings in - # @antora/pdf-extension, the PDF theme/renderer/postprocessors, and its - # own isolated Asciidoctor.js install (via its own postinstall) all by - # itself. Nothing to check out or path-patch here any more. - name: Install docset dependencies working-directory: ${{ env.DOCS_DIR }} run: | @@ -104,6 +105,16 @@ jobs: # currently pins. npm uninstall @antora/cli @antora/site-generator-default || true npm install "antora@${ANTORA_VERSION}" + # @neo4j-antora/pdf-generator is installed here, on its own, rather than + # being declared in the docset's own package.json - it's only ever + # needed for this PDF-specific build, and a docset's package.json is + # shared by every job that runs npm install against it (e.g. the plain + # HTML PR-check build), so declaring it there would make all of those + # unrelated jobs fail whenever this version isn't published yet. It + # brings in @antora/pdf-extension, the PDF theme/renderer/ + # postprocessors, and its own isolated Asciidoctor.js install (via its + # own postinstall) all by itself. + npm install "@neo4j-antora/pdf-generator@${PDF_GENERATOR_VERSION}" # Mirrors reusable-docs-build.yml's own "Remove excluded extensions" + # "Create list of extensions for the Antora CLI" steps - not every diff --git a/docs/package.json b/docs/package.json index 5bf5db5d..a8ebff21 100644 --- a/docs/package.json +++ b/docs/package.json @@ -26,7 +26,6 @@ "@neo4j-antora/aliases-redirects": "^0.2.7", "@neo4j-antora/antora-add-notes": "^0.3.2", "@neo4j-antora/mark-terms": "^1.1.4", - "@neo4j-antora/pdf-generator": "0.1.0", "@neo4j-antora/roles-labels": "^0.1.8", "@neo4j-antora/selector-labels": "^0.1.1", "@neo4j-antora/table-footnotes": "^1.0.1", diff --git a/pdf-generator/README.adoc b/pdf-generator/README.adoc index 9b95854c..a7b5060b 100644 --- a/pdf-generator/README.adoc +++ b/pdf-generator/README.adoc @@ -6,12 +6,18 @@ renderer) instead of the legacy Gradle + AsciidoctorJ mono-merge pipeline. See `reusable-pdf-build.yml` in `.github/workflows/` for the CI entry point that uses this package. -Published as a normal npm dependency - a docset adds it as a devDependency -and gets a real, versioned copy in its own `node_modules`, the same way it -already depends on `@neo4j-antora/roles-labels` or `@neo4j-antora/tabbed-nav`. -There's nothing to check out, symlink, or hand-configure: `@antora/pdf- -extension` is this package's own dependency (not something a docset lists -itself), so `npm install @neo4j-antora/pdf-generator` is the entire setup. +Published as a normal npm package, but deliberately *not* one a docset +declares in its own `package.json`: it's only ever needed for a PDF-specific +build, and a docset's `package.json` is shared by every job that runs +`npm install` against it (including totally unrelated jobs, e.g. a plain +HTML PR check), so declaring it there would make all of those fail whenever +a given version isn't published yet. `reusable-docs-pdf-build.yml` installs +it itself, as its own explicit `npm install @neo4j-antora/pdf-generator@...` +step, the same way it installs a specific Antora version regardless of what +the docset's own `package.json` pins. There's nothing to check out, symlink, +or hand-configure beyond that: `@antora/pdf-extension` is this package's own +dependency (not something a docset lists itself), so installing this one +package is the entire setup. == Usage From 9709478ed62d8a83454111e9dcf816c8f5ecdfba Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 13:36:18 +0100 Subject: [PATCH 10/21] Temporarily pin pdf-generator to published RC for CI verification (revert after) --- .github/workflows/docs-generate-pdf.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index d9920ac2..47dd904c 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -33,3 +33,6 @@ jobs: # reason reusable-docs-build.yml has this same input: no docset # installs every one of them. antora-extensions-exclude: '@neo4j-antora/antora-modify-sitemaps @neo4j-antora/antora-page-list @neo4j-antora/antora-unlisted-pages' + # TEMPORARY, for CI verification only - revert after: real 0.1.0 isn't + # published yet, so pin to the latest published RC. + pdf-generator-version: '0.1.0-rc.2' From 412befbdd424e27133e9e3120c23e4c40f7dc974 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 13:37:17 +0100 Subject: [PATCH 11/21] Revert "Temporarily pin pdf-generator to published RC for CI verification (revert after)" This reverts commit 9709478ed62d8a83454111e9dcf816c8f5ecdfba. --- .github/workflows/docs-generate-pdf.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index 47dd904c..d9920ac2 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -33,6 +33,3 @@ jobs: # reason reusable-docs-build.yml has this same input: no docset # installs every one of them. antora-extensions-exclude: '@neo4j-antora/antora-modify-sitemaps @neo4j-antora/antora-page-list @neo4j-antora/antora-unlisted-pages' - # TEMPORARY, for CI verification only - revert after: real 0.1.0 isn't - # published yet, so pin to the latest published RC. - pdf-generator-version: '0.1.0-rc.2' From abf895acf801fe880bb0432146f20e6cf5081398 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 16:17:02 +0100 Subject: [PATCH 12/21] Fix footnotes rendering inline instead of as numbered page notes Overriding asciidoctor-web-pdf's stylesheet attribute wholesale drops its default theme entirely, including the span.footnote { float: footnote } rule its footnote support depends on - without it, a footnote's text just sits inline in the running text with no number and no separation, easy to mistake for the macro being dropped. Add the missing footnote CSS rules to print.css. --- pdf-generator/pdf-theme/print.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pdf-generator/pdf-theme/print.css b/pdf-generator/pdf-theme/print.css index 07b1b1cc..18ca41a5 100644 --- a/pdf-generator/pdf-theme/print.css +++ b/pdf-generator/pdf-theme/print.css @@ -270,6 +270,28 @@ tr { break-inside: avoid; } /* lists */ .ulist, .olist, .dlist { margin: 0.5em 0; } +/* footnotes (copied from asciidoctor-web-pdf's own default document.css - + overriding `stylesheet` wholesale, as this theme does, drops the default + theme's CSS entirely, not just the rules this theme redefines. Without + these, a footnote's `span.footnote` renders in normal document flow + instead of being pulled out via the CSS `float: footnote`/`::footnote` + paged-media mechanism Vivliostyle implements, so the note text shows up + inline, right where footnote:[...] was written, with no superscript + marker and no separation from the surrounding sentence - easy to mistake + for footnotes being silently dropped, since nothing errors and the text + is still technically present.) */ +span.footnote { + float: footnote; +} + +[data-footnote-call]::after { + content: "[" counter(footnote) "]"; +} + +a[data-footnote-call] { + cursor: pointer; +} + /* role labels (badges added by @neo4j-antora/roles-labels for label:x[] macros and :page-role:/[role=label--x] roles). From ca39b1242295e2a8a9d71737a6267bcb0da7cfe8 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 24 Sep 2026 16:17:09 +0100 Subject: [PATCH 13/21] Remove table-footnotes handling, made obsolete by the footnote fix Now that span.footnote { float: footnote } is in place, the CSS layout engine itself already places a table's footnotes on whatever page that table lands on - the entire reason table-footnotes-postprocessor.js existed. It only ever fired against the old HTML5-backend #footnotes div structure, which asciidoctor-web-pdf's own converter never produces, so it was always a silent no-op here. Remove the postprocessor and its wiring in convert.js, drop @neo4j-antora/table-footnotes from reusable-docs-pdf-build.yml's default antora-extensions list (an Antora pagesComposed hook this pipeline's isolated Asciidoctor conversion never triggers), and update the README accordingly. --- .github/workflows/reusable-docs-pdf-build.yml | 3 +- pdf-generator/README.adoc | 45 +++++------ pdf-generator/scripts/convert.js | 15 ++-- .../table-footnotes-postprocessor.js | 74 ------------------- 4 files changed, 33 insertions(+), 104 deletions(-) delete mode 100644 pdf-generator/vendor/extensions/table-footnotes-postprocessor.js diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index ff4d108c..936bb6b7 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -43,7 +43,7 @@ on: type: string default: 'verify:publish' antora-extensions: - description: 'Antora extensions to pass to the build script. Defaults to the same list reusable-docs-build.yml uses for a real publish build, plus @neo4j-antora/pdf-generator itself - package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' + description: 'Antora extensions to pass to the build script. Defaults to the same list reusable-docs-build.yml uses for a real publish build, plus @neo4j-antora/pdf-generator itself, minus @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' required: false type: string default: ' @@ -53,7 +53,6 @@ on: @neo4j-antora/antora-unlisted-pages @neo4j-antora/roles-labels @neo4j-antora/selector-labels - @neo4j-antora/table-footnotes @neo4j-antora/xref-hash-validator @neo4j-antora/pdf-generator ' diff --git a/pdf-generator/README.adoc b/pdf-generator/README.adoc index a7b5060b..d27c13fc 100644 --- a/pdf-generator/README.adoc +++ b/pdf-generator/README.adoc @@ -77,9 +77,8 @@ override individual assembler settings if needed. `pdf-theme/assets/fonts/` rather than read from a docset's own HTML build output, so this theme has no dependency on that build having already run. -- `extensions/` - `roles-labels-postprocessor.js` and - `table-footnotes-postprocessor.js` port the essential parts of the Antora - extensions of the same name (see "Known limitations" below); +- `extensions/` - `roles-labels-postprocessor.js` ports the essential parts + of the Antora extension of the same name (see "Known limitations" below); `macros-adapter.js` and `remote-include-adapter.js` wrap the real `@neo4j-documentation/macros`/`remote-include` packages, fixing a pre-4.x-Asciidoctor.js API incompatibility in each at runtime (see their @@ -96,28 +95,32 @@ override individual assembler settings if needed. == Known limitations -- **No footnotes render at all, anywhere in the PDF, because of the TOC.** - Isolated by direct testing against `asciidoctor-web-pdf` outside this whole - pipeline: passing `-a toc` alone (nothing else - no tables, no our own - extensions) is enough to break native Asciidoctor `footnote:[...]` - conversion entirely - no superscript reference, no `#footnotes` div, just - the footnote text dropped in-line as if the macro had never been - processed. Since the print theme enables `toc` for every docset, this - currently affects every PDF this pipeline produces, not just docsets using - footnotes inside tables. `extensions/table-footnotes-postprocessor.js` - (see above) is correctly written and wired in, but has nothing to do while - this is broken - there's no `#footnotes` div for it to move content out of. - Not yet root-caused further (asciidoctor-web-pdf itself, or its Vivliostyle - dependency) or fixed. +- **Fixed**: footnotes used to render inline, in the middle of the running + text, with no number and no separation from the surrounding sentence - + easy to mistake for the macro being dropped entirely, since the text is + still technically there. Root cause: `asciidoctor-web-pdf`'s own converter + emits a footnote as a single self-contained `text + ` right at the point of use, and relies entirely on its default + theme's `span.footnote { float: footnote }` (a native CSS Generated + Content for Paged Media rule - the browser/Vivliostyle itself lays the + floated span into the footnote area of whichever page it lands on, and + generates the marker/counter) to pull it out and number it. Overriding + `stylesheet` wholesale (see `print.css` above) replaces that default theme + entirely rather than layering on top of it, so this pipeline had silently + dropped that rule. `print.css` now carries its own copy of it. Because the + float is resolved per-page by the layout engine itself, this also means a + footnote always lands on the same page as whatever referenced it - table + or not - with no extra plumbing needed. - `@neo4j-antora/roles-labels` and `@neo4j-antora/table-footnotes` are Antora extensions (hook `pagesComposed`, operate on an Antora `ContentCatalog`) - neither can run against this pipeline at all, structurally: the assembler hands the merged `.adoc` to a completely separate, isolated Asciidoctor - conversion with no Antora generator context. `table-footnotes` is ported - as a plain Asciidoctor `Postprocessor` instead - (`extensions/table-footnotes-postprocessor.js`) - pure HTML restructuring, - no Antora-specific data needed, so the port is a straight copy. - `roles-labels-postprocessor.js` reuses `@neo4j-antora/roles-labels`'s own + conversion with no Antora generator context. `table-footnotes` has no + ported equivalent here (nor is it in `reusable-docs-pdf-build.yml`'s + `antora-extensions` default list) - per the footnote fix above, the native + CSS float already puts a table's footnotes on the right page on its own, + so there's nothing left for it to do. `roles-labels-postprocessor.js` + reuses `@neo4j-antora/roles-labels`'s own shared `lib/process-labels.js` core directly (same synonym resolution, version/product-suffix stripping, dataset attributes as the real HTML site - not a hand-ported subset), with one deliberate behavioural diff --git a/pdf-generator/scripts/convert.js b/pdf-generator/scripts/convert.js index a3e590cb..2db9ba30 100644 --- a/pdf-generator/scripts/convert.js +++ b/pdf-generator/scripts/convert.js @@ -27,7 +27,6 @@ const path = require('node:path') const RENDERER = path.join(__dirname, '../vendor/node_modules/asciidoctor-pdf/bin/asciidoctor-web-pdf') const STYLESHEET = path.join(__dirname, '../pdf-theme/print.css') const ROLES_LABELS_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/roles-labels-postprocessor.js') -const TABLE_FOOTNOTES_POSTPROCESSOR = path.join(__dirname, '../vendor/extensions/table-footnotes-postprocessor.js') const REMOTE_INCLUDE_ADAPTER = path.join(__dirname, '../vendor/extensions/remote-include-adapter.js') const MACROS_ADAPTER = path.join(__dirname, '../vendor/extensions/macros-adapter.js') @@ -94,15 +93,17 @@ const PUPPETEER_TIMEOUT_ENV = { // this can't just be a relative value in the playbook/assembler config. const args = process.argv.slice(2) const stdinMarkerIdx = args.lastIndexOf('-') -// roles-labels-postprocessor.js and table-footnotes-postprocessor.js port the -// essential parts of the Antora extensions of the same name (see those -// files); macros-adapter.js and remote-include-adapter.js wrap the real -// @neo4j-documentation packages - added here, __dirname-computed, for the -// same reason as the stylesheet above. +// roles-labels-postprocessor.js ports the essential parts of the Antora +// extension of the same name (see that file); macros-adapter.js and +// remote-include-adapter.js wrap the real @neo4j-documentation packages - +// added here, __dirname-computed, for the same reason as the stylesheet +// above. (table-footnotes has no equivalent here: CSS `float: footnote` - +// see the print theme's own comment - already places a footnote on whatever +// page its table lands on, natively, so there's nothing for a postprocessor +// to move.) const extraArgs = [ '-a', `stylesheet=${STYLESHEET}`, '--extension', ROLES_LABELS_POSTPROCESSOR, - '--extension', TABLE_FOOTNOTES_POSTPROCESSOR, '--extension', REMOTE_INCLUDE_ADAPTER, '--extension', MACROS_ADAPTER, ] diff --git a/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js b/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js deleted file mode 100644 index ff7b7561..00000000 --- a/pdf-generator/vendor/extensions/table-footnotes-postprocessor.js +++ /dev/null @@ -1,74 +0,0 @@ -'use strict' - -// Ports @neo4j-antora/table-footnotes for the PDF pipeline: moving a table's -// own footnotes out of Asciidoctor's single document-wide #footnotes div and -// into a row on that specific table, instead of leaving them -// dumped at the very end of the whole document, disconnected from the table -// they came from. -// -// Same reason this can't just reuse table-footnotes.js directly as roles- -// labels-postprocessor.js: it hooks Antora's own `pagesComposed` event and -// operates on files in an Antora ContentCatalog, neither of which exist in -// this pipeline (see docs-tools/pdf/README) - asciidoctor-web-pdf runs a -// separate, isolated Asciidoctor conversion on the assembler's merged .adoc -// text that table-footnotes never gets a chance to see. This is a -// Postprocessor instead (Asciidoctor's own extension point, run after -// conversion to HTML), with the same DOM manipulation ported over near -// verbatim - it's pure HTML restructuring, no Antora-specific data needed. - -const { parse: parseHTML } = require('node-html-parser') -// Must come from the same package identity the running engine uses -// internally, not a separately-resolved copy - see roles-labels- -// postprocessor.js for why requiring the class from the wrong copy of the -// module fails Registry's own type check. -const { Postprocessor } = require('asciidoctor') - -function createElement (el, className = '') { - return parseHTML(`<${el}${className ? ` class="${className}"` : ''}>`) -} - -class TableFootnotesPostprocessor extends Postprocessor { - process (_document, output) { - if (!output.includes('id="footnotes"')) return output - const root = parseHTML(output) - const footnotesDiv = root.getElementById('footnotes') - const tables = root.querySelectorAll('table') - if (!footnotesDiv || tables.length === 0) return output - - tables.forEach((table) => { - const tableFootnotes = table.querySelectorAll('tbody a.footnote') - if (tableFootnotes.length === 0) return - - const cols = table.querySelectorAll('colgroup col').length - const tFoot = createElement('tfoot') - const footnoteRow = createElement('tr') - tFoot.firstElementChild.appendChild(footnoteRow) - const footnoteCell = createElement('td', 'tableblock footnote-cell') - footnoteCell.firstElementChild.setAttribute('colspan', cols) - - // For each footnote reference in this table, find the matching - // footnote definition (by id, from its href) in the document-wide - // footnotes div, and move it into this table's own footer. - tableFootnotes.forEach((footnote) => { - const footnoteId = footnote.getAttribute('href').replace('#', '') - const matchingFootnote = footnotesDiv.querySelector(`#${footnoteId}`) - if (!matchingFootnote) return - footnoteCell.firstElementChild.appendChild(matchingFootnote) - }) - - footnoteRow.firstElementChild.appendChild(footnoteCell) - table.appendChild(tFoot) - }) - - // Remove the document-wide footnotes div if every footnote in it ended - // up moved into a table footer. - if (footnotesDiv.querySelectorAll('div.footnote').length === 0) { - footnotesDiv.remove() - } - return root.toString() - } -} - -module.exports.register = function (registry) { - registry.postprocessor(TableFootnotesPostprocessor) -} From 0acc759b16e96eac48e8395249249aa02c02631a Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 12:51:37 +0100 Subject: [PATCH 14/21] Pin antora to 3.2.0 in docs/package.json Third-party dependencies in package.json files are pinned to an exact version, not a range. --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index a8ebff21..e9ff21cb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,7 +32,7 @@ "@neo4j-antora/xref-hash-validator": "^0.1.4", "@neo4j-documentation/macros": "^1.0.4", "@neo4j-documentation/remote-include": "^1.0.0", - "antora": "^3.2.0", + "antora": "3.2.0", "node-html-parser": "9.0.0" }, "devDependencies": { From d7b50e964cebe9c01b6f32c45830f8a8d12af8a3 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 15:12:11 +0100 Subject: [PATCH 15/21] Trigger a PDF build alongside the HTML build, and trim the PDF extension defaults Add a trigger-generate-pdf job to docs-trigger-builds.yml that dispatches docs-generate-pdf.yml as its own separate run for the branch that was pushed, in parallel with and independent of the HTML dispatch. Add this branch to the push trigger while the PDF workflow is tested; that line must be removed before the PR is merged. Remove the sitemaps, page list and unlisted pages extensions from the default extensions of reusable-docs-pdf-build.yml, since they only apply to an HTML site, and drop the antora-extensions-exclude from docs-generate-pdf.yml that existed to remove them again. --- .github/workflows/docs-generate-pdf.yml | 5 --- .github/workflows/docs-trigger-builds.yml | 36 +++++++++++++++++++ .github/workflows/reusable-docs-pdf-build.yml | 5 +-- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs-generate-pdf.yml b/.github/workflows/docs-generate-pdf.yml index d9920ac2..0719fa5e 100644 --- a/.github/workflows/docs-generate-pdf.yml +++ b/.github/workflows/docs-generate-pdf.yml @@ -28,8 +28,3 @@ jobs: docs-dir: 'docs' build-ref: ${{ inputs.build-ref || github.ref_name }} fetch-depth: 0 - # This repo's own docs/package.json doesn't install every extension in - # reusable-docs-pdf-build.yml's antora-extensions default list - same - # reason reusable-docs-build.yml has this same input: no docset - # installs every one of them. - antora-extensions-exclude: '@neo4j-antora/antora-modify-sitemaps @neo4j-antora/antora-page-list @neo4j-antora/antora-unlisted-pages' diff --git a/.github/workflows/docs-trigger-builds.yml b/.github/workflows/docs-trigger-builds.yml index a4111c87..e3495e4f 100644 --- a/.github/workflows/docs-trigger-builds.yml +++ b/.github/workflows/docs-trigger-builds.yml @@ -32,6 +32,10 @@ on: push: branches: - 'dev' + # TEMPORARY, while docs-generate-pdf.yml is being tested on its PR (#136): remove this + # branch before merging. docs-tools itself is not published from, so there is no real + # branch list to keep in step with the playbooks here. + - 'pdf-generator-package-clean' workflow_dispatch: # Set DEV_BRANCH to your staging branch. @@ -139,3 +143,35 @@ jobs: } await core.summary.write() + + # Trigger docs-generate-pdf.yml as its own separate run too, for the same reasons as + # above: not a workflow_call, so it is fully independent of the HTML builds. If the PDF + # build fails, the HTML builds are unaffected (nothing here `needs` this job, and it + # `needs` nothing), and its artifact can't collide with the HTML "docs" artifact. + # It builds the branch that triggered this run: a PDF is not environment-specific, so + # there is no dev/prod pairing to work out. + trigger-generate-pdf: + name: Trigger PDF build + runs-on: ubuntu-latest + steps: + - name: Dispatch docs-generate-pdf.yml + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + BUILD_REF: ${{ github.ref_name }} + with: + script: | + const buildRef = process.env.BUILD_REF + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'docs-generate-pdf.yml', + // Dispatch from the same branch that is being built, so the workflow file + // that runs is the one on that branch (docs-generate-pdf.yml only exists on + // this branch until its PR is merged). + ref: buildRef, + inputs: { + 'build-ref': buildRef, + }, + }) + core.summary.addRaw(`Triggered \`docs-generate-pdf.yml\` on branch \`${buildRef}\``, true) + await core.summary.write() diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 936bb6b7..a0523f7c 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -43,14 +43,11 @@ on: type: string default: 'verify:publish' antora-extensions: - description: 'Antora extensions to pass to the build script. Defaults to the same list reusable-docs-build.yml uses for a real publish build, plus @neo4j-antora/pdf-generator itself, minus @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' + description: 'Antora extensions to pass to the build script. Defaults to the extensions that affect the content of a PDF, plus @neo4j-antora/pdf-generator itself. It deliberately leaves out the HTML-site-only extensions that reusable-docs-build.yml has (the sitemaps, page list and unlisted pages ones), and @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' required: false type: string default: ' @neo4j-antora/aliases-redirects - @neo4j-antora/antora-modify-sitemaps - @neo4j-antora/antora-page-list - @neo4j-antora/antora-unlisted-pages @neo4j-antora/roles-labels @neo4j-antora/selector-labels @neo4j-antora/xref-hash-validator From da31cf0cf979b89a81058bac9ddb37e7d93fe243 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 15:17:54 +0100 Subject: [PATCH 16/21] Remove aliases-redirects from the default extensions of the PDF build Redirects only apply to an HTML site, so the PDF build does not need the aliases-redirects extension. The defaults are now roles-labels, selector-labels, xref-hash-validator and pdf-generator. --- .github/workflows/reusable-docs-pdf-build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index a0523f7c..17dec072 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -43,11 +43,10 @@ on: type: string default: 'verify:publish' antora-extensions: - description: 'Antora extensions to pass to the build script. Defaults to the extensions that affect the content of a PDF, plus @neo4j-antora/pdf-generator itself. It deliberately leaves out the HTML-site-only extensions that reusable-docs-build.yml has (the sitemaps, page list and unlisted pages ones), and @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' + description: 'Antora extensions to pass to the build script. Defaults to the extensions that affect the content of a PDF, plus @neo4j-antora/pdf-generator itself. It deliberately leaves out the HTML-site-only extensions that reusable-docs-build.yml has (the redirects, sitemaps, page list and unlisted pages ones), and @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' required: false type: string default: ' - @neo4j-antora/aliases-redirects @neo4j-antora/roles-labels @neo4j-antora/selector-labels @neo4j-antora/xref-hash-validator From 7508f00571a61f026b7c2b30b5d1099a9d27d34e Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 15:20:44 +0100 Subject: [PATCH 17/21] Remove selector-labels from the default extensions of the PDF build The version selector labels only apply to an HTML site. The defaults are now roles-labels, xref-hash-validator and pdf-generator. --- .github/workflows/reusable-docs-pdf-build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 17dec072..8cf9faca 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -43,12 +43,11 @@ on: type: string default: 'verify:publish' antora-extensions: - description: 'Antora extensions to pass to the build script. Defaults to the extensions that affect the content of a PDF, plus @neo4j-antora/pdf-generator itself. It deliberately leaves out the HTML-site-only extensions that reusable-docs-build.yml has (the redirects, sitemaps, page list and unlisted pages ones), and @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' + description: 'Antora extensions to pass to the build script. Defaults to the extensions that affect the content of a PDF, plus @neo4j-antora/pdf-generator itself. It deliberately leaves out the HTML-site-only extensions that reusable-docs-build.yml has (the redirects, version selector labels, sitemaps, page list and unlisted pages ones), and @neo4j-antora/table-footnotes - that one hooks Antora''s own pagesComposed event, which this pipeline''s separate, isolated Asciidoctor conversion never triggers, so it would always be a silent no-op here (see pdf-generator/README.adoc). package-script''s own command has none of these baked in itself (reusable-docs-build.yml supplies them as CLI flags, the same way this workflow does), so without this a PDF build would silently run without them.' required: false type: string default: ' @neo4j-antora/roles-labels - @neo4j-antora/selector-labels @neo4j-antora/xref-hash-validator @neo4j-antora/pdf-generator ' From 938a110379b6314f5e68f1960efa2245627d0cac Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 15:33:46 +0100 Subject: [PATCH 18/21] pdf-generator: publish only the intended files, and bump to 0.1.1 Add a files whitelist to package.json. Without one, npm packed everything in the folder that is not excluded by default, including the vendor/node_modules, vendor/package.json and vendor/package-lock.json that the postinstall script creates, so publishing from a folder where the package had been installed produced a 34 MB tarball with 13,000 files. The whitelist gives the same 13 files (75 kB) from any folder. Bump the version to 0.1.1, because 0.1.0 was published with those files, and move the default pdf-generator-version of the PDF build to match. --- .github/workflows/reusable-docs-pdf-build.yml | 2 +- pdf-generator/package.json | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 8cf9faca..3361f697 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -36,7 +36,7 @@ on: description: 'The @neo4j-antora/pdf-generator version to install. Not a dependency of the docset''s own package.json - it is only ever needed for this PDF-specific build, so declaring it there would make *every* install in the repo (including totally unrelated jobs, e.g. a plain HTML PR check) fail whenever this version is not yet published, the same way reusable-docs-build.yml installs a specific Antora version itself rather than trusting whatever a docset''s own package.json happens to pin.' required: false type: string - default: '0.1.0' + default: '0.1.1' package-script: description: 'The name of the script to run in package.json, same convention as reusable-docs-build.yml''s own package-script input - the docset is trusted to already have a valid script here (defaults to verify:publish, matching what a real PDF export should reflect). @neo4j-antora/pdf-generator is injected as a one-off --extension flag onto whatever that script already does, so it needs no dedicated PDF playbook or script of its own.' required: false diff --git a/pdf-generator/package.json b/pdf-generator/package.json index 58254a9b..b93dd7ef 100644 --- a/pdf-generator/package.json +++ b/pdf-generator/package.json @@ -1,8 +1,16 @@ { "name": "@neo4j-antora/pdf-generator", - "version": "0.1.0", + "version": "0.1.1", "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", "main": "extension.js", + "files": [ + "extension.js", + "antora-assembler-pdf.yml", + "pdf-theme", + "scripts", + "vendor/extensions", + "README.adoc" + ], "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "postinstall": "node scripts/setup-vendor.js" From 21b79c6cd1f4aa71ec2f3d29179937162dd41352 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 15:43:53 +0100 Subject: [PATCH 19/21] PDF build: print and upload the Antora log, as the HTML build does The PDF reusable workflow did not keep the Antora log, so a failed PDF build left nothing to inspect but the console. Add a Print Antora log step and an Upload Log artifact step that follow reusable-docs-build.yml. The print step also runs when the PDF lookup fails, since the renderer can fail without making the Antora run itself fail. --- .github/workflows/reusable-docs-pdf-build.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 3361f697..b4ccf235 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -168,6 +168,20 @@ jobs: fi echo "pdf-path=$pdf_path" >> "$GITHUB_OUTPUT" + # Same as reusable-docs-build.yml's "Print Antora log", but also when the build step + # itself reported success yet no PDF was found: the PDF renderer can fail ("Unable + # to generate the PDF") without making the Antora run fail, so that case only shows + # up as the PDF lookup above failing. + - name: Print Antora log + if: always() && (steps.run-pdf-build.outcome == 'failure' || steps.get-pdf-path.outcome == 'failure') + working-directory: ${{ env.DOCS_DIR }} + run: | + if [ -f build/log/log.json ]; then + cat build/log/log.json + else + echo "No log file found at build/log/log.json" + fi + - name: Upload PDF artifact if: steps.run-pdf-build.outcome == 'success' uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 @@ -176,6 +190,14 @@ jobs: path: ${{ env.DOCS_DIR }}/${{ steps.get-pdf-path.outputs.pdf-path }} retention-days: ${{ inputs.retain-artifacts }} + - name: Upload Log artifact + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: antora-log + path: ${{ env.DOCS_DIR }}/build/log + retention-days: ${{ inputs.retain-artifacts }} + - name: Fail job if PDF build failed if: always() && steps.run-pdf-build.outcome == 'failure' run: exit 1 From 895186c2c13c278e73da39b7435a32a59cf26c2e Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 16:05:54 +0100 Subject: [PATCH 20/21] pdf-generator: stop footnotes in nested tables from hanging the PDF render, and bump to 0.1.2 A footnote reference inside a table that is itself nested in another table never finished paginating in Vivliostyle: the renderer waited out its 180 s rendering timeout and the build produced no PDF. Two cases were found with a 7 KB test document: - an admonition (which Asciidoctor renders as a table) containing a table with a footnote. Lay the admonition out as plain blocks, so it is no longer a table in a table. - a table inside a table cell (an a| cell holding a table) with a footnote. Do not float the footnote in that case; its text stays in the cell instead of moving to the foot of the page. Footnotes in a plain paragraph, an admonition, a simple table and a table with header and footer footnotes are unchanged. The docs-tools docs now build a PDF locally in a few seconds. Bump the version to 0.1.2 and the default pdf-generator-version of the PDF build to match. --- .github/workflows/reusable-docs-pdf-build.yml | 2 +- pdf-generator/package.json | 2 +- pdf-generator/pdf-theme/print.css | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index b4ccf235..4713a68b 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -36,7 +36,7 @@ on: description: 'The @neo4j-antora/pdf-generator version to install. Not a dependency of the docset''s own package.json - it is only ever needed for this PDF-specific build, so declaring it there would make *every* install in the repo (including totally unrelated jobs, e.g. a plain HTML PR check) fail whenever this version is not yet published, the same way reusable-docs-build.yml installs a specific Antora version itself rather than trusting whatever a docset''s own package.json happens to pin.' required: false type: string - default: '0.1.1' + default: '0.1.2' package-script: description: 'The name of the script to run in package.json, same convention as reusable-docs-build.yml''s own package-script input - the docset is trusted to already have a valid script here (defaults to verify:publish, matching what a real PDF export should reflect). @neo4j-antora/pdf-generator is injected as a one-off --extension flag onto whatever that script already does, so it needs no dedicated PDF playbook or script of its own.' required: false diff --git a/pdf-generator/package.json b/pdf-generator/package.json index b93dd7ef..f639ad86 100644 --- a/pdf-generator/package.json +++ b/pdf-generator/package.json @@ -1,6 +1,6 @@ { "name": "@neo4j-antora/pdf-generator", - "version": "0.1.1", + "version": "0.1.2", "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", "main": "extension.js", "files": [ diff --git a/pdf-generator/pdf-theme/print.css b/pdf-generator/pdf-theme/print.css index 18ca41a5..1d3b5962 100644 --- a/pdf-generator/pdf-theme/print.css +++ b/pdf-generator/pdf-theme/print.css @@ -232,6 +232,16 @@ tr { break-inside: avoid; } /* admonitions */ .admonitionblock { break-inside: avoid-page; margin: 1em 0; } .admonitionblock table { width: 100%; border: none; } +/* Asciidoctor renders an admonition as a table (icon cell + content cell). Laid out as a + real table, a content table (or anything else with a footnote) inside it is a table + nested in a table, and Vivliostyle then never finishes paginating a floated footnote in + there - the build waits out the 180 s rendering timeout and produces no PDF at all. + Only the content cell is shown (the icon cell is hidden below), so lay the admonition + out as plain blocks instead. */ +.admonitionblock > table, +.admonitionblock > table > tbody, +.admonitionblock > table > tbody > tr, +.admonitionblock td.content { display: block; } .admonitionblock td.icon { display: none; } .admonitionblock td.content { border-left: 3pt solid var(--brand-border-strong); @@ -284,6 +294,16 @@ span.footnote { float: footnote; } +/* A footnote inside a table that is itself inside a table cell (an `a|` cell holding a + table) cannot be floated either: Vivliostyle never finishes paginating it, and the build + times out with no PDF. Leave such a footnote in place instead - its text then appears + in the cell rather than as a numbered note at the foot of the page, which is a poorer + result but a PDF. Admonition tables are not affected (they are laid out as blocks + above and their footnotes float normally). */ +table.tableblock table span.footnote { + float: none; +} + [data-footnote-call]::after { content: "[" counter(footnote) "]"; } From 035b5cc783ef9ccb856d3d062c4dfdb5bb76dca4 Mon Sep 17 00:00:00 2001 From: Neil Dewhurst Date: Thu, 1 Oct 2026 16:32:28 +0100 Subject: [PATCH 21/21] pdf-generator: keep table captions above the header row inside admonitions, and bump to 0.1.3 The admonition title rules applied to every .title inside an admonition, not only its own title. A table in an admonition has its caption as caption.title, so that caption was made display: block, which laid it out after the header row (a thead is always laid out first) instead of above the table, and shown in capitals in the admonition colour. Scope the title rules to the admonition's own title, a direct child of its content cell. Table captions in admonitions now look like those outside them. Bump the version to 0.1.3 and the default pdf-generator-version of the PDF build to match. --- .github/workflows/reusable-docs-pdf-build.yml | 2 +- pdf-generator/package.json | 2 +- pdf-generator/pdf-theme/print.css | 17 +++++++++++------ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/reusable-docs-pdf-build.yml b/.github/workflows/reusable-docs-pdf-build.yml index 4713a68b..445cd758 100644 --- a/.github/workflows/reusable-docs-pdf-build.yml +++ b/.github/workflows/reusable-docs-pdf-build.yml @@ -36,7 +36,7 @@ on: description: 'The @neo4j-antora/pdf-generator version to install. Not a dependency of the docset''s own package.json - it is only ever needed for this PDF-specific build, so declaring it there would make *every* install in the repo (including totally unrelated jobs, e.g. a plain HTML PR check) fail whenever this version is not yet published, the same way reusable-docs-build.yml installs a specific Antora version itself rather than trusting whatever a docset''s own package.json happens to pin.' required: false type: string - default: '0.1.2' + default: '0.1.3' package-script: description: 'The name of the script to run in package.json, same convention as reusable-docs-build.yml''s own package-script input - the docset is trusted to already have a valid script here (defaults to verify:publish, matching what a real PDF export should reflect). @neo4j-antora/pdf-generator is injected as a one-off --extension flag onto whatever that script already does, so it needs no dedicated PDF playbook or script of its own.' required: false diff --git a/pdf-generator/package.json b/pdf-generator/package.json index f639ad86..fd16e377 100644 --- a/pdf-generator/package.json +++ b/pdf-generator/package.json @@ -1,6 +1,6 @@ { "name": "@neo4j-antora/pdf-generator", - "version": "0.1.2", + "version": "0.1.3", "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", "main": "extension.js", "files": [ diff --git a/pdf-generator/pdf-theme/print.css b/pdf-generator/pdf-theme/print.css index 1d3b5962..38df2d94 100644 --- a/pdf-generator/pdf-theme/print.css +++ b/pdf-generator/pdf-theme/print.css @@ -248,7 +248,12 @@ tr { break-inside: avoid; } padding: 6pt 10pt; background: var(--brand-bg-strong); } -.admonitionblock .title { +/* Style only the admonition's own title (a direct child of its content cell), not every + `.title` inside it. A table in an admonition has its caption as `caption.title`; with + these rules applied to it, the caption was made `display: block` (so it was laid out + after the header row instead of above it) and shown in capitals and the admonition + colour. */ +.admonitionblock td.content > .title { font-weight: 700; text-transform: uppercase; font-size: 8.5pt; @@ -262,20 +267,20 @@ tr { break-inside: avoid; } border-left-color: var(--brand-warning); background: var(--brand-warning-bg); } -.admonitionblock.warning .title, -.admonitionblock.caution .title { color: var(--brand-warning); } +.admonitionblock.warning td.content > .title, +.admonitionblock.caution td.content > .title { color: var(--brand-warning); } .admonitionblock.important td.content { border-left-color: var(--brand-danger); background: var(--brand-danger-bg); } -.admonitionblock.important .title { color: var(--brand-danger); } +.admonitionblock.important td.content > .title { color: var(--brand-danger); } .admonitionblock.note td.content, .admonitionblock.tip td.content { border-left-color: var(--brand-primary); background: var(--brand-primary-weak); } -.admonitionblock.note .title, -.admonitionblock.tip .title { color: var(--brand-primary-strong); } +.admonitionblock.note td.content > .title, +.admonitionblock.tip td.content > .title { color: var(--brand-primary-strong); } /* lists */ .ulist, .olist, .dlist { margin: 0.5em 0; }