Skip to content

Commit 2c35cf0

Browse files
Docs: automatically screenshot examples as light/dark images (#42329)
1 parent 6a90b13 commit 2c35cf0

File tree

136 files changed

+292
-4
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

136 files changed

+292
-4
lines changed

build/screenshot-examples.mjs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
})

package-lock.json

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

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"docs-prettier-format": "prettier --config site/.prettierrc.json --write --cache site",
9595
"docs-serve": "npm run astro-dev -- --host",
9696
"docs-serve-only": "npx sirv-cli _site --port 9001",
97+
"screenshots": "rm -f site/static/docs/[version]/assets/img/examples/*.png && node build/screenshot-examples.mjs",
9798
"lockfile-lint": "lockfile-lint --allowed-hosts npm --allowed-schemes https: --empty-hostname false --type npm --path package-lock.json",
9899
"update-deps": "ncu -u",
99100
"release": "npm-run-all dist release-sri docs-build release-zip*",
@@ -179,6 +180,7 @@
179180
"mime": "^4.1.0",
180181
"nodemon": "^3.1.14",
181182
"npm-run-all2": "^8.0.4",
183+
"playwright": "^1.59.1",
182184
"postcss": "^8.5.10",
183185
"postcss-cli": "^11.0.1",
184186
"prettier": "^3.8.3",
@@ -190,12 +192,14 @@
190192
"rollup-plugin-istanbul": "^5.0.0",
191193
"sass": "^1.99.0",
192194
"sass-true": "^10.1.0",
195+
"sharp": "^0.34.5",
193196
"shelljs": "^0.10.0",
194197
"stylelint": "16.26.1",
195198
"stylelint-config-twbs-bootstrap": "^16.1.0",
196199
"stylelint-order": "^8.1.1",
197200
"terser": "^5.46.1",
198201
"unist-util-visit": "^5.1.0",
202+
"yaml": "^2.8.3",
199203
"zod": "^4.3.6"
200204
},
201205
"files": [

site/src/layouts/partials/ExamplesMain.astro

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ import { getSlug } from '@libs/utils'
6060
href={`/docs/${getConfig().docs_version}/examples/${getSlug(example.name)}/`}
6161
>
6262
<img
63-
class="img-thumbnail mb-3"
63+
class="img-thumbnail mb-3 examples-thumb"
64+
data-light-src={getVersionedDocsPath(`/assets/img/examples/${getSlug(example.name)}.png`)}
65+
data-light-srcset={`${getVersionedDocsPath(
66+
`/assets/img/examples/${getSlug(example.name)}.png`
67+
)}, ${getVersionedDocsPath(`/assets/img/examples/${getSlug(example.name)}@2x.png`)} 2x`}
68+
data-dark-src={getVersionedDocsPath(`/assets/img/examples/${getSlug(example.name)}-dark.png`)}
69+
data-dark-srcset={`${getVersionedDocsPath(
70+
`/assets/img/examples/${getSlug(example.name)}-dark.png`
71+
)}, ${getVersionedDocsPath(`/assets/img/examples/${getSlug(example.name)}[email protected]`)} 2x`}
6472
srcset={`${getVersionedDocsPath(
6573
`/assets/img/examples/${getSlug(example.name)}.png`
6674
)}, ${getVersionedDocsPath(`/assets/img/examples/${getSlug(example.name)}@2x.png`)} 2x`}
@@ -81,3 +89,30 @@ import { getSlug } from '@libs/utils'
8189
)
8290
})
8391
}
92+
93+
<script is:inline>
94+
const applyExamplesThumbTheme = () => {
95+
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark'
96+
const thumbs = document.querySelectorAll('.examples-thumb')
97+
98+
thumbs.forEach((thumb) => {
99+
const src = isDark ? thumb.dataset.darkSrc : thumb.dataset.lightSrc
100+
const srcset = isDark ? thumb.dataset.darkSrcset : thumb.dataset.lightSrcset
101+
102+
if (src && thumb.getAttribute('src') !== src) {
103+
thumb.setAttribute('src', src)
104+
}
105+
106+
if (srcset && thumb.getAttribute('srcset') !== srcset) {
107+
thumb.setAttribute('srcset', srcset)
108+
}
109+
})
110+
}
111+
112+
applyExamplesThumbTheme()
113+
114+
new MutationObserver(applyExamplesThumbTheme).observe(document.documentElement, {
115+
attributes: true,
116+
attributeFilter: ['data-bs-theme']
117+
})
118+
</script>
5.17 KB
16.2 KB
-6.04 KB
-10.6 KB
4.06 KB
10.9 KB

0 commit comments

Comments
 (0)