|
| 1 | +/** |
| 2 | + * Screenshot Bootstrap examples using Playwright. |
| 3 | + * |
| 4 | + * Starts the Astro dev server automatically, waits for it to be ready, |
| 5 | + * takes light + dark screenshots at 1x and 2x, then shuts the server down. |
| 6 | + * |
| 7 | + * Usage: |
| 8 | + * node build/screenshot-examples.mjs [--only album,pricing] |
| 9 | + * |
| 10 | + * Prerequisites: |
| 11 | + * npm install -D playwright |
| 12 | + * npx playwright install chromium |
| 13 | + * |
| 14 | + * The script reads examples.yml and saves to: |
| 15 | + * site/static/docs/[version]/assets/img/examples/{slug}.png (480×300) |
| 16 | + * site/static/docs/[version]/assets/img/examples/{slug}@2x.png (960×600) |
| 17 | + * site/static/docs/[version]/assets/img/examples/{slug}-dark.png (480×300) |
| 18 | + * site/static/docs/[version]/assets/img/examples/{slug}[email protected] (960×600) |
| 19 | + */ |
| 20 | + |
| 21 | +import { readFileSync, mkdirSync } from 'node:fs' |
| 22 | +import path from 'node:path' |
| 23 | +import { fileURLToPath } from 'node:url' |
| 24 | +import { spawn } from 'node:child_process' |
| 25 | +import { parse as parseYaml } from 'yaml' |
| 26 | +import { chromium } from 'playwright' |
| 27 | +import sharp from 'sharp' |
| 28 | + |
| 29 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
| 30 | +const ROOT = path.resolve(__dirname, '..') |
| 31 | + |
| 32 | +// ─── Config ────────────────────────────────────────────────────────────────── |
| 33 | + |
| 34 | +const args = process.argv.slice(2) |
| 35 | +const getArg = flag => { |
| 36 | + const idx = args.indexOf(flag) |
| 37 | + return idx === -1 ? null : args[idx + 1] |
| 38 | +} |
| 39 | + |
| 40 | +const ONLY = getArg('--only')?.split(',').map(s => s.trim().toLowerCase()) ?? null |
| 41 | + |
| 42 | +// Astro dev server port (matches astro-dev in package.json) |
| 43 | +const PORT = 9001 |
| 44 | +const BASE_URL = `http://localhost:${PORT}` |
| 45 | +const SERVER_TIMEOUT_MS = 60_000 |
| 46 | +const SERVER_POLL_INTERVAL_MS = 500 |
| 47 | + |
| 48 | +// Read docs version from config.yml |
| 49 | +const configYml = readFileSync(path.resolve(ROOT, 'config.yml'), 'utf8') |
| 50 | +const DOCS_VERSION = parseYaml(configYml).docs_version ?? '6.0' |
| 51 | + |
| 52 | +// Output directory — [version] is a literal Astro dynamic-route folder name |
| 53 | +const OUT_DIR = path.resolve(ROOT, 'site/static/docs/[version]/assets/img/examples') |
| 54 | +mkdirSync(OUT_DIR, { recursive: true }) |
| 55 | + |
| 56 | +// Full-width capture viewport; images are then resized down to thumbnail sizes |
| 57 | +const CAPTURE_VIEWPORT = { width: 1440, height: 900 } |
| 58 | +// 1x thumbnail: 480×300 | 2x thumbnail: 960×600 |
| 59 | +const THUMB = { w: 480, h: 300 } |
| 60 | + |
| 61 | +// ─── Dev server ────────────────────────────────────────────────────────────── |
| 62 | + |
| 63 | +/** Spawn the Astro dev server and return the child process. */ |
| 64 | +function startDevServer() { |
| 65 | + console.log('Starting Astro dev server…') |
| 66 | + const server = spawn('node', ['node_modules/.bin/astro', 'dev', '--root', 'site', '--port', String(PORT)], { |
| 67 | + cwd: ROOT, |
| 68 | + stdio: ['ignore', 'pipe', 'pipe'] |
| 69 | + }) |
| 70 | + server.stdout.on('data', d => process.stdout.write(`[astro] ${d}`)) |
| 71 | + server.stderr.on('data', d => process.stderr.write(`[astro] ${d}`)) |
| 72 | + return server |
| 73 | +} |
| 74 | + |
| 75 | +/** Poll until the server responds or timeout is reached. */ |
| 76 | +async function waitForServer() { |
| 77 | + const deadline = Date.now() + SERVER_TIMEOUT_MS |
| 78 | + |
| 79 | + const poll = async () => { |
| 80 | + if (Date.now() >= deadline) { |
| 81 | + throw new Error(`Dev server did not start within ${SERVER_TIMEOUT_MS / 1000}s`) |
| 82 | + } |
| 83 | + |
| 84 | + try { |
| 85 | + const res = await fetch(`${BASE_URL}/`) |
| 86 | + if (res.ok || res.status < 500) { |
| 87 | + console.log('Dev server is ready.\n') |
| 88 | + return |
| 89 | + } |
| 90 | + } catch { |
| 91 | + // not up yet |
| 92 | + } |
| 93 | + |
| 94 | + await new Promise(resolvePromise => { |
| 95 | + setTimeout(resolvePromise, SERVER_POLL_INTERVAL_MS) |
| 96 | + }) |
| 97 | + |
| 98 | + await poll() |
| 99 | + } |
| 100 | + |
| 101 | + await poll() |
| 102 | +} |
| 103 | + |
| 104 | +// ─── Helpers ───────────────────────────────────────────────────────────────── |
| 105 | + |
| 106 | +/** Replicate the getSlug() logic used in the Astro components */ |
| 107 | +function getSlug(name) { |
| 108 | + return name |
| 109 | + .toLowerCase() |
| 110 | + .replace(/[^a-z0-9]+/g, '-') |
| 111 | + .replace(/(^-|-$)/g, '') |
| 112 | +} |
| 113 | + |
| 114 | +/** Collect all non-external examples from examples.yml */ |
| 115 | +function getExamples() { |
| 116 | + const yml = readFileSync(path.resolve(ROOT, 'site/data/examples.yml'), 'utf8') |
| 117 | + const categories = parseYaml(yml) |
| 118 | + const result = [] |
| 119 | + for (const { examples, external } of categories) { |
| 120 | + if (external) { |
| 121 | + continue |
| 122 | + } |
| 123 | + |
| 124 | + for (const example of examples ?? []) { |
| 125 | + result.push(example.name) |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + return result |
| 130 | +} |
| 131 | + |
| 132 | +// ─── Screenshot ────────────────────────────────────────────────────────────── |
| 133 | + |
| 134 | +/** |
| 135 | + * Capture the page at full viewport, then resize to the target thumbnail size. |
| 136 | + * colorScheme: 'light' | 'dark' |
| 137 | + * scale: 1 (480×300) | 2 (960×600) |
| 138 | + */ |
| 139 | +async function screenshot(page, slug, colorScheme, scale) { |
| 140 | + const darkSuffix = colorScheme === 'dark' ? '-dark' : '' |
| 141 | + const scaleSuffix = scale === 2 ? '@2x' : '' |
| 142 | + const outFile = path.resolve(OUT_DIR, `${slug}${darkSuffix}${scaleSuffix}.png`) |
| 143 | + |
| 144 | + await page.emulateMedia({ colorScheme }) |
| 145 | + const rawBuffer = await page.screenshot({ type: 'png' }) |
| 146 | + |
| 147 | + await sharp(rawBuffer) |
| 148 | + .resize(THUMB.w * scale, THUMB.h * scale, { fit: 'cover', position: 'top' }) |
| 149 | + .toFile(outFile) |
| 150 | + |
| 151 | + console.log(` saved ${outFile.replace(`${ROOT}/`, '')}`) |
| 152 | +} |
| 153 | + |
| 154 | +async function run() { |
| 155 | + const examples = getExamples() |
| 156 | + const filtered = ONLY ? examples.filter(n => ONLY.includes(n.toLowerCase())) : examples |
| 157 | + |
| 158 | + if (filtered.length === 0) { |
| 159 | + throw new Error('No examples matched. Check --only values against examples.yml.') |
| 160 | + } |
| 161 | + |
| 162 | + const server = startDevServer() |
| 163 | + |
| 164 | + // Ensure the server is killed even if we crash |
| 165 | + const cleanup = () => server.kill() |
| 166 | + process.on('exit', cleanup) |
| 167 | + |
| 168 | + try { |
| 169 | + await waitForServer() |
| 170 | + |
| 171 | + console.log(`Taking screenshots of ${filtered.length} example(s)`) |
| 172 | + console.log(`Output → ${OUT_DIR}\n`) |
| 173 | + |
| 174 | + const browser = await chromium.launch() |
| 175 | + |
| 176 | + await Promise.all(filtered.map(async name => { |
| 177 | + const slug = getSlug(name) |
| 178 | + const url = `${BASE_URL}/docs/${DOCS_VERSION}/examples/${slug}/` |
| 179 | + console.log(`→ ${name} (${slug})`) |
| 180 | + |
| 181 | + // Single page load — reuse for light & dark, both scales (sharp handles resizing) |
| 182 | + const page = await browser.newPage({ viewport: CAPTURE_VIEWPORT, deviceScaleFactor: 1 }) |
| 183 | + await page.goto(url, { waitUntil: 'networkidle' }) |
| 184 | + await page.addStyleTag({ content: '.bd-mode-toggle { display: none !important; }' }) |
| 185 | + await screenshot(page, slug, 'light', 1) |
| 186 | + await screenshot(page, slug, 'light', 2) |
| 187 | + await screenshot(page, slug, 'dark', 1) |
| 188 | + await screenshot(page, slug, 'dark', 2) |
| 189 | + await page.close() |
| 190 | + })) |
| 191 | + |
| 192 | + await browser.close() |
| 193 | + console.log('\nDone.') |
| 194 | + } finally { |
| 195 | + server.kill() |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +run().catch(error => { |
| 200 | + console.error(error) |
| 201 | + process.exitCode = 1 |
| 202 | +}) |
0 commit comments