Skip to content

Commit 35bec1b

Browse files
committed
PDF generation workflow
1 parent 9e83d00 commit 35bec1b

13 files changed

Lines changed: 904 additions & 792 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
"outofsync",
270270
"PABC",
271271
"pacticipant",
272+
"PAGEDJS",
272273
"PERC",
273274
"permissioned",
274275
"pgbouncer",

eslint.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import securityPlugin from 'eslint-plugin-security'
1010
import ymlPlugin from 'eslint-plugin-yml'
1111
import tsPlugin from 'typescript-eslint'
1212
import restrictedGlobals from 'confusing-browser-globals'
13+
import globals from 'globals'
1314
import enforceCentralizedEventsRule from './test/eslint/enforce-centralized-events-rule'
1415
import noHtmlElementAssertionsRule from './test/eslint/no-html-element-assertions-rule'
1516
import noQuerySelectorOutsideSelectorsRule from './test/eslint/no-query-selector-outside-selectors-rule'
@@ -50,7 +51,9 @@ export default [
5051
ecmaVersion: 'latest',
5152
sourceType: 'module',
5253
globals: {
53-
NodeJS: 'readonly',
54+
...globals.node, // Covers 'process', 'Buffer', etc.
55+
NodeJS: 'readonly', // Covers TS namespaces
56+
console: 'readonly', // Specific fix for 'console'
5457
},
5558
},
5659
rules: {

package-lock.json

Lines changed: 268 additions & 628 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"",
5656
"lint:tsc:check": "npm run sync && tsc --noEmit -p tsconfig.json --pretty false",
5757
"search:reindex": "python3 scripts/search-index.py",
58+
"pdf:generate": "node scripts/generate-pdfs.mjs",
5859
"search:content-length": "python3 scripts/search_content_lengths.py",
5960
"search:relevancy": "python3 scripts/search_relevancy.py",
6061
"search:relevancy:reranking": "python3 scripts/search_relevancy.py --reranking",
@@ -116,8 +117,8 @@
116117
"@types/react": "^19.2.14",
117118
"@types/sanitize-html": "^2.16.1",
118119
"@types/to-ico": "1.1.3",
119-
"@typescript-eslint/eslint-plugin": "8.57.2",
120-
"@typescript-eslint/parser": "8.57.2",
120+
"@typescript-eslint/eslint-plugin": "8.58.0",
121+
"@typescript-eslint/parser": "8.58.0",
121122
"@ungap/structured-clone": "^1.3.0",
122123
"@uppy/audio": "^3.1.0",
123124
"@uppy/box": "^4.1.0",
@@ -133,11 +134,11 @@
133134
"@vitest/coverage-v8": "^4.1.2",
134135
"@webcomponents/template-shadowroot": "^0.2.1",
135136
"alex": "^11.0.1",
136-
"astro": "6.1.1",
137+
"astro": "6.1.2",
137138
"astro-link-validator": "github:rodgtr1/astro-link-validator",
138139
"astro-og-canvas": "^0.10.1",
139140
"astro-vtbot": "^2.1.12",
140-
"baseline-browser-mapping": "^2.10.11",
141+
"baseline-browser-mapping": "^2.10.12",
141142
"canvas-confetti": "^1.9.4",
142143
"confusing-browser-globals": "1.0.11",
143144
"cross-env": "^10.1.0",
@@ -157,6 +158,7 @@
157158
"eslint-plugin-security": "4.0.0",
158159
"eslint-plugin-yml": "3.3.1",
159160
"focus-trap": "8.0.1",
161+
"globals": "^17.4.0",
160162
"gsap": "^3.14.2",
161163
"happy-dom": "^20.8.9",
162164
"hast-util-heading-rank": "^3.0.0",
@@ -168,7 +170,7 @@
168170
"isomorphic-git": "^1.37.4",
169171
"js-cookie": "^3.0.5",
170172
"jsdom": "^29.0.1",
171-
"libphonenumber-js": "1.12.40",
173+
"libphonenumber-js": "1.12.41",
172174
"lighthouse": "^13.0.3",
173175
"lit": "^3.3.2",
174176
"markdownlint-cli2": "^0.22.0",
@@ -178,14 +180,14 @@
178180
"node-html-parser": "^7.1.0",
179181
"nodemailer": "^8.0.4",
180182
"npm": "^11.12.1",
181-
"pagedjs": "^0.4.3",
182183
"playwright-lighthouse": "^4.0.0",
183184
"postcss": "8.5.8",
184185
"postcss-html": "1.8.1",
185186
"preact": "^10.29.0",
186187
"prettier": "3.8.1",
187188
"prettier-plugin-astro": "0.14.1",
188189
"pubsub-js": "^1.9.5",
190+
"puppeteer": "^24.40.0",
189191
"qr-code-styling": "^1.9.2",
190192
"rehype-accessible-emojis": "^0.3.2",
191193
"rehype-external-links": "^3.0.0",
@@ -210,7 +212,7 @@
210212
"remark-stringify": "^11.0.0",
211213
"remark-supersub": "^1.0.0",
212214
"remark-video": "^0.10.0",
213-
"resend": "^6.9.4",
215+
"resend": "^6.10.0",
214216
"retext": "^9.0.0",
215217
"retext-smartypants": "^6.2.0",
216218
"rimraf": "6.1.3",
@@ -230,7 +232,7 @@
230232
"to-ico": "1.1.5",
231233
"tslib": "2.8.1",
232234
"typescript": "6.0.2",
233-
"typescript-eslint": "8.57.2",
235+
"typescript-eslint": "8.58.0",
234236
"unified": "^11.0.5",
235237
"unist": "^0.0.1",
236238
"unist-util-inspect": "^8.1.0",
@@ -245,7 +247,7 @@
245247
"zod": "4.3.6"
246248
},
247249
"optionalDependencies": {
248-
"@rollup/rollup-linux-x64-gnu": "^4.60.0"
250+
"@rollup/rollup-linux-x64-gnu": "^4.60.1"
249251
},
250252
"overrides": {
251253
"vite": "^7.3.1"
Binary file not shown.

scripts/generate-pdfs.mjs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Generate PDFs from /print/ pages using Puppeteer.
5+
*
6+
* Usage:
7+
* - node scripts/generate-pdfs.mjs <slug> # single article
8+
* - node scripts/generate-pdfs.mjs # all deep dive articles
9+
*
10+
* Requires a running server (dev or preview) at localhost:4321.
11+
* Set PDF_SERVER_URL to override the server base URL.
12+
*/
13+
14+
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
15+
import { join, resolve } from 'node:path'
16+
import puppeteer from 'puppeteer'
17+
18+
// --- Configuration ---
19+
20+
const SERVER_BASE = process.env.PDF_SERVER_URL ?? 'http://localhost:4321'
21+
const OUTPUT_DIR = resolve('public/downloads/pdfs')
22+
const ARTICLES_DIR = resolve('src/content/articles')
23+
const CONTACT_JSON = resolve('src/content/contact.json')
24+
const slugFilter = process.argv[2] ?? null
25+
26+
// PDF page: US Letter (8.5 × 11 in)
27+
const MARGIN = { top: '2cm', right: '2cm', bottom: '2.5cm', left: '2cm' }
28+
29+
// Content area in pixels at 96 DPI (for approximate ToC page-number calculation)
30+
const CM_PER_IN = 2.54
31+
const PAGE_HEIGHT_IN = 11
32+
const MARGIN_TOP_IN = 2 / CM_PER_IN
33+
const MARGIN_BOTTOM_IN = 2.5 / CM_PER_IN
34+
const MARGIN_SIDE_IN = 2 / CM_PER_IN
35+
const CONTENT_HEIGHT_PX = (PAGE_HEIGHT_IN - MARGIN_TOP_IN - MARGIN_BOTTOM_IN) * 96
36+
const CONTENT_WIDTH_PX = (8.5 - 2 * MARGIN_SIDE_IN) * 96
37+
38+
// --- Templates ---
39+
40+
const contact = JSON.parse(readFileSync(CONTACT_JSON, 'utf-8')).company
41+
42+
const esc = (s) =>
43+
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
44+
45+
const buildHeaderTemplate = (title) =>
46+
`<div style="font-size:9pt; font-family:Arial,Helvetica,sans-serif; width:100%; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #ccc; padding-bottom:4px;">
47+
<span style="font-weight:bold;">${esc(contact.name)}</span>
48+
<span style="color:#555; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right;">${esc(title)}</span>
49+
</div>`
50+
51+
const footerTemplate =
52+
`<div style="font-size:8pt; font-family:Arial,Helvetica,sans-serif; width:100%; border-top:1px solid #ccc; padding-top:4px;">
53+
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
54+
<span>${esc(contact.address)}, ${esc(contact.city)}, ${esc(contact.state)} ${esc(contact.index)}</span>
55+
<span>${esc(contact.email)}</span>
56+
</div>
57+
<div style="text-align:center; color:#555;">
58+
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
59+
</div>
60+
</div>`
61+
62+
// --- Slug collection ---
63+
64+
const collectAllSlugs = () => {
65+
if (!existsSync(ARTICLES_DIR)) return []
66+
return readdirSync(ARTICLES_DIR, { withFileTypes: true })
67+
.filter(e => e.isDirectory() && existsSync(join(ARTICLES_DIR, e.name, 'pdf.mdx')))
68+
.map(e => e.name)
69+
.sort()
70+
}
71+
72+
// --- Server check ---
73+
74+
const checkServer = async () => {
75+
try {
76+
await fetch(SERVER_BASE, { signal: AbortSignal.timeout(5000) })
77+
return true
78+
} catch {
79+
return false
80+
}
81+
}
82+
83+
// --- PDF generation ---
84+
85+
const generatePdf = async (browser, slug) => {
86+
const inputUrl = `${SERVER_BASE}/print/${slug}`
87+
const outputPdf = join(OUTPUT_DIR, `${slug}.pdf`)
88+
89+
console.log(` ${slug}`)
90+
console.log(` input: ${inputUrl}`)
91+
console.log(` output: ${outputPdf}`)
92+
93+
const page = await browser.newPage()
94+
95+
try {
96+
// Viewport matches PDF content area for more accurate measurement
97+
await page.setViewport({
98+
width: Math.round(CONTENT_WIDTH_PX),
99+
height: Math.round(CONTENT_HEIGHT_PX),
100+
})
101+
102+
await page.goto(inputUrl, { waitUntil: 'networkidle0', timeout: 60_000 })
103+
await page.emulateMediaType('print')
104+
105+
// Wait for all images to finish loading
106+
await page.evaluate(() =>
107+
Promise.all(
108+
Array.from(document.images)
109+
.filter(img => !img.complete)
110+
.map(img => new Promise(r => { img.onload = r; img.onerror = r }))
111+
)
112+
)
113+
114+
const title = await page.evaluate(() => document.title)
115+
116+
// Inject approximate page numbers into the ToC
117+
await page.evaluate((contentH) => {
118+
const toc = document.querySelector('.print-toc')
119+
const article = document.querySelector('.print-article')
120+
if (!toc || !article) return
121+
122+
// Cover always occupies 1 page (break-after: page)
123+
const coverPages = 1
124+
const tocPages = Math.max(1, Math.ceil(toc.getBoundingClientRect().height / contentH))
125+
const pagesBeforeArticle = coverPages + tocPages
126+
127+
const articleTop = article.getBoundingClientRect().top
128+
const pageMap = new Map()
129+
130+
for (const h of article.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]')) {
131+
const offset = h.getBoundingClientRect().top - articleTop
132+
pageMap.set(h.id, pagesBeforeArticle + Math.floor(Math.max(0, offset) / contentH) + 1)
133+
}
134+
135+
for (const span of document.querySelectorAll('.print-toc__page')) {
136+
const href = span.closest('a')?.getAttribute('href')?.replace('#', '')
137+
if (href && pageMap.has(href)) {
138+
span.textContent = String(pageMap.get(href))
139+
}
140+
}
141+
}, CONTENT_HEIGHT_PX)
142+
143+
// Generate PDF
144+
await page.pdf({
145+
path: outputPdf,
146+
format: 'Letter',
147+
margin: MARGIN,
148+
displayHeaderFooter: true,
149+
headerTemplate: buildHeaderTemplate(title),
150+
footerTemplate,
151+
printBackground: true,
152+
preferCSSPageSize: false,
153+
tagged: true,
154+
outline: true,
155+
})
156+
157+
console.log(' ✓ done\n')
158+
return true
159+
} catch (error) {
160+
console.error(` ✗ FAILED: ${error.message ?? error}\n`)
161+
return false
162+
} finally {
163+
await page.close()
164+
}
165+
}
166+
167+
// --- Main ---
168+
169+
mkdirSync(OUTPUT_DIR, { recursive: true })
170+
171+
if (!(await checkServer())) {
172+
console.error(`ERROR: No server running at ${SERVER_BASE}`)
173+
console.error('Start the dev server (npm run dev) or preview server (npm run preview) first.')
174+
process.exit(1)
175+
}
176+
177+
if (slugFilter) {
178+
const pdfMdx = join(ARTICLES_DIR, slugFilter, 'pdf.mdx')
179+
if (!existsSync(pdfMdx)) {
180+
console.error(`ERROR: No pdf.mdx found for "${slugFilter}"`)
181+
console.error(`Expected: ${pdfMdx}`)
182+
process.exit(1)
183+
}
184+
}
185+
186+
const slugs = slugFilter ? [slugFilter] : collectAllSlugs()
187+
188+
if (slugs.length === 0) {
189+
console.error('ERROR: No deep dive articles with pdf.mdx found.')
190+
process.exit(1)
191+
}
192+
193+
console.log(`Generating ${slugs.length} PDF(s) from ${SERVER_BASE}...\n`)
194+
195+
const browser = await puppeteer.launch({
196+
headless: true,
197+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
198+
})
199+
200+
let succeeded = 0
201+
let failed = 0
202+
203+
try {
204+
for (const slug of slugs) {
205+
const ok = await generatePdf(browser, slug)
206+
if (ok) succeeded++
207+
else failed++
208+
}
209+
} finally {
210+
await browser.close()
211+
}
212+
213+
if (slugs.length > 1) {
214+
console.log(`Results: ${succeeded} succeeded, ${failed} failed out of ${slugs.length} total`)
215+
}
216+
217+
process.exit(failed > 0 ? 1 : 0)

src/components/Layout/Print/Cover/index.astro

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ const publishDateLabel = article.data.publishDate.toLocaleDateString(undefined,
1616
})
1717
---
1818

19-
<section class="flex min-h-screen break-after-page flex-col items-center justify-center text-center print:break-after-page">
20-
<h1 class="mb-4 text-4xl font-bold text-content-active">{article.data.title}</h1>
21-
<div class="mb-6 w-full max-w-4xl">
19+
<section class="print-cover">
20+
<h1 class="print-cover__title">{article.data.title}</h1>
21+
<div class="print-cover__image">
2222
<Image
2323
src={article.data.cover}
2424
alt={article.data.coverAlt}
2525
loading="eager"
2626
variant="full"
2727
/>
2828
</div>
29-
<p class="mb-2 text-lg text-content">By {authorName}</p>
30-
<p class="text-sm text-content-offset">
29+
<p class="print-cover__author">By {authorName}</p>
30+
<p class="print-cover__date">
3131
Published on <time datetime={publishDateIso}>{publishDateLabel}</time>
3232
</p>
3333
</section>

0 commit comments

Comments
 (0)