astro-better-declarative-screenshots 0.1.0
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/.github/workflows/check-screenshots.yml +68 -0
- package/CONFIGURATION.md +251 -0
- package/Highlight.astro +31 -0
- package/README.md +141 -0
- package/Screenshot.astro +131 -0
- package/example/groups.mdx +33 -0
- package/example/screenshot.config.mjs +72 -0
- package/index.mjs +56 -0
- package/package.json +36 -0
- package/scripts/check-screenshots.mjs +188 -0
- package/scripts/take-screenshots.mjs +162 -0
- package/src/capture.mjs +108 -0
- package/src/chrome.mjs +153 -0
- package/src/config.mjs +122 -0
- package/src/diff.mjs +80 -0
- package/src/discover.mjs +128 -0
- package/src/docker.mjs +123 -0
- package/src/highlight.mjs +133 -0
- package/src/naming.mjs +78 -0
- package/src/placeholder.mjs +53 -0
- package/tests/chrome.test.mjs +61 -0
- package/tests/config.test.mjs +96 -0
- package/tests/diff.test.mjs +85 -0
- package/tests/discover.test.mjs +118 -0
- package/tests/naming.test.mjs +72 -0
- package/tests/placeholder.test.mjs +35 -0
- package/vitest.config.mjs +12 -0
package/src/chrome.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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 CHROME_HEIGHT = 52;
|
|
7
|
+
const TL_CY = 26;
|
|
8
|
+
const TL_X0 = 18;
|
|
9
|
+
const TL_R = 7;
|
|
10
|
+
const TL_GAP = 9;
|
|
11
|
+
const URL_BAR_H = 28;
|
|
12
|
+
const URL_BAR_Y = (CHROME_HEIGHT - URL_BAR_H) / 2; // vertically centered
|
|
13
|
+
const URL_BAR_X = 100;
|
|
14
|
+
const URL_BAR_X_MARGIN_R = 20;
|
|
15
|
+
const URL_BAR_RADIUS = 5;
|
|
16
|
+
|
|
17
|
+
export async function addChrome(screenshotBuffer, opts = {}) {
|
|
18
|
+
const {
|
|
19
|
+
url = '',
|
|
20
|
+
dark = false,
|
|
21
|
+
showUrl = true,
|
|
22
|
+
shadowBlur = 40,
|
|
23
|
+
shadowPadding = 48,
|
|
24
|
+
} = opts;
|
|
25
|
+
|
|
26
|
+
const pageImg = sharp(screenshotBuffer);
|
|
27
|
+
const { width: pageWidth, height: pageHeight } = await pageImg.metadata();
|
|
28
|
+
|
|
29
|
+
const chromeHeight = showUrl ? CHROME_HEIGHT : Math.floor(CHROME_HEIGHT * 0.6);
|
|
30
|
+
const totalWidth = pageWidth + (shadowBlur > 0 ? shadowPadding * 2 : 0);
|
|
31
|
+
const totalHeight = pageHeight + chromeHeight + (shadowBlur > 0 ? shadowPadding * 2 : 0);
|
|
32
|
+
|
|
33
|
+
const windowX = shadowBlur > 0 ? shadowPadding : 0;
|
|
34
|
+
const windowY = shadowBlur > 0 ? shadowPadding : 0;
|
|
35
|
+
const windowWidth = pageWidth;
|
|
36
|
+
const windowHeight = pageHeight + chromeHeight;
|
|
37
|
+
|
|
38
|
+
const chromeSvg = buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url });
|
|
39
|
+
|
|
40
|
+
const chromeBuf = await sharp(Buffer.from(chromeSvg))
|
|
41
|
+
.resize(windowWidth, chromeHeight)
|
|
42
|
+
.png()
|
|
43
|
+
.toBuffer();
|
|
44
|
+
|
|
45
|
+
const layers = [];
|
|
46
|
+
|
|
47
|
+
if (shadowBlur > 0) {
|
|
48
|
+
const shadowSvg = buildShadowSvg({
|
|
49
|
+
totalWidth, totalHeight, windowX, windowY,
|
|
50
|
+
windowWidth, windowHeight, shadowBlur,
|
|
51
|
+
});
|
|
52
|
+
const shadowBuf = await sharp(Buffer.from(shadowSvg))
|
|
53
|
+
.resize(totalWidth, totalHeight)
|
|
54
|
+
.png()
|
|
55
|
+
.toBuffer();
|
|
56
|
+
layers.push({ input: shadowBuf, top: 0, left: 0 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
layers.push({ input: screenshotBuffer, top: windowY + chromeHeight, left: windowX });
|
|
60
|
+
layers.push({ input: chromeBuf, top: windowY, left: windowX });
|
|
61
|
+
|
|
62
|
+
const base = {
|
|
63
|
+
create: {
|
|
64
|
+
width: totalWidth,
|
|
65
|
+
height: totalHeight,
|
|
66
|
+
channels: 4,
|
|
67
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return sharp(base).composite(layers).png().toBuffer();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildChromeSvg({ windowWidth, chromeHeight, dark, showUrl, url }) {
|
|
75
|
+
const bg = dark ? '#323232' : '#e8e8e8';
|
|
76
|
+
const bgBottom = dark ? '#2c2c2c' : '#dcdcdc';
|
|
77
|
+
const urlBarBg = dark ? '#454547' : '#ffffff';
|
|
78
|
+
const urlBorder = dark ? '#5a5a5c' : '#cacaca';
|
|
79
|
+
const urlText = dark ? '#f0f0f0' : '#111111';
|
|
80
|
+
const separator = dark ? '#1a1a1a' : '#b8b8b8';
|
|
81
|
+
|
|
82
|
+
const tlColors = ['#ff5f57', '#febc2e', '#28c840'];
|
|
83
|
+
const lights = tlColors.map((color, i) => {
|
|
84
|
+
const cx = TL_X0 + i * (TL_R * 2 + TL_GAP);
|
|
85
|
+
return `<circle cx="${cx}" cy="${TL_CY}" r="${TL_R}" fill="${color}"/>`;
|
|
86
|
+
}).join('\n ');
|
|
87
|
+
|
|
88
|
+
let urlBar = '';
|
|
89
|
+
if (showUrl) {
|
|
90
|
+
const barX = URL_BAR_X;
|
|
91
|
+
const barY = URL_BAR_Y;
|
|
92
|
+
const barW = windowWidth - URL_BAR_X - URL_BAR_X_MARGIN_R;
|
|
93
|
+
const displayUrl = url.length > 90 ? url.slice(0, 87) + '...' : url;
|
|
94
|
+
urlBar = `
|
|
95
|
+
<rect x="${barX}" y="${barY}" width="${barW}" height="${URL_BAR_H}"
|
|
96
|
+
rx="${URL_BAR_RADIUS}" fill="${urlBarBg}" stroke="${urlBorder}" stroke-width="1"/>
|
|
97
|
+
<text
|
|
98
|
+
x="${barX + barW / 2}"
|
|
99
|
+
y="${barY + URL_BAR_H / 2 + 5}"
|
|
100
|
+
text-anchor="middle"
|
|
101
|
+
font-family="Arial, sans-serif"
|
|
102
|
+
font-size="13"
|
|
103
|
+
fill="${urlText}"
|
|
104
|
+
>${escapeXml(displayUrl)}</text>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// gradient: slightly lighter top, slightly darker bottom
|
|
108
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${windowWidth}" height="${chromeHeight}">
|
|
109
|
+
<defs>
|
|
110
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
111
|
+
<stop offset="0%" stop-color="${bg}"/>
|
|
112
|
+
<stop offset="100%" stop-color="${bgBottom}"/>
|
|
113
|
+
</linearGradient>
|
|
114
|
+
</defs>
|
|
115
|
+
<rect width="${windowWidth}" height="${chromeHeight}" fill="url(#bg)" rx="10" ry="10"/>
|
|
116
|
+
<rect x="0" y="${chromeHeight - 10}" width="${windowWidth}" height="10" fill="${bgBottom}"/>
|
|
117
|
+
<line x1="0" y1="${chromeHeight - 0.5}" x2="${windowWidth}" y2="${chromeHeight - 0.5}"
|
|
118
|
+
stroke="${separator}" stroke-width="1"/>
|
|
119
|
+
${lights}
|
|
120
|
+
${urlBar}
|
|
121
|
+
</svg>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildShadowSvg({ totalWidth, totalHeight, windowX, windowY, windowWidth, windowHeight, shadowBlur }) {
|
|
125
|
+
const halfBlur = shadowBlur / 2;
|
|
126
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
127
|
+
<defs>
|
|
128
|
+
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
129
|
+
<feDropShadow
|
|
130
|
+
dx="0" dy="${halfBlur / 2}"
|
|
131
|
+
stdDeviation="${halfBlur}"
|
|
132
|
+
flood-color="rgba(0,0,0,0.35)"
|
|
133
|
+
/>
|
|
134
|
+
</filter>
|
|
135
|
+
</defs>
|
|
136
|
+
<rect
|
|
137
|
+
x="${windowX}" y="${windowY}"
|
|
138
|
+
width="${windowWidth}" height="${windowHeight}"
|
|
139
|
+
rx="10" ry="10"
|
|
140
|
+
fill="white"
|
|
141
|
+
filter="url(#shadow)"
|
|
142
|
+
/>
|
|
143
|
+
</svg>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function escapeXml(str) {
|
|
147
|
+
return String(str)
|
|
148
|
+
.replace(/&/g, '&')
|
|
149
|
+
.replace(/</g, '<')
|
|
150
|
+
.replace(/>/g, '>')
|
|
151
|
+
.replace(/"/g, '"')
|
|
152
|
+
.replace(/'/g, ''');
|
|
153
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { pathToFileURL } from 'url';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const WindowSizeSchema = z.object({
|
|
7
|
+
width: z.number().int().positive(),
|
|
8
|
+
height: z.number().int().positive(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const DockerSchema = z.object({
|
|
12
|
+
// path to a docker-compose file
|
|
13
|
+
compose: z.string().optional(),
|
|
14
|
+
// specific service to start (omit to start all services in the compose file)
|
|
15
|
+
service: z.string().optional(),
|
|
16
|
+
// health check URL -- polled until 200 or timeout
|
|
17
|
+
healthcheck: z.object({
|
|
18
|
+
url: z.string().url(),
|
|
19
|
+
timeout: z.number().int().positive().default(60000),
|
|
20
|
+
interval: z.number().int().positive().default(2000),
|
|
21
|
+
}),
|
|
22
|
+
// path to a bootstrap/seed file to pass into the container.
|
|
23
|
+
// exposed to docker-compose.yml as env vars SCREENSHOT_BOOTSTRAP_PATH and
|
|
24
|
+
// SCREENSHOT_BOOTSTRAP_CONTENT. wire these into your container however it needs
|
|
25
|
+
// (e.g. FUSIONAUTH_APP_KICKSTART_FILE=SCREENSHOT_BOOTSTRAP_PATH for FusionAuth,
|
|
26
|
+
// or mount the path as a volume for other apps).
|
|
27
|
+
bootstrap: z.string().optional(),
|
|
28
|
+
// legacy alias for bootstrap -- 'kickstart' still works
|
|
29
|
+
kickstart: z.string().optional(),
|
|
30
|
+
// shell command to run after the health check passes and before beforeAll.
|
|
31
|
+
// runs in the project root. useful for database migrations, seed scripts, etc.
|
|
32
|
+
// example: 'node scripts/seed.js' or 'docker exec app npm run db:seed'
|
|
33
|
+
postStart: z.string().optional(),
|
|
34
|
+
// arbitrary env vars merged into the container environment when running compose up
|
|
35
|
+
env: z.record(z.string()).optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ChromeSchema = z.object({
|
|
39
|
+
// 'safari-macos' | 'none'
|
|
40
|
+
style: z.enum(['safari-macos', 'none']).default('safari-macos'),
|
|
41
|
+
// show the URL bar in the chrome
|
|
42
|
+
showUrl: z.boolean().default(true),
|
|
43
|
+
// dark chrome (title bar) regardless of page dark mode
|
|
44
|
+
dark: z.boolean().default(false),
|
|
45
|
+
// drop shadow radius in pixels; 0 to disable
|
|
46
|
+
shadowBlur: z.number().int().min(0).default(40),
|
|
47
|
+
// extra padding around the window to make room for the shadow
|
|
48
|
+
shadowPadding: z.number().int().min(0).default(48),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const ConfigSchema = z.object({
|
|
52
|
+
// base URL of the running app
|
|
53
|
+
baseUrl: z.string().url(),
|
|
54
|
+
|
|
55
|
+
// where to write screenshot PNGs -- relative to the project root
|
|
56
|
+
outputDir: z.string().default('./src/assets/screenshots'),
|
|
57
|
+
|
|
58
|
+
// default window dimensions
|
|
59
|
+
window: WindowSizeSchema.default({ width: 1280, height: 800 }),
|
|
60
|
+
|
|
61
|
+
// minimum and maximum window dimensions enforced globally
|
|
62
|
+
minWindow: WindowSizeSchema.default({ width: 640, height: 400 }),
|
|
63
|
+
maxWindow: WindowSizeSchema.default({ width: 2560, height: 1600 }),
|
|
64
|
+
|
|
65
|
+
// maximum dimensions for full-page captures
|
|
66
|
+
maxFullPage: WindowSizeSchema.default({ width: 2560, height: 8000 }),
|
|
67
|
+
|
|
68
|
+
// window chrome style
|
|
69
|
+
chrome: ChromeSchema.default({}),
|
|
70
|
+
|
|
71
|
+
// browser engine -- webkit gives the most Safari-accurate render
|
|
72
|
+
browser: z.enum(['webkit', 'chromium', 'firefox']).default('webkit'),
|
|
73
|
+
|
|
74
|
+
// default color scheme
|
|
75
|
+
colorScheme: z.enum(['light', 'dark']).default('light'),
|
|
76
|
+
|
|
77
|
+
// docker service configuration -- optional when the app is already running
|
|
78
|
+
docker: DockerSchema.optional(),
|
|
79
|
+
|
|
80
|
+
// strict mode -- fail the build when a screenshot file is missing
|
|
81
|
+
// defaults to true when SCREENSHOTS_STRICT=true env var is set
|
|
82
|
+
strict: z.boolean().default(process.env.SCREENSHOTS_STRICT === 'true'),
|
|
83
|
+
|
|
84
|
+
// called once before all screenshots are taken (good for login flows)
|
|
85
|
+
beforeAll: z.function().args(z.any()).returns(z.promise(z.any())).optional(),
|
|
86
|
+
|
|
87
|
+
// called before each individual screenshot
|
|
88
|
+
beforeScreenshot: z.function().args(z.any(), z.any()).returns(z.promise(z.any())).optional(),
|
|
89
|
+
|
|
90
|
+
// called once after all screenshots are taken
|
|
91
|
+
afterAll: z.function().returns(z.promise(z.any())).optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export async function loadConfig(projectRoot) {
|
|
95
|
+
const candidates = [
|
|
96
|
+
path.join(projectRoot, 'screenshot.config.mjs'),
|
|
97
|
+
path.join(projectRoot, 'screenshot.config.js'),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const configPath = candidates.find(existsSync);
|
|
101
|
+
if (!configPath) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`No screenshot.config.mjs found in ${projectRoot}. ` +
|
|
104
|
+
`Create one -- see the README for the full config reference.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const raw = await import(/* @vite-ignore */ pathToFileURL(configPath).href);
|
|
109
|
+
const config = raw.default ?? raw;
|
|
110
|
+
|
|
111
|
+
const result = ConfigSchema.safeParse(config);
|
|
112
|
+
if (!result.success) {
|
|
113
|
+
const issues = result.error.issues
|
|
114
|
+
.map(i => ` ${i.path.join('.')}: ${i.message}`)
|
|
115
|
+
.join('\n');
|
|
116
|
+
throw new Error(`screenshot.config.mjs is invalid:\n${issues}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { ConfigSchema, DockerSchema };
|
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 './naming.mjs';
|
|
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('./config.mjs').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('./config.mjs').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
|
+
}
|