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 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
+ });
@@ -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, '&amp;')
162
+ .replace(/</g, '&lt;')
163
+ .replace(/>/g, '&gt;')
164
+ .replace(/"/g, '&quot;')
165
+ .replace(/'/g, '&apos;');
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
+ }
@@ -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, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&apos;');
53
+ }