Skip to content

Add full-site PDF export - #4

Open
stuible wants to merge 2 commits into
chore/node-24-supportfrom
feat/pdf-export
Open

Add full-site PDF export#4
stuible wants to merge 2 commits into
chore/node-24-supportfrom
feat/pdf-export

Conversation

@stuible

@stuible stuible commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

npm run export:pdf renders every generated route into a single PDF. Run it after npm run generate.

Output: 92 pages across 88 sections, ~40 MB. Text stays selectable rather than rasterized.

Targets chore/node-24-support (#3), not master — it needs those build fixes to generate at all. Retarget once #3 lands.

Approach

Serve dist/ statically → drive headless Chromium over each route with page.pdf() → merge with pdf-lib. Screen media is emulated so the capture stays faithful to the site as it renders in a browser. PDF width is pinned to the 1440px viewport so nothing reflows; leaving height unset lets long routes flow onto continuation pages.

File Role
index.mjs Orchestrator
routes.mjs Route discovery + reading order
learn.mjs Quiz state walking
render.mjs Page setup, waits, PDF capture
server.mjs Minimal static server (avoids another dependency)

Route discovery reads dist/**/index.html rather than re-deriving routes from pages/, so the dynamic /tour/_region/_video routes come along for free and new videos under content/tour/*/videos/ need no script changes. Routes are then ordered for reading rather than alphabetically, which would otherwise split the two tour regions.

The /learn walk is the one interesting bit. It's a single route hiding ~20 screens, and the quiz state is local component data on pages/learn.vue — with showFeedback and moduleComplete computed from currentIndex. So the walk sets moduleStarted and currentIndex directly instead of simulating correct answers, then resets the module afterwards so the beforeRouteLeave confirm() can't wedge the run.

Waits that turned out to matter

Each of these was a real defect caught during verification, not speculative hardening:

  • document.fonts.ready — otherwise Typekit hasn't resolved and you capture fallback fonts.
  • Video poster decoding — plyr now initializes lazily in mounted(), so there's a real async gap. Capturing early gave black rectangles.
  • A scroll pass — the site uses vue-lazyload, and nothing scrolls during an export. Without this, eight thumbnails on /tour/videos exported as blank grey placeholders.

Verified

Sweep across all 92 pages: no blank pages, no untexted pages. Video pages render real frames with plyr controls. All 18 quiz screens match the content files exactly (6 sections → 9 questions, 9 feedback, plus objectives and completion). The fixed <nav-bar /> does not repeat on continuation pages — the artifact I'd flagged as likely didn't materialize, so no print stylesheet was needed.

Notes

  • *.pdf added to .gitignore; the 40 MB output is not committed.
  • Adds playwright and pdf-lib as devDependencies. Playwright needs npx playwright install chromium once.
  • The quiz walk is coupled to learn.vue internals by necessity. It throws a descriptive error rather than silently exporting the wrong screens if that data shape changes.

https://claude.ai/code/session_01YYw1jCDqP22swZPmSqcUtn

stuible added 2 commits July 31, 2026 12:38
`npm run export:pdf` renders every generated route to a single PDF. Run it
after `npm run generate`.

Output: 92 pages across 88 sections, ~40MB. Text stays selectable rather
than being rasterized.

Approach: serve dist/ statically, drive headless Chromium over each route
with page.pdf(), merge with pdf-lib. Screen media is emulated so the
capture stays faithful to the site as it renders in a browser; the PDF
width is pinned to the 1440px viewport so nothing reflows, while leaving
height unset lets long routes flow onto continuation pages.

Route discovery reads dist/**/index.html rather than re-deriving routes
from pages/, so dynamic /tour/_region/_video routes come along for free
and new videos under content/tour/*/videos/ need no script changes.
Routes are then ordered for reading rather than alphabetically, which
would otherwise split the two tour regions.

/learn is a single route hiding ~20 screens. Its quiz state is local
component data on pages/learn.vue, with showFeedback and moduleComplete
computed from currentIndex, so the walk sets moduleStarted and
currentIndex directly instead of simulating correct answers. It also
resets the module afterwards so the beforeRouteLeave confirm() cannot
wedge the run. This is the one part coupled to component internals; it
fails loudly if learn.vue's data shape changes.

Capture waits are deliberate: document.fonts.ready for Typekit, explicit
image and video-poster decoding (plyr initializes lazily in mounted(), so
capturing early gave black rectangles), and a scroll pass to trip
vue-lazyload, without which eight thumbnails on /tour/videos exported as
blank placeholders.

Verified: no blank pages, no untexted pages, video posters render real
frames, all 18 quiz screens match the content files, and the fixed nav
does not repeat on continuation pages.

Claude-Session: https://claude.ai/code/session_01YYw1jCDqP22swZPmSqcUtn
The module hides content behind two kinds of interaction that a plain
state walk missed. Learn screens go from 18 to 39; the document from 92
to 113 pages.

Answer reveals: showAnswer is computed as userRequestedAnswer &&
userAnsweredQuestion, both pre-declared per question by
helpers/formatLearnQuestions.js and therefore reactive. Setting them
exposes the correct answer in the input and, for non-matching questions,
the explanation panel beside it. All 8 questions gain a revealed screen.

Feedback panel reveals: the section-feedback components mount interactive
widgets that start fully collapsed. Timeline, OverdoseStats and
AddressingStigma step through a `sections` array via `sectionIndex`;
StigmaImpacts switches on an `activeContent` key. Section 3's panel says
"Click on the icons on the left to explore" and showed nothing at all
before this. That is 13 further screens.

Reveals are discovered by probing the live component tree rather than
being hardcoded, so a feedback panel that grows a fifth step, or a new
panel built on the same `sectionIndex` shape, needs no changes here. The
one exception is StigmaImpacts: its keys exist only as literals in the
template and cannot be read from component data, so they are listed in a
constant tied back to that file by comment.

walkLearn now drives the module with a capture callback instead of
returning a precomputed state list, since reveal steps are only knowable
once a screen is mounted.

Verified: 113 pages, none blank or untexted, and no two consecutive learn
screens are textually identical, so every reveal genuinely adds content.

Claude-Session: https://claude.ai/code/session_01YYw1jCDqP22swZPmSqcUtn
@stuible

stuible commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Added: interactive reveals in the learn module

The module hid content behind two kinds of interaction the original state walk missed. Learn screens go from 18 → 39; the document from 92 → 113 pages.

Answer reveals (+8). showAnswer is computed as userRequestedAnswer && userAnsweredQuestion, both pre-declared per question by helpers/formatLearnQuestions.js and therefore reactive. Setting them exposes the correct answer in the input and, for non-matching questions, the explanation panel beside it.

Feedback panel reveals (+13). The section-feedback components mount interactive widgets that start fully collapsed:

Component Mechanism Screens
StigmaImpacts activeContent key 3 (individual / social / structural)
AddressingStigma sectionIndex 3
OverdoseStats sectionIndex 3 (19% / 16% / 60%)
Timeline sectionIndex 4

Section 3's panel is the clearest case — it reads "Click on the icons on the left to explore how stigma…" and rendered nothing below that before this change.

Discovery is dynamic, not hardcoded. The walk probes the live component tree, so a panel that grows a fifth step — or a new panel built on the same sectionIndex shape — needs no changes here. The one exception is StigmaImpacts: its keys exist only as literals in the template and can't be read from component data, so they're in a constant tied back to that file by comment.

walkLearn now drives the module with a capture callback rather than returning a precomputed state list, since reveal steps are only knowable once a screen is mounted.

Verified: 113 pages, none blank or untexted, and no two consecutive learn screens are textually identical — so every reveal genuinely adds content rather than re-capturing the same view.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant