mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 13:47:00 +04:00
163 lines
6.6 KiB
JavaScript
163 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Serves the built screenshots/dist via `astro preview` and captures a light
|
|
// and dark (?theme=dark) PNG — at 1x and @2x — of every page's #screenshot
|
|
// canvas (the full 1024x768 frame — logo, gradient backdrop and fake cursor
|
|
// included, not just the cropped component card).
|
|
// Only the @2x picture is rasterized by the browser; the 1x file is that
|
|
// picture downscaled with a Lanczos filter. Chromium renders a 1x surface with
|
|
// plain grayscale antialiasing, and hairlines (chart grids, borders) snap to
|
|
// whole pixels there; a supersampled 1x keeps their weight and gives text a
|
|
// smoother edge. Playwright's own `scale: 'css'` does not do this — it simply
|
|
// rasterizes at 1x.
|
|
// Run via `pnpm run capture` (builds first) — pass slugs as args to capture
|
|
// only specific pages, e.g. `pnpm run capture button badge`.
|
|
import { chromium, type Page } from 'playwright'
|
|
import sharp from 'sharp'
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const root = path.join(__dirname, '..')
|
|
const distDir = path.join(root, 'dist')
|
|
const outDir = path.join(root, 'captures')
|
|
|
|
const PORT = Number(process.env.CAPTURE_PORT ?? 4020)
|
|
const baseUrl = `http://localhost:${PORT}`
|
|
|
|
function discoverSlugs(): string[] {
|
|
if (!existsSync(distDir)) {
|
|
console.error(`No build found at ${path.relative(process.cwd(), distDir)} — run \`astro build\` first (or use \`pnpm run capture\`).`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const filter = process.argv.slice(2)
|
|
return readdirSync(distDir)
|
|
.filter((file) => file.endsWith('.html') && file !== 'index.html')
|
|
.map((file) => file.replace(/\.html$/, ''))
|
|
.filter((slug) => filter.length === 0 || filter.includes(slug))
|
|
.sort()
|
|
}
|
|
|
|
// Spawns the `astro` binary directly (not via `pnpm exec astro`) so the CLI's
|
|
// own background handling is the only thing between us and the server.
|
|
// Astro detaches `preview` into a background daemon whenever stdout is not a
|
|
// TTY (which it never is here) and prints a JSON status line instead of the
|
|
// human "Local http://…" banner, so readiness is polled over HTTP rather than
|
|
// scraped from stdout, and the server is stopped through `astro preview stop`
|
|
// rather than by killing the process we spawned — that one has already exited.
|
|
const astroBin = path.join(root, 'node_modules/.bin/astro')
|
|
|
|
function runAstro(args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(astroBin, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
child.stderr?.on('data', (data) => process.stderr.write(data))
|
|
child.once('error', reject)
|
|
child.once('exit', (code) => {
|
|
if (code === null || code === 0) resolve()
|
|
else reject(new Error(`astro ${args.join(' ')} exited with code ${code}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function startPreviewServer(): Promise<void> {
|
|
await runAstro(['preview', '--background', '--port', String(PORT)])
|
|
|
|
const deadline = Date.now() + 20_000
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(baseUrl, { method: 'HEAD' })
|
|
if (response.ok || response.status === 404) return
|
|
} catch {
|
|
// not listening yet
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
}
|
|
|
|
throw new Error(`astro preview didn't come up on port ${PORT} within 20s`)
|
|
}
|
|
|
|
const stopPreviewServer = () => runAstro(['preview', 'stop']).catch(() => {})
|
|
|
|
function filenameFor(slug: string, theme: 'light' | 'dark', scale: 1 | 2): string {
|
|
const themeSuffix = theme === 'dark' ? '-dark' : ''
|
|
const scaleSuffix = scale === 2 ? '@2x' : ''
|
|
return `${slug}${themeSuffix}${scaleSuffix}.png`
|
|
}
|
|
|
|
async function captureOne(page: Page, slug: string, theme: 'light' | 'dark') {
|
|
await page.goto(`${baseUrl}/${slug}?theme=${theme}`, { waitUntil: 'load' })
|
|
await page.waitForFunction(() => document.documentElement.dataset.screenshotReady === 'true', { timeout: 15_000 })
|
|
|
|
// Page furniture that exists for the human browsing the pages (the light/dark
|
|
// toggle). The app frame fills the viewport, so anything left on screen would
|
|
// sit inside the captured rectangle.
|
|
await page.addStyleTag({ content: '[data-screenshot-chrome] { display: none !important; }' })
|
|
|
|
const frame = page.locator('#screenshot')
|
|
// The picture is always the frame's CSS size, times the device scale for @2x.
|
|
// A layout may render its frame larger than the picture it wants so the page
|
|
// shows more (data-screenshot-width, see ScreenshotAppLayout); the raster is
|
|
// then reduced to that width, keeping the frame's aspect ratio.
|
|
const [cssWidth, cssHeight, targetWidth] = await frame.evaluate((el) => {
|
|
const { width, height } = el.getBoundingClientRect()
|
|
const wanted = Number((el as HTMLElement).dataset.screenshotWidth ?? width)
|
|
return [width, height, wanted]
|
|
})
|
|
const width1x = Math.round(targetWidth)
|
|
const height1x = Math.round((targetWidth * cssHeight) / cssWidth)
|
|
|
|
const raster = await frame.screenshot()
|
|
const write = async (filename: string, factor: 1 | 2) => {
|
|
await sharp(raster)
|
|
.resize(width1x * factor, height1x * factor, { kernel: 'lanczos3' })
|
|
.png()
|
|
.toFile(path.join(outDir, filename))
|
|
console.log(` ✓ ${filename}`)
|
|
}
|
|
|
|
await write(filenameFor(slug, theme, 2), 2)
|
|
await write(filenameFor(slug, theme, 1), 1)
|
|
}
|
|
|
|
async function main() {
|
|
const slugs = discoverSlugs()
|
|
if (slugs.length === 0) {
|
|
console.error('No matching pages found to capture.')
|
|
process.exit(1)
|
|
}
|
|
|
|
mkdirSync(outDir, { recursive: true })
|
|
|
|
console.log(`Starting preview server on port ${PORT}…`)
|
|
await startPreviewServer()
|
|
// Interrupting the script (Ctrl+C) must not leave the preview server behind
|
|
// squatting on the port for the next run to collide with.
|
|
process.once('SIGINT', () => void stopPreviewServer())
|
|
process.once('SIGTERM', () => void stopPreviewServer())
|
|
|
|
try {
|
|
const browser = await chromium.launch()
|
|
// Wide enough for the app frame (1708x1068, see ScreenshotAppLayout); the
|
|
// 1024x768 card frame sits centered in it on whole pixels.
|
|
const page = await browser.newPage({ viewport: { width: 1708, height: 1068 }, deviceScaleFactor: 2 })
|
|
|
|
for (const slug of slugs) {
|
|
console.log(slug)
|
|
await captureOne(page, slug, 'light')
|
|
await captureOne(page, slug, 'dark')
|
|
}
|
|
|
|
await browser.close()
|
|
console.log(`\n${slugs.length * 4} screenshots written to ${path.relative(process.cwd(), outDir)}/`)
|
|
} finally {
|
|
await stopPreviewServer()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|