astro-better-generate-screenshots 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/README.md +22 -0
- package/index.mjs +55 -0
- package/package.json +41 -0
- package/scripts/check-screenshots.mjs +188 -0
- package/scripts/take-screenshots.mjs +162 -0
- package/src/capture.mjs +112 -0
- package/src/chrome.mjs +166 -0
- package/src/diff.mjs +80 -0
- package/src/discover.mjs +128 -0
- package/src/docker.mjs +123 -0
- package/src/placeholder.mjs +53 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Better Static Sites
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# astro-better-generate-screenshots
|
|
2
|
+
|
|
3
|
+
CLI that generates the screenshot PNGs consumed by astro-better-declarative-screenshots.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install astro-better-generate-screenshots
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- Starts Docker and captures pages with Playwright WebKit
|
|
14
|
+
- Injects highlights and composites window chrome
|
|
15
|
+
- Diffs against committed PNGs to detect drift
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
Full documentation, including configuration and examples, is at
|
|
20
|
+
[https://better-static-sites.github.io/content/declarative-screenshots](https://better-static-sites.github.io/content/declarative-screenshots).
|
|
21
|
+
|
|
22
|
+
This package is published from the [Better Static Sites monorepo](https://github.com/better-static-sites/better-static-sites.github.io); the directory it lives in has a combined README covering it and its sibling package.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Astro integration entry point.
|
|
2
|
+
// usage in astro.config.mjs:
|
|
3
|
+
// import screenshots from 'astro-better-generate-screenshots';
|
|
4
|
+
// export default defineConfig({ integrations: [screenshots()] });
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from 'astro-better-declarative-screenshots';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Partial<import('astro-better-declarative-screenshots').ConfigSchema['_type']>} [integrationConfig]
|
|
11
|
+
* @returns {import('astro').AstroIntegration}
|
|
12
|
+
*/
|
|
13
|
+
export default function screenshotsIntegration(integrationConfig = {}) {
|
|
14
|
+
let resolvedConfig;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
name: 'astro-better-declarative-screenshots',
|
|
18
|
+
|
|
19
|
+
hooks: {
|
|
20
|
+
'astro:config:setup': async ({ config: astroConfig, addWatchFile, logger }) => {
|
|
21
|
+
const projectRoot = astroConfig.root
|
|
22
|
+
? new URL(astroConfig.root).pathname
|
|
23
|
+
: process.cwd();
|
|
24
|
+
|
|
25
|
+
// load and validate the screenshot.config.mjs
|
|
26
|
+
try {
|
|
27
|
+
resolvedConfig = await loadConfig(projectRoot);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (integrationConfig.strict === false) {
|
|
30
|
+
logger.warn(`[astro-better-declarative-screenshots] ${e.message}`);
|
|
31
|
+
resolvedConfig = null;
|
|
32
|
+
} else {
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// watch the config file so the dev server restarts when it changes
|
|
38
|
+
const configFile = path.join(projectRoot, 'screenshot.config.mjs');
|
|
39
|
+
addWatchFile(configFile);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
'astro:build:done': async ({ logger }) => {
|
|
43
|
+
if (!resolvedConfig) return;
|
|
44
|
+
logger.info(
|
|
45
|
+
'[astro-better-declarative-screenshots] build done. ' +
|
|
46
|
+
'Run `take-screenshots` to update screenshots.'
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// re-export utilities consumers may want
|
|
54
|
+
export { loadConfig, deriveName } from 'astro-better-declarative-screenshots';
|
|
55
|
+
export { discoverScreenshots } from './src/discover.mjs';
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-better-generate-screenshots",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "CLI that generates screenshot PNGs for astro-better-declarative-screenshots",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/better-static-sites/better-static-sites.github.io.git",
|
|
8
|
+
"directory": "astro-better-declarative-screenshots/generate-screenshots"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://better-static-sites.github.io",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/better-static-sites/better-static-sites.github.io/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"take-screenshots": "./scripts/take-screenshots.mjs",
|
|
17
|
+
"check-screenshots": "./scripts/check-screenshots.mjs"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./index.mjs",
|
|
21
|
+
"./scripts/take-screenshots.mjs": "./scripts/take-screenshots.mjs",
|
|
22
|
+
"./scripts/check-screenshots.mjs": "./scripts/check-screenshots.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.mjs",
|
|
26
|
+
"src/",
|
|
27
|
+
"scripts/"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"playwright": "^1.45.0",
|
|
31
|
+
"sharp": "^0.33.0",
|
|
32
|
+
"pixelmatch": "^6.0.0",
|
|
33
|
+
"pngjs": "^7.0.0",
|
|
34
|
+
"glob": "^11.0.0",
|
|
35
|
+
"zod": "^3.23.0",
|
|
36
|
+
"astro-better-declarative-screenshots": "^0.4.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"astro": ">=4.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: check-screenshots
|
|
3
|
+
// captures all screenshots and diffs them against the committed references.
|
|
4
|
+
// exits non-zero if any screenshot exceeds the diff threshold.
|
|
5
|
+
// designed for CI -- does NOT overwrite the committed files.
|
|
6
|
+
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
9
|
+
import { loadConfig } from 'astro-better-declarative-screenshots';
|
|
10
|
+
import { discoverScreenshots } from '../src/discover.mjs';
|
|
11
|
+
import { startDocker, stopDocker } from '../src/docker.mjs';
|
|
12
|
+
import { capturePage, createBrowserContext } from '../src/capture.mjs';
|
|
13
|
+
import { addChrome } from '../src/chrome.mjs';
|
|
14
|
+
import { diffScreenshot } from '../src/diff.mjs';
|
|
15
|
+
|
|
16
|
+
const projectRoot = process.cwd();
|
|
17
|
+
|
|
18
|
+
// default: 0.1% of pixels may differ before it's a failure
|
|
19
|
+
const DEFAULT_THRESHOLD_RATIO = 0.001;
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const args = parseArgs(process.argv.slice(2));
|
|
23
|
+
const config = await loadConfig(projectRoot).catch(e => {
|
|
24
|
+
console.error(e.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const outputDir = path.resolve(projectRoot, config.outputDir);
|
|
29
|
+
const diffDir = path.resolve(projectRoot, args.diffDir ?? '.screenshot-diffs');
|
|
30
|
+
mkdirSync(diffDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const specs = await discoverScreenshots(projectRoot);
|
|
33
|
+
|
|
34
|
+
if (specs.length === 0) {
|
|
35
|
+
console.log('[check-screenshots] no Screenshot components found.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const filter = args.filter;
|
|
40
|
+
const toCheck = filter
|
|
41
|
+
? specs.filter(s => s.name.includes(filter) || s.url.includes(filter))
|
|
42
|
+
: specs;
|
|
43
|
+
|
|
44
|
+
console.log(`[check-screenshots] checking ${toCheck.length} screenshots...`);
|
|
45
|
+
|
|
46
|
+
if (config.docker) {
|
|
47
|
+
await startDocker(config.docker, projectRoot).catch(e => {
|
|
48
|
+
console.error('[check-screenshots] docker start failed:', e.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// create one browser context shared across beforeAll and all captures
|
|
54
|
+
const { browser, context } = await createBrowserContext({
|
|
55
|
+
browser: config.browser,
|
|
56
|
+
width: config.window.width,
|
|
57
|
+
height: config.window.height,
|
|
58
|
+
colorScheme: config.colorScheme,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (config.beforeAll) {
|
|
62
|
+
await config.beforeAll(context).catch(e => {
|
|
63
|
+
console.error('[check-screenshots] beforeAll hook failed:', e.message);
|
|
64
|
+
browser.close();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const failures = [];
|
|
70
|
+
const missing = [];
|
|
71
|
+
let passed = 0;
|
|
72
|
+
|
|
73
|
+
for (const spec of toCheck) {
|
|
74
|
+
const fullUrl = spec.url.startsWith('http')
|
|
75
|
+
? spec.url
|
|
76
|
+
: `${config.baseUrl.replace(/\/$/, '')}${spec.url}`;
|
|
77
|
+
|
|
78
|
+
const referencePath = path.join(outputDir, `${spec.name}.png`);
|
|
79
|
+
process.stdout.write(` ${spec.name} ... `);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const rawBuffer = await capturePage({
|
|
83
|
+
url: fullUrl,
|
|
84
|
+
name: spec.name,
|
|
85
|
+
highlights: spec.highlights,
|
|
86
|
+
width: spec.width ?? config.window.width,
|
|
87
|
+
height: spec.height ?? config.window.height,
|
|
88
|
+
fullPage: spec.fullPage,
|
|
89
|
+
browser: config.browser,
|
|
90
|
+
colorScheme: config.colorScheme,
|
|
91
|
+
beforeScreenshot: config.beforeScreenshot,
|
|
92
|
+
context,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let newBuffer = rawBuffer;
|
|
96
|
+
if (config.chrome.style !== 'none') {
|
|
97
|
+
newBuffer = await addChrome(rawBuffer, {
|
|
98
|
+
url: fullUrl,
|
|
99
|
+
dark: config.chrome.dark,
|
|
100
|
+
showUrl: config.chrome.showUrl,
|
|
101
|
+
shadowBlur: config.chrome.shadowBlur,
|
|
102
|
+
shadowPadding: config.chrome.shadowPadding,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const result = await diffScreenshot(referencePath, newBuffer, {
|
|
107
|
+
threshold: args.pixelThreshold ?? 0.1,
|
|
108
|
+
writeDiff: true,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (result.pixels === -1) {
|
|
112
|
+
// reference file was missing
|
|
113
|
+
process.stdout.write('MISSING\n');
|
|
114
|
+
missing.push(spec.name);
|
|
115
|
+
// save the new capture as the diff artifact
|
|
116
|
+
writeFileSync(path.join(diffDir, `${spec.name}.new.png`), newBuffer);
|
|
117
|
+
} else if (result.ratio > (args.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO)) {
|
|
118
|
+
process.stdout.write(`CHANGED (${(result.ratio * 100).toFixed(2)}% pixels differ)\n`);
|
|
119
|
+
failures.push({ name: spec.name, ratio: result.ratio, pixels: result.pixels });
|
|
120
|
+
if (result.diffImage) {
|
|
121
|
+
writeFileSync(path.join(diffDir, `${spec.name}.diff.png`), result.diffImage);
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(path.join(diffDir, `${spec.name}.new.png`), newBuffer);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write('ok\n');
|
|
126
|
+
passed++;
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
process.stdout.write(`ERROR: ${e.message}\n`);
|
|
130
|
+
failures.push({ name: spec.name, ratio: 1, pixels: -1, error: e.message });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (config.afterAll) {
|
|
135
|
+
await config.afterAll().catch(e => console.warn('[check-screenshots] afterAll hook failed:', e.message));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await browser.close();
|
|
139
|
+
|
|
140
|
+
if (config.docker) {
|
|
141
|
+
stopDocker(config.docker, projectRoot);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(`\n[check-screenshots] ${passed} passed, ${failures.length} changed, ${missing.length} missing`);
|
|
145
|
+
|
|
146
|
+
if (missing.length > 0) {
|
|
147
|
+
console.log('\nMissing screenshots (run take-screenshots to generate):');
|
|
148
|
+
for (const name of missing) console.log(` - ${name}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (failures.length > 0) {
|
|
152
|
+
console.log('\nChanged screenshots (review diffs in ' + args.diffDir + '):');
|
|
153
|
+
for (const f of failures) {
|
|
154
|
+
if (f.error) console.log(` - ${f.name}: error (${f.error})`);
|
|
155
|
+
else console.log(` - ${f.name}: ${(f.ratio * 100).toFixed(2)}% pixels changed`);
|
|
156
|
+
}
|
|
157
|
+
console.log('\nIf changes are intentional, run take-screenshots to update the reference files.');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (missing.length > 0 && args.failOnMissing) {
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseArgs(argv) {
|
|
167
|
+
const args = {
|
|
168
|
+
filter: null,
|
|
169
|
+
diffDir: '.screenshot-diffs',
|
|
170
|
+
thresholdRatio: DEFAULT_THRESHOLD_RATIO,
|
|
171
|
+
pixelThreshold: 0.1,
|
|
172
|
+
failOnMissing: false,
|
|
173
|
+
};
|
|
174
|
+
for (let i = 0; i < argv.length; i++) {
|
|
175
|
+
if (argv[i] === '--fail-on-missing') args.failOnMissing = true;
|
|
176
|
+
if (argv[i] === '--filter' && argv[i + 1]) args.filter = argv[++i];
|
|
177
|
+
if (argv[i].startsWith('--filter=')) args.filter = argv[i].slice(9);
|
|
178
|
+
if (argv[i].startsWith('--diff-dir=')) args.diffDir = argv[i].slice(11);
|
|
179
|
+
if (argv[i] === '--diff-dir' && argv[i + 1]) args.diffDir = argv[++i];
|
|
180
|
+
if (argv[i].startsWith('--threshold=')) args.thresholdRatio = parseFloat(argv[i].slice(12));
|
|
181
|
+
}
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch(e => {
|
|
186
|
+
console.error('[check-screenshots] fatal:', e);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: take-screenshots
|
|
3
|
+
// discovers Screenshot components in the project, starts Docker, captures
|
|
4
|
+
// each page via Playwright, composites chrome, and writes PNGs to outputDir.
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { loadConfig } from 'astro-better-declarative-screenshots';
|
|
9
|
+
import { discoverScreenshots } from '../src/discover.mjs';
|
|
10
|
+
import { startDocker, stopDocker } from '../src/docker.mjs';
|
|
11
|
+
import { capturePage, createBrowserContext } from '../src/capture.mjs';
|
|
12
|
+
import { addChrome } from '../src/chrome.mjs';
|
|
13
|
+
import { generatePlaceholder } from '../src/placeholder.mjs';
|
|
14
|
+
|
|
15
|
+
const projectRoot = process.cwd();
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const args = parseArgs(process.argv.slice(2));
|
|
19
|
+
const config = await loadConfig(projectRoot).catch(e => {
|
|
20
|
+
console.error(e.message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const outputDir = path.resolve(projectRoot, config.outputDir);
|
|
25
|
+
mkdirSync(outputDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// discover all Screenshot components in the source
|
|
28
|
+
console.log(`[screenshots] scanning ${projectRoot} for Screenshot components...`);
|
|
29
|
+
const specs = await discoverScreenshots(projectRoot);
|
|
30
|
+
|
|
31
|
+
if (specs.length === 0) {
|
|
32
|
+
console.log('[screenshots] no Screenshot components found.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const filter = args.filter;
|
|
37
|
+
const toCapture = filter
|
|
38
|
+
? specs.filter(s => s.name.includes(filter) || s.url.includes(filter))
|
|
39
|
+
: specs;
|
|
40
|
+
|
|
41
|
+
console.log(`[screenshots] found ${specs.length} screenshots, capturing ${toCapture.length}`);
|
|
42
|
+
|
|
43
|
+
// start Docker if configured
|
|
44
|
+
if (config.docker) {
|
|
45
|
+
await startDocker(config.docker, projectRoot).catch(e => {
|
|
46
|
+
console.error('[screenshots] docker start failed:', e.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// create one browser context shared across beforeAll and all captures so
|
|
52
|
+
// session cookies from login are preserved throughout
|
|
53
|
+
const { browser, context } = await createBrowserContext({
|
|
54
|
+
browser: config.browser,
|
|
55
|
+
width: config.window.width,
|
|
56
|
+
height: config.window.height,
|
|
57
|
+
colorScheme: config.colorScheme,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (config.beforeAll) {
|
|
61
|
+
console.log('[screenshots] running beforeAll hook');
|
|
62
|
+
await config.beforeAll(context).catch(e => {
|
|
63
|
+
console.error('[screenshots] beforeAll hook failed:', e.message);
|
|
64
|
+
browser.close();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const errors = [];
|
|
70
|
+
let captured = 0;
|
|
71
|
+
|
|
72
|
+
for (const spec of toCapture) {
|
|
73
|
+
const fullUrl = spec.url.startsWith('http')
|
|
74
|
+
? spec.url
|
|
75
|
+
: `${config.baseUrl.replace(/\/$/, '')}${spec.url}`;
|
|
76
|
+
|
|
77
|
+
const outputPath = path.join(outputDir, `${spec.name}.png`);
|
|
78
|
+
process.stdout.write(` [${++captured}/${toCapture.length}] ${spec.name} ... `);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const rawBuffer = await capturePage({
|
|
82
|
+
url: fullUrl,
|
|
83
|
+
name: spec.name,
|
|
84
|
+
highlights: spec.highlights,
|
|
85
|
+
width: spec.width ?? config.window.width,
|
|
86
|
+
height: spec.height ?? config.window.height,
|
|
87
|
+
fullPage: spec.fullPage,
|
|
88
|
+
browser: config.browser,
|
|
89
|
+
colorScheme: config.colorScheme,
|
|
90
|
+
beforeScreenshot: config.beforeScreenshot,
|
|
91
|
+
context,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let finalBuffer = rawBuffer;
|
|
95
|
+
|
|
96
|
+
if (config.chrome.style !== 'none' && config.chrome.renderIn !== 'css') {
|
|
97
|
+
finalBuffer = await addChrome(rawBuffer, {
|
|
98
|
+
url: fullUrl,
|
|
99
|
+
dark: config.chrome.theme === 'dark',
|
|
100
|
+
showUrl: config.chrome.showUrl,
|
|
101
|
+
shadowBlur: config.chrome.shadowBlur,
|
|
102
|
+
shadowPadding: config.chrome.shadowPadding,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
writeFileSync(outputPath, finalBuffer);
|
|
107
|
+
process.stdout.write('done\n');
|
|
108
|
+
} catch (e) {
|
|
109
|
+
process.stdout.write(`FAILED: ${e.message}\n`);
|
|
110
|
+
errors.push({ spec, error: e });
|
|
111
|
+
|
|
112
|
+
if (args.strict || config.strict) {
|
|
113
|
+
// write a placeholder so the build doesn't break
|
|
114
|
+
try {
|
|
115
|
+
const placeholder = await generatePlaceholder({
|
|
116
|
+
name: spec.name,
|
|
117
|
+
width: spec.width ?? config.window.width,
|
|
118
|
+
height: spec.height ?? config.window.height,
|
|
119
|
+
});
|
|
120
|
+
writeFileSync(outputPath, placeholder);
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (config.afterAll) {
|
|
127
|
+
await config.afterAll().catch(e => {
|
|
128
|
+
console.warn('[screenshots] afterAll hook failed:', e.message);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await browser.close();
|
|
133
|
+
|
|
134
|
+
if (config.docker) {
|
|
135
|
+
stopDocker(config.docker, projectRoot);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (errors.length > 0) {
|
|
139
|
+
console.error(`\n[screenshots] ${errors.length} screenshot(s) failed:`);
|
|
140
|
+
for (const { spec, error } of errors) {
|
|
141
|
+
console.error(` - ${spec.name}: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (args.strict) process.exit(1);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`\n[screenshots] all ${captured} screenshots saved to ${config.outputDir}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseArgs(argv) {
|
|
150
|
+
const args = { strict: false, filter: null };
|
|
151
|
+
for (let i = 0; i < argv.length; i++) {
|
|
152
|
+
if (argv[i] === '--strict') args.strict = true;
|
|
153
|
+
if (argv[i] === '--filter' && argv[i + 1]) args.filter = argv[++i];
|
|
154
|
+
if (argv[i].startsWith('--filter=')) args.filter = argv[i].slice(9);
|
|
155
|
+
}
|
|
156
|
+
return args;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch(e => {
|
|
160
|
+
console.error('[screenshots] fatal:', e);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Playwright-based page capture. opens the page, runs beforeScreenshot hooks,
|
|
2
|
+
// injects highlights, takes the screenshot, then cleans up.
|
|
3
|
+
|
|
4
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
5
|
+
import { injectHighlights, scrollIntoView } from 'astro-better-declarative-screenshots';
|
|
6
|
+
|
|
7
|
+
const BROWSERS = { webkit, chromium, firefox };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} CaptureOptions
|
|
11
|
+
* @property {string} url - full URL including host
|
|
12
|
+
* @property {string} name - screenshot name (for logging)
|
|
13
|
+
* @property {Array<import('astro-better-declarative-screenshots').HighlightSpec>} highlights
|
|
14
|
+
* @property {number} [width=1280]
|
|
15
|
+
* @property {number} [height=800]
|
|
16
|
+
* @property {boolean} [fullPage=false]
|
|
17
|
+
* @property {'webkit'|'chromium'|'firefox'} [browser='webkit']
|
|
18
|
+
* @property {'light'|'dark'} [colorScheme='light']
|
|
19
|
+
* @property {Function} [beforeScreenshot]
|
|
20
|
+
* @property {import('playwright').BrowserContext} [context] - reuse an existing context
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Capture a single screenshot and return the PNG buffer.
|
|
25
|
+
*
|
|
26
|
+
* @param {CaptureOptions} opts
|
|
27
|
+
* @returns {Promise<Buffer>}
|
|
28
|
+
*/
|
|
29
|
+
export async function capturePage(opts) {
|
|
30
|
+
const {
|
|
31
|
+
url,
|
|
32
|
+
name,
|
|
33
|
+
highlights = [],
|
|
34
|
+
width = 1280,
|
|
35
|
+
height = 800,
|
|
36
|
+
fullPage = false,
|
|
37
|
+
browser: browserName = 'webkit',
|
|
38
|
+
colorScheme = 'light',
|
|
39
|
+
beforeScreenshot,
|
|
40
|
+
context: existingContext,
|
|
41
|
+
} = opts;
|
|
42
|
+
|
|
43
|
+
let browser;
|
|
44
|
+
let context = existingContext;
|
|
45
|
+
let page;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (!context) {
|
|
49
|
+
browser = await BROWSERS[browserName].launch({ headless: true });
|
|
50
|
+
context = await browser.newContext({
|
|
51
|
+
viewport: { width, height },
|
|
52
|
+
colorScheme,
|
|
53
|
+
deviceScaleFactor: 2,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
page = await context.newPage();
|
|
58
|
+
await page.setViewportSize({ width, height });
|
|
59
|
+
|
|
60
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
61
|
+
|
|
62
|
+
if (beforeScreenshot) {
|
|
63
|
+
await beforeScreenshot(page, { url, name, highlights });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (fullPage) {
|
|
67
|
+
// full-page captures render from y=0; scrolling first would displace sticky/fixed elements
|
|
68
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
69
|
+
} else if (highlights.length > 0 && highlights[0].selector) {
|
|
70
|
+
await scrollIntoView(page, highlights[0].selector).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cleanup = await injectHighlights(page, highlights);
|
|
74
|
+
|
|
75
|
+
// small pause to let any CSS transitions finish after highlight injection
|
|
76
|
+
await page.waitForTimeout(150);
|
|
77
|
+
|
|
78
|
+
const buffer = await page.screenshot({
|
|
79
|
+
type: 'png',
|
|
80
|
+
fullPage,
|
|
81
|
+
animations: 'disabled',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await cleanup();
|
|
85
|
+
await page.close();
|
|
86
|
+
|
|
87
|
+
return buffer;
|
|
88
|
+
} finally {
|
|
89
|
+
if (browser) await browser.close().catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a persistent browser context for taking multiple screenshots.
|
|
95
|
+
* Caller is responsible for calling context.close() when done.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {'webkit'|'chromium'|'firefox'} [opts.browser='webkit']
|
|
99
|
+
* @param {number} [opts.width=1280]
|
|
100
|
+
* @param {number} [opts.height=800]
|
|
101
|
+
* @param {'light'|'dark'} [opts.colorScheme='light']
|
|
102
|
+
* @returns {Promise<{browser: import('playwright').Browser, context: import('playwright').BrowserContext}>}
|
|
103
|
+
*/
|
|
104
|
+
export async function createBrowserContext({ browser: browserName = 'webkit', width = 1280, height = 800, colorScheme = 'light' } = {}) {
|
|
105
|
+
const browser = await BROWSERS[browserName].launch({ headless: true });
|
|
106
|
+
const context = await browser.newContext({
|
|
107
|
+
viewport: { width, height },
|
|
108
|
+
colorScheme,
|
|
109
|
+
deviceScaleFactor: 2,
|
|
110
|
+
});
|
|
111
|
+
return { browser, context };
|
|
112
|
+
}
|
package/src/chrome.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// composes a macOS Safari-style window chrome around a screenshot buffer.
|
|
2
|
+
// everything is generated programmatically via sharp + SVG -- no PNG templates.
|
|
3
|
+
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
|
|
6
|
+
const WINDOW_RADIUS = 10;
|
|
7
|
+
const CHROME_HEIGHT = 52;
|
|
8
|
+
const TL_CY = 26;
|
|
9
|
+
const TL_X0 = 18;
|
|
10
|
+
const TL_R = 7;
|
|
11
|
+
const TL_GAP = 9;
|
|
12
|
+
const URL_BAR_H = 28;
|
|
13
|
+
const URL_BAR_Y = (CHROME_HEIGHT - URL_BAR_H) / 2; // vertically centered
|
|
14
|
+
const URL_BAR_X = 100;
|
|
15
|
+
const URL_BAR_X_MARGIN_R = 20;
|
|
16
|
+
const URL_BAR_RADIUS = 5;
|
|
17
|
+
|
|
18
|
+
export async function addChrome(screenshotBuffer, opts = {}) {
|
|
19
|
+
const {
|
|
20
|
+
url = '',
|
|
21
|
+
dark = false,
|
|
22
|
+
showUrl = true,
|
|
23
|
+
shadowBlur = 40,
|
|
24
|
+
shadowPadding = 48,
|
|
25
|
+
} = opts;
|
|
26
|
+
|
|
27
|
+
const pageImg = sharp(screenshotBuffer);
|
|
28
|
+
const { width: pageWidth, height: pageHeight } = await pageImg.metadata();
|
|
29
|
+
|
|
30
|
+
const chromeHeight = showUrl ? CHROME_HEIGHT : Math.floor(CHROME_HEIGHT * 0.6);
|
|
31
|
+
const totalWidth = pageWidth + (shadowBlur > 0 ? shadowPadding * 2 : 0);
|
|
32
|
+
const totalHeight = pageHeight + chromeHeight + (shadowBlur > 0 ? shadowPadding * 2 : 0);
|
|
33
|
+
|
|
34
|
+
const windowX = shadowBlur > 0 ? shadowPadding : 0;
|
|
35
|
+
const windowY = shadowBlur > 0 ? shadowPadding : 0;
|
|
36
|
+
const windowWidth = pageWidth;
|
|
37
|
+
const windowHeight = pageHeight + chromeHeight;
|
|
38
|
+
|
|
39
|
+
const chromeSvg = buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url });
|
|
40
|
+
|
|
41
|
+
const chromeBuf = await sharp(Buffer.from(chromeSvg))
|
|
42
|
+
.resize(windowWidth, chromeHeight)
|
|
43
|
+
.png()
|
|
44
|
+
.toBuffer();
|
|
45
|
+
|
|
46
|
+
// round the bottom corners of the page to match the window border radius
|
|
47
|
+
const cornerMask = `<svg xmlns="http://www.w3.org/2000/svg" width="${pageWidth}" height="${pageHeight}">
|
|
48
|
+
<path d="M 0,0 H ${pageWidth} V ${pageHeight - WINDOW_RADIUS}
|
|
49
|
+
Q ${pageWidth},${pageHeight} ${pageWidth - WINDOW_RADIUS},${pageHeight}
|
|
50
|
+
H ${WINDOW_RADIUS} Q 0,${pageHeight} 0,${pageHeight - WINDOW_RADIUS} Z"
|
|
51
|
+
fill="white"/>
|
|
52
|
+
</svg>`;
|
|
53
|
+
const maskedPage = await sharp(screenshotBuffer)
|
|
54
|
+
.composite([{ input: Buffer.from(cornerMask), blend: 'dest-in' }])
|
|
55
|
+
.png()
|
|
56
|
+
.toBuffer();
|
|
57
|
+
|
|
58
|
+
const layers = [];
|
|
59
|
+
|
|
60
|
+
if (shadowBlur > 0) {
|
|
61
|
+
const shadowSvg = buildShadowSvg({
|
|
62
|
+
totalWidth, totalHeight, windowX, windowY,
|
|
63
|
+
windowWidth, windowHeight, shadowBlur,
|
|
64
|
+
});
|
|
65
|
+
const shadowBuf = await sharp(Buffer.from(shadowSvg))
|
|
66
|
+
.resize(totalWidth, totalHeight)
|
|
67
|
+
.png()
|
|
68
|
+
.toBuffer();
|
|
69
|
+
layers.push({ input: shadowBuf, top: 0, left: 0 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
layers.push({ input: maskedPage, top: windowY + chromeHeight, left: windowX });
|
|
73
|
+
layers.push({ input: chromeBuf, top: windowY, left: windowX });
|
|
74
|
+
|
|
75
|
+
const base = {
|
|
76
|
+
create: {
|
|
77
|
+
width: totalWidth,
|
|
78
|
+
height: totalHeight,
|
|
79
|
+
channels: 4,
|
|
80
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return sharp(base).composite(layers).png().toBuffer();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url }) {
|
|
88
|
+
const bg = dark ? '#323232' : '#e8e8e8';
|
|
89
|
+
const bgBottom = dark ? '#2c2c2c' : '#dcdcdc';
|
|
90
|
+
const urlBarBg = dark ? '#454547' : '#ffffff';
|
|
91
|
+
const urlBorder = dark ? '#5a5a5c' : '#cacaca';
|
|
92
|
+
const urlText = dark ? '#f0f0f0' : '#111111';
|
|
93
|
+
const separator = dark ? '#1a1a1a' : '#b8b8b8';
|
|
94
|
+
|
|
95
|
+
const tlColors = ['#ff5f57', '#febc2e', '#28c840'];
|
|
96
|
+
const lights = tlColors.map((color, i) => {
|
|
97
|
+
const cx = TL_X0 + i * (TL_R * 2 + TL_GAP);
|
|
98
|
+
return `<circle cx="${cx}" cy="${TL_CY}" r="${TL_R}" fill="${color}"/>`;
|
|
99
|
+
}).join('\n ');
|
|
100
|
+
|
|
101
|
+
let urlBar = '';
|
|
102
|
+
if (showUrl) {
|
|
103
|
+
const barX = URL_BAR_X;
|
|
104
|
+
const barY = URL_BAR_Y;
|
|
105
|
+
const barW = windowWidth - URL_BAR_X - URL_BAR_X_MARGIN_R;
|
|
106
|
+
const displayUrl = url.length > 90 ? url.slice(0, 87) + '...' : url;
|
|
107
|
+
urlBar = `
|
|
108
|
+
<rect x="${barX}" y="${barY}" width="${barW}" height="${URL_BAR_H}"
|
|
109
|
+
rx="${URL_BAR_RADIUS}" fill="${urlBarBg}" stroke="${urlBorder}" stroke-width="1"/>
|
|
110
|
+
<text
|
|
111
|
+
x="${barX + barW / 2}"
|
|
112
|
+
y="${barY + URL_BAR_H / 2 + 5}"
|
|
113
|
+
text-anchor="middle"
|
|
114
|
+
font-family="Arial, sans-serif"
|
|
115
|
+
font-size="13"
|
|
116
|
+
fill="${urlText}"
|
|
117
|
+
>${escapeXml(displayUrl)}</text>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// gradient: slightly lighter top, slightly darker bottom
|
|
121
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${windowWidth}" height="${chromeHeight}">
|
|
122
|
+
<defs>
|
|
123
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
124
|
+
<stop offset="0%" stop-color="${bg}"/>
|
|
125
|
+
<stop offset="100%" stop-color="${bgBottom}"/>
|
|
126
|
+
</linearGradient>
|
|
127
|
+
</defs>
|
|
128
|
+
<rect width="${windowWidth}" height="${chromeHeight}" fill="url(#bg)" rx="${WINDOW_RADIUS}" ry="${WINDOW_RADIUS}"/>
|
|
129
|
+
<rect x="0" y="${chromeHeight - WINDOW_RADIUS}" width="${windowWidth}" height="${WINDOW_RADIUS}" fill="${bgBottom}"/>
|
|
130
|
+
<line x1="0" y1="${chromeHeight - 0.5}" x2="${windowWidth}" y2="${chromeHeight - 0.5}"
|
|
131
|
+
stroke="${separator}" stroke-width="1"/>
|
|
132
|
+
${lights}
|
|
133
|
+
${urlBar}
|
|
134
|
+
</svg>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildShadowSvg({ totalWidth, totalHeight, windowX, windowY, windowWidth, windowHeight, shadowBlur }) {
|
|
138
|
+
const halfBlur = shadowBlur / 2;
|
|
139
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
140
|
+
<defs>
|
|
141
|
+
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
142
|
+
<feDropShadow
|
|
143
|
+
dx="0" dy="${halfBlur / 2}"
|
|
144
|
+
stdDeviation="${halfBlur}"
|
|
145
|
+
flood-color="rgba(0,0,0,0.35)"
|
|
146
|
+
/>
|
|
147
|
+
</filter>
|
|
148
|
+
</defs>
|
|
149
|
+
<rect
|
|
150
|
+
x="${windowX}" y="${windowY}"
|
|
151
|
+
width="${windowWidth}" height="${windowHeight}"
|
|
152
|
+
rx="${WINDOW_RADIUS}" ry="${WINDOW_RADIUS}"
|
|
153
|
+
fill="white"
|
|
154
|
+
filter="url(#shadow)"
|
|
155
|
+
/>
|
|
156
|
+
</svg>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function escapeXml(str) {
|
|
160
|
+
return String(str)
|
|
161
|
+
.replace(/&/g, '&')
|
|
162
|
+
.replace(/</g, '<')
|
|
163
|
+
.replace(/>/g, '>')
|
|
164
|
+
.replace(/"/g, '"')
|
|
165
|
+
.replace(/'/g, ''');
|
|
166
|
+
}
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// pixel-level diff between an existing screenshot and a newly captured one.
|
|
2
|
+
// used by check-screenshots to detect visual regressions in CI.
|
|
3
|
+
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
import pixelmatch from 'pixelmatch';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} DiffResult
|
|
11
|
+
* @property {number} pixels - total number of differing pixels
|
|
12
|
+
* @property {number} ratio - differing pixels / total pixels (0..1)
|
|
13
|
+
* @property {Buffer|null} diffImage - PNG buffer visualizing the diff, or null if identical
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compare a newly captured PNG buffer against the on-disk reference file.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} referencePath - absolute path to the reference PNG
|
|
20
|
+
* @param {Buffer} newBuffer - PNG buffer from a fresh capture
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {number} [opts.threshold=0.1] - pixelmatch threshold (0 = strict, 1 = lenient)
|
|
23
|
+
* @param {boolean} [opts.writeDiff=false] - include the diff image buffer in the result
|
|
24
|
+
* @returns {Promise<DiffResult>}
|
|
25
|
+
*/
|
|
26
|
+
export async function diffScreenshot(referencePath, newBuffer, opts = {}) {
|
|
27
|
+
const { threshold = 0.1, writeDiff = false } = opts;
|
|
28
|
+
|
|
29
|
+
let refBuffer;
|
|
30
|
+
try {
|
|
31
|
+
refBuffer = readFileSync(referencePath);
|
|
32
|
+
} catch {
|
|
33
|
+
// reference doesn't exist -- treat as fully different
|
|
34
|
+
return { pixels: -1, ratio: 1, diffImage: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// normalize both images to the same dimensions before diffing
|
|
38
|
+
const [refNorm, newNorm] = await Promise.all([
|
|
39
|
+
normalizeSize(refBuffer),
|
|
40
|
+
normalizeSize(newBuffer),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const refPng = PNG.sync.read(refNorm);
|
|
44
|
+
const newPng = PNG.sync.read(newNorm);
|
|
45
|
+
|
|
46
|
+
// if dimensions differ after normalization, sizes changed -- treat as fully different
|
|
47
|
+
if (refPng.width !== newPng.width || refPng.height !== newPng.height) {
|
|
48
|
+
return {
|
|
49
|
+
pixels: refPng.width * refPng.height,
|
|
50
|
+
ratio: 1,
|
|
51
|
+
diffImage: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { width, height } = refPng;
|
|
56
|
+
const totalPixels = width * height;
|
|
57
|
+
const diffPng = writeDiff ? new PNG({ width, height }) : null;
|
|
58
|
+
|
|
59
|
+
const diffPixels = pixelmatch(
|
|
60
|
+
refPng.data,
|
|
61
|
+
newPng.data,
|
|
62
|
+
diffPng?.data ?? null,
|
|
63
|
+
width,
|
|
64
|
+
height,
|
|
65
|
+
{ threshold }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const diffImage = diffPng ? PNG.sync.write(diffPng) : null;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
pixels: diffPixels,
|
|
72
|
+
ratio: diffPixels / totalPixels,
|
|
73
|
+
diffImage,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function normalizeSize(buffer) {
|
|
78
|
+
// re-encode via sharp to ensure consistent PNG format and strip metadata
|
|
79
|
+
return sharp(buffer).png().toBuffer();
|
|
80
|
+
}
|
package/src/discover.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// scans MDX/Astro source files for Screenshot components and extracts their specs.
|
|
2
|
+
// this is how the CLI knows what screenshots to take without running the full Astro build.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { glob } from 'glob';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { deriveName } from 'astro-better-declarative-screenshots';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} HighlightSpec
|
|
11
|
+
* @property {string} selector
|
|
12
|
+
* @property {'border'|'arrow'|'both'} style
|
|
13
|
+
* @property {string} color
|
|
14
|
+
* @property {string} label
|
|
15
|
+
* @property {number} borderWidth
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} ScreenshotSpec
|
|
20
|
+
* @property {string} url
|
|
21
|
+
* @property {string} name - derived output filename without extension
|
|
22
|
+
* @property {HighlightSpec[]} highlights
|
|
23
|
+
* @property {number|undefined} width
|
|
24
|
+
* @property {number|undefined} height
|
|
25
|
+
* @property {boolean} fullPage
|
|
26
|
+
* @property {string} sourceFile - absolute path of the file containing this Screenshot
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Scan a project's source files and return all Screenshot specs.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
33
|
+
* @param {string[]} [patterns] - glob patterns relative to projectRoot; default scans src/**
|
|
34
|
+
* @returns {Promise<ScreenshotSpec[]>}
|
|
35
|
+
*/
|
|
36
|
+
export async function discoverScreenshots(projectRoot, patterns) {
|
|
37
|
+
const defaultPatterns = ['src/**/*.mdx', 'src/**/*.md', 'src/**/*.astro'];
|
|
38
|
+
const globs = (patterns ?? defaultPatterns).map(p => path.join(projectRoot, p));
|
|
39
|
+
|
|
40
|
+
const files = (
|
|
41
|
+
await Promise.all(globs.map(pattern => glob(pattern, { absolute: true })))
|
|
42
|
+
).flat();
|
|
43
|
+
|
|
44
|
+
const used = new Set();
|
|
45
|
+
const specs = [];
|
|
46
|
+
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const fileSpecs = parseFile(file, used);
|
|
49
|
+
specs.push(...fileSpecs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return specs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseFile(filePath, used) {
|
|
56
|
+
let source;
|
|
57
|
+
try {
|
|
58
|
+
source = readFileSync(filePath, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const specs = [];
|
|
64
|
+
|
|
65
|
+
// find all <Screenshot ...> blocks, including multiline
|
|
66
|
+
// we look for <Screenshot, then collect attributes until we see > or />
|
|
67
|
+
// then look for Highlight children until </Screenshot>
|
|
68
|
+
const screenshotRe = /<Screenshot\b([\s\S]*?)(?:\/>|>([\s\S]*?)<\/Screenshot>)/g;
|
|
69
|
+
let match;
|
|
70
|
+
|
|
71
|
+
while ((match = screenshotRe.exec(source)) !== null) {
|
|
72
|
+
const attrBlock = match[1];
|
|
73
|
+
const childBlock = match[2] ?? '';
|
|
74
|
+
|
|
75
|
+
const url = extractProp(attrBlock, 'url');
|
|
76
|
+
if (!url) continue;
|
|
77
|
+
|
|
78
|
+
const id = extractProp(attrBlock, 'id');
|
|
79
|
+
const widthStr = extractProp(attrBlock, 'width');
|
|
80
|
+
const heightStr = extractProp(attrBlock, 'height');
|
|
81
|
+
const fullPageStr = extractProp(attrBlock, 'fullPage');
|
|
82
|
+
|
|
83
|
+
const highlights = parseHighlightChildren(childBlock);
|
|
84
|
+
const name = id ?? deriveName(url, highlights, used);
|
|
85
|
+
|
|
86
|
+
specs.push({
|
|
87
|
+
url,
|
|
88
|
+
name,
|
|
89
|
+
highlights,
|
|
90
|
+
width: widthStr ? parseInt(widthStr, 10) : undefined,
|
|
91
|
+
height: heightStr ? parseInt(heightStr, 10) : undefined,
|
|
92
|
+
fullPage: fullPageStr === 'true' || fullPageStr === '{true}',
|
|
93
|
+
sourceFile: filePath,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return specs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractProp(attrBlock, propName) {
|
|
101
|
+
// matches: propName="value" or propName={'value'} or propName={value}
|
|
102
|
+
const re = new RegExp(
|
|
103
|
+
`${propName}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)'|\\{['"](.*?)['"]}|\\{([^}]+?)\\})`,
|
|
104
|
+
's'
|
|
105
|
+
);
|
|
106
|
+
const m = re.exec(attrBlock);
|
|
107
|
+
if (!m) return undefined;
|
|
108
|
+
return (m[1] ?? m[2] ?? m[3] ?? m[4] ?? '').trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseHighlightChildren(childBlock) {
|
|
112
|
+
const highlights = [];
|
|
113
|
+
const highlightRe = /<Highlight\b([^>]*?)(?:\/>|>[\s\S]*?<\/Highlight>)/g;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = highlightRe.exec(childBlock)) !== null) {
|
|
116
|
+
const attrs = m[1];
|
|
117
|
+
const selector = extractProp(attrs, 'selector');
|
|
118
|
+
if (!selector) continue;
|
|
119
|
+
highlights.push({
|
|
120
|
+
selector,
|
|
121
|
+
style: extractProp(attrs, 'style') ?? 'border',
|
|
122
|
+
color: extractProp(attrs, 'color') ?? '#f60',
|
|
123
|
+
label: extractProp(attrs, 'label') ?? '',
|
|
124
|
+
borderWidth: parseInt(extractProp(attrs, 'borderWidth') ?? '3', 10),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return highlights;
|
|
128
|
+
}
|
package/src/docker.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// manages the Docker container lifecycle for screenshot sessions.
|
|
2
|
+
// starts the container, optionally loads a bootstrap/kickstart file, polls
|
|
3
|
+
// a health check URL until the app is ready, then runs an optional postStart command.
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start the Docker service described in config.docker and wait for it to be healthy.
|
|
11
|
+
* After the healthcheck passes, runs docker.postStart if defined.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('astro-better-declarative-screenshots').DockerConfig} dockerConfig
|
|
14
|
+
* @param {string} projectRoot
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
export async function startDocker(dockerConfig, projectRoot) {
|
|
18
|
+
if (!dockerConfig) return;
|
|
19
|
+
|
|
20
|
+
const composePath = dockerConfig.compose
|
|
21
|
+
? path.resolve(projectRoot, dockerConfig.compose)
|
|
22
|
+
: null;
|
|
23
|
+
|
|
24
|
+
if (composePath) {
|
|
25
|
+
// support both 'bootstrap' (preferred) and 'kickstart' (legacy alias)
|
|
26
|
+
const bootstrapPath = dockerConfig.bootstrap ?? dockerConfig.kickstart;
|
|
27
|
+
const env = buildEnv(dockerConfig.env ?? {}, bootstrapPath, projectRoot);
|
|
28
|
+
const serviceArg = dockerConfig.service ? ` ${dockerConfig.service}` : '';
|
|
29
|
+
|
|
30
|
+
console.log(`[docker] starting services via ${path.relative(projectRoot, composePath)}`);
|
|
31
|
+
execSync(
|
|
32
|
+
`docker compose -f "${composePath}" up -d${serviceArg}`,
|
|
33
|
+
{ stdio: 'inherit', env: { ...process.env, ...env } }
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`[docker] waiting for ${dockerConfig.healthcheck.url}`);
|
|
38
|
+
await pollHealthcheck(dockerConfig.healthcheck);
|
|
39
|
+
console.log('[docker] app is ready');
|
|
40
|
+
|
|
41
|
+
if (dockerConfig.postStart) {
|
|
42
|
+
console.log(`[docker] running postStart: ${dockerConfig.postStart}`);
|
|
43
|
+
execSync(dockerConfig.postStart, {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
cwd: projectRoot,
|
|
46
|
+
env: process.env,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stop containers started for this session.
|
|
53
|
+
* Does NOT remove volumes -- use `docker compose down -v` manually to reset seed data.
|
|
54
|
+
*
|
|
55
|
+
* @param {import('astro-better-declarative-screenshots').DockerConfig} dockerConfig
|
|
56
|
+
* @param {string} projectRoot
|
|
57
|
+
*/
|
|
58
|
+
export function stopDocker(dockerConfig, projectRoot) {
|
|
59
|
+
if (!dockerConfig?.compose) return;
|
|
60
|
+
|
|
61
|
+
const composePath = path.resolve(projectRoot, dockerConfig.compose);
|
|
62
|
+
console.log('[docker] stopping services');
|
|
63
|
+
try {
|
|
64
|
+
execSync(`docker compose -f "${composePath}" down`, { stdio: 'inherit' });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.warn('[docker] warning: docker compose down failed:', e.message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function pollHealthcheck({ url, timeout = 60000, interval = 2000 }) {
|
|
71
|
+
const deadline = Date.now() + timeout;
|
|
72
|
+
let lastError;
|
|
73
|
+
let attempts = 0;
|
|
74
|
+
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
attempts++;
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(interval) });
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
if (attempts > 1) process.stdout.write('\n');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
lastError = e;
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write('.');
|
|
88
|
+
await sleep(interval);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
process.stdout.write('\n');
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[docker] health check timed out after ${timeout}ms waiting for ${url}` +
|
|
94
|
+
(lastError ? `: ${lastError.message}` : '')
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildEnv(extraEnv, bootstrapPath, projectRoot) {
|
|
99
|
+
const env = { ...extraEnv };
|
|
100
|
+
if (bootstrapPath) {
|
|
101
|
+
const absPath = path.resolve(projectRoot, bootstrapPath);
|
|
102
|
+
let content;
|
|
103
|
+
try {
|
|
104
|
+
content = readFileSync(absPath, 'utf8');
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`[docker] bootstrap file not found: ${absPath}`);
|
|
107
|
+
}
|
|
108
|
+
// expose bootstrap content and path as env vars.
|
|
109
|
+
// the docker-compose.yml can wire these into the container however it needs.
|
|
110
|
+
// for FusionAuth: set FUSIONAUTH_APP_KICKSTART_FILE to SCREENSHOT_BOOTSTRAP_PATH.
|
|
111
|
+
// for other apps: mount the file or use the content as a seed script input.
|
|
112
|
+
env.SCREENSHOT_BOOTSTRAP_CONTENT = content;
|
|
113
|
+
env.SCREENSHOT_BOOTSTRAP_PATH = absPath;
|
|
114
|
+
// legacy alias for backward compat
|
|
115
|
+
env.SCREENSHOT_KICKSTART_CONTENT = content;
|
|
116
|
+
env.SCREENSHOT_KICKSTART_PATH = absPath;
|
|
117
|
+
}
|
|
118
|
+
return env;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
123
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// generates a placeholder PNG for a screenshot that has not yet been taken.
|
|
2
|
+
// the placeholder is gray with white centered text showing the filename.
|
|
3
|
+
// sharp is used so the placeholder is a real PNG, not a data URI.
|
|
4
|
+
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.name - screenshot name (without extension), shown as label
|
|
10
|
+
* @param {number} [opts.width=1280]
|
|
11
|
+
* @param {number} [opts.height=800]
|
|
12
|
+
* @returns {Promise<Buffer>} PNG buffer
|
|
13
|
+
*/
|
|
14
|
+
export async function generatePlaceholder({ name, width = 1280, height = 800 }) {
|
|
15
|
+
const label = name + '.png';
|
|
16
|
+
const fontSize = Math.min(20, Math.floor(width / 40));
|
|
17
|
+
|
|
18
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
19
|
+
<rect width="${width}" height="${height}" fill="#e8e8e8"/>
|
|
20
|
+
<line x1="0" y1="0" x2="${width}" y2="${height}" stroke="#ccc" stroke-width="1"/>
|
|
21
|
+
<line x1="${width}" y1="0" x2="0" y2="${height}" stroke="#ccc" stroke-width="1"/>
|
|
22
|
+
<rect x="${width / 2 - 200}" y="${height / 2 - 40}" width="400" height="80" rx="8" fill="white" opacity="0.8"/>
|
|
23
|
+
<text
|
|
24
|
+
x="${width / 2}"
|
|
25
|
+
y="${height / 2 - 6}"
|
|
26
|
+
text-anchor="middle"
|
|
27
|
+
dominant-baseline="middle"
|
|
28
|
+
font-family="sans-serif"
|
|
29
|
+
font-size="${fontSize}"
|
|
30
|
+
fill="#666"
|
|
31
|
+
>Screenshot not generated</text>
|
|
32
|
+
<text
|
|
33
|
+
x="${width / 2}"
|
|
34
|
+
y="${height / 2 + fontSize + 4}"
|
|
35
|
+
text-anchor="middle"
|
|
36
|
+
dominant-baseline="middle"
|
|
37
|
+
font-family="monospace"
|
|
38
|
+
font-size="${Math.max(10, fontSize - 4)}"
|
|
39
|
+
fill="#999"
|
|
40
|
+
>${escapeXml(label)}</text>
|
|
41
|
+
</svg>`;
|
|
42
|
+
|
|
43
|
+
return sharp(Buffer.from(svg)).png().toBuffer();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeXml(str) {
|
|
47
|
+
return str
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''');
|
|
53
|
+
}
|