@rettangoli/sites 0.2.0-rc6 → 0.2.1
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/package.json +5 -2
- package/src/cli/build.js +5 -5
- package/src/cli/index.js +1 -1
- package/src/cli/watch.js +8 -9
- package/src/createSiteBuilder.js +18 -11
- package/src/rtglMarkdown.js +40 -117
- package/src/screenshot.js +250 -0
- package/src/screenshotRunner.js +171 -0
- package/src/utils/loadSiteConfig.js +11 -1
- package/src/cli/screenshot-command.js +0 -167
- package/src/cli/screenshot.js +0 -145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/sites",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Generate static sites using Markdown and YAML. Straightforward, zero-complexity. Complete toolkit for landing pages, blogs, documentation, admin dashboards, and more.git remote add origin git@github.com:yuusoft-org/sitic.git",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Luciano Hanyon Wu",
|
|
@@ -20,13 +20,16 @@
|
|
|
20
20
|
"jempl": "^0.3.1-rc1",
|
|
21
21
|
"js-yaml": "^4.1.0",
|
|
22
22
|
"markdown-it": "^14.1.0",
|
|
23
|
+
"minimatch": "^10.0.3",
|
|
23
24
|
"playwright": "^1.55.0",
|
|
25
|
+
"sharp": "^0.34.3",
|
|
24
26
|
"ws": "^8.18.0",
|
|
25
27
|
"yahtml": "^0.0.2-rc1"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"memfs": "^4.36.0",
|
|
29
|
-
"puty": "^0.0.4"
|
|
31
|
+
"puty": "^0.0.4",
|
|
32
|
+
"vitest": "^3.2.4"
|
|
30
33
|
},
|
|
31
34
|
"scripts": {
|
|
32
35
|
"test": "vitest run --reporter=verbose"
|
package/src/cli/build.js
CHANGED
|
@@ -6,25 +6,25 @@ import { loadSiteConfig } from '../utils/loadSiteConfig.js';
|
|
|
6
6
|
* Build the static site
|
|
7
7
|
* @param {Object} options - Options for building the site
|
|
8
8
|
* @param {string} options.rootDir - Root directory of the site (defaults to cwd)
|
|
9
|
-
* @param {Object} options.
|
|
9
|
+
* @param {Object} options.md - Optional markdown renderer
|
|
10
10
|
* @param {boolean} options.quiet - Suppress build output logs
|
|
11
11
|
*/
|
|
12
12
|
export const buildSite = async (options = {}) => {
|
|
13
|
-
const { rootDir = process.cwd(),
|
|
13
|
+
const { rootDir = process.cwd(), md, functions, quiet = false } = options;
|
|
14
14
|
|
|
15
15
|
// Load config file if needed
|
|
16
16
|
let config = {};
|
|
17
|
-
if (!
|
|
17
|
+
if (!md || !functions) {
|
|
18
18
|
config = await loadSiteConfig(rootDir);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const build = createSiteBuilder({
|
|
22
22
|
fs,
|
|
23
23
|
rootDir,
|
|
24
|
-
|
|
24
|
+
md: md || config.mdRender,
|
|
25
25
|
functions: functions || config.functions || {},
|
|
26
26
|
quiet
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
build();
|
|
29
|
+
await build();
|
|
30
30
|
};
|
package/src/cli/index.js
CHANGED
package/src/cli/watch.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { WebSocketServer } from 'ws';
|
|
6
6
|
import { buildSite } from './build.js';
|
|
7
|
-
import
|
|
7
|
+
import { createScreenshotCapture } from '../screenshot.js';
|
|
8
8
|
import { loadSiteConfig } from '../utils/loadSiteConfig.js';
|
|
9
9
|
|
|
10
10
|
// Client script to inject into HTML pages
|
|
@@ -247,7 +247,7 @@ const setupWatcher = (directory, options, server, screenshotCapture) => {
|
|
|
247
247
|
|
|
248
248
|
const currentOptions = {
|
|
249
249
|
...options,
|
|
250
|
-
|
|
250
|
+
md: config.md || options.md,
|
|
251
251
|
functions: config.functions || options.functions || {}
|
|
252
252
|
};
|
|
253
253
|
|
|
@@ -340,10 +340,10 @@ const watchSite = async (options = {}) => {
|
|
|
340
340
|
|
|
341
341
|
if (Object.keys(config).length > 0) {
|
|
342
342
|
console.log('✅ Loaded sites.config.js');
|
|
343
|
-
if (config.
|
|
344
|
-
console.log('✅ Custom
|
|
343
|
+
if (config.md) {
|
|
344
|
+
console.log('✅ Custom md function found');
|
|
345
345
|
} else {
|
|
346
|
-
console.log('ℹ️ No custom
|
|
346
|
+
console.log('ℹ️ No custom md function in config');
|
|
347
347
|
}
|
|
348
348
|
if (config.functions) {
|
|
349
349
|
console.log(`✅ Found ${Object.keys(config.functions).length} custom function(s)`);
|
|
@@ -356,7 +356,7 @@ const watchSite = async (options = {}) => {
|
|
|
356
356
|
console.log('Starting initial build...');
|
|
357
357
|
await buildSite({
|
|
358
358
|
rootDir,
|
|
359
|
-
|
|
359
|
+
md: config.md,
|
|
360
360
|
functions: config.functions || {}
|
|
361
361
|
});
|
|
362
362
|
console.log('Initial build complete');
|
|
@@ -369,8 +369,7 @@ const watchSite = async (options = {}) => {
|
|
|
369
369
|
let screenshotCapture = null;
|
|
370
370
|
if (screenshots) {
|
|
371
371
|
console.log('\n📸 Screenshot capture enabled');
|
|
372
|
-
screenshotCapture =
|
|
373
|
-
await screenshotCapture.init();
|
|
372
|
+
screenshotCapture = await createScreenshotCapture(port);
|
|
374
373
|
}
|
|
375
374
|
|
|
376
375
|
// Watch all relevant directories
|
|
@@ -382,7 +381,7 @@ const watchSite = async (options = {}) => {
|
|
|
382
381
|
console.log(`👁️ Watching: ${dir}/`);
|
|
383
382
|
setupWatcher(dirPath, {
|
|
384
383
|
rootDir,
|
|
385
|
-
|
|
384
|
+
md: config.md,
|
|
386
385
|
functions: config.functions || {}
|
|
387
386
|
}, server, screenshotCapture);
|
|
388
387
|
}
|
package/src/createSiteBuilder.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
|
|
6
6
|
import MarkdownIt from 'markdown-it';
|
|
7
|
+
import rtglMarkdown from './rtglMarkdown.js';
|
|
7
8
|
|
|
8
9
|
// Deep merge utility function
|
|
9
10
|
function deepMerge(target, source) {
|
|
@@ -30,10 +31,10 @@ function isObject(item) {
|
|
|
30
31
|
return item && typeof item === 'object' && !Array.isArray(item);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function createSiteBuilder({ fs, rootDir = '.',
|
|
34
|
-
return function build() {
|
|
35
|
-
// Use provided
|
|
36
|
-
const
|
|
34
|
+
export function createSiteBuilder({ fs, rootDir = '.', md, functions = {}, quiet = false }) {
|
|
35
|
+
return async function build() {
|
|
36
|
+
// Use provided md or default to rtglMarkdown
|
|
37
|
+
const mdInstance = md || rtglMarkdown(MarkdownIt);
|
|
37
38
|
|
|
38
39
|
// Read all partials and create a JSON object
|
|
39
40
|
const partialsDir = path.join(rootDir, 'partials');
|
|
@@ -190,7 +191,7 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
190
191
|
const collections = buildCollections();
|
|
191
192
|
|
|
192
193
|
// Function to process a single page file
|
|
193
|
-
function processPage(pagePath, outputRelativePath, isMarkdown = false) {
|
|
194
|
+
async function processPage(pagePath, outputRelativePath, isMarkdown = false) {
|
|
194
195
|
if (!quiet) console.log(`Processing ${pagePath}...`);
|
|
195
196
|
|
|
196
197
|
// Read page content
|
|
@@ -244,7 +245,13 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
244
245
|
|
|
245
246
|
if (isMarkdown) {
|
|
246
247
|
// Process markdown content with MarkdownIt
|
|
247
|
-
|
|
248
|
+
//If markdownit async then use the async render method
|
|
249
|
+
let htmlContent;
|
|
250
|
+
if(mdInstance.renderAsync){
|
|
251
|
+
htmlContent = await mdInstance.renderAsync(rawContent);
|
|
252
|
+
} else {
|
|
253
|
+
htmlContent = mdInstance.render(rawContent);
|
|
254
|
+
}
|
|
248
255
|
// For markdown, store as raw HTML that will be inserted directly
|
|
249
256
|
processedPageContent = { __html: htmlContent };
|
|
250
257
|
} else {
|
|
@@ -304,7 +311,7 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
304
311
|
}
|
|
305
312
|
|
|
306
313
|
// Process all YAML and Markdown files in pages directory recursively
|
|
307
|
-
function processAllPages(dir, basePath = '') {
|
|
314
|
+
async function processAllPages(dir, basePath = '') {
|
|
308
315
|
const pagesDir = path.join(rootDir, 'pages');
|
|
309
316
|
const fullDir = path.join(pagesDir, basePath);
|
|
310
317
|
|
|
@@ -318,18 +325,18 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
318
325
|
|
|
319
326
|
if (item.isDirectory()) {
|
|
320
327
|
// Recursively process subdirectories
|
|
321
|
-
processAllPages(dir, relativePath);
|
|
328
|
+
await processAllPages(dir, relativePath);
|
|
322
329
|
} else if (item.isFile()) {
|
|
323
330
|
if (item.name.endsWith('.yaml')) {
|
|
324
331
|
// Process YAML file
|
|
325
332
|
const outputFileName = item.name.replace('.yaml', '.html');
|
|
326
333
|
const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
|
|
327
|
-
processPage(itemPath, outputRelativePath, false);
|
|
334
|
+
await processPage(itemPath, outputRelativePath, false);
|
|
328
335
|
} else if (item.name.endsWith('.md')) {
|
|
329
336
|
// Process Markdown file
|
|
330
337
|
const outputFileName = item.name.replace('.md', '.html');
|
|
331
338
|
const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
|
|
332
|
-
processPage(itemPath, outputRelativePath, true);
|
|
339
|
+
await processPage(itemPath, outputRelativePath, true);
|
|
333
340
|
}
|
|
334
341
|
// Ignore other file types
|
|
335
342
|
}
|
|
@@ -387,7 +394,7 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
387
394
|
copyStaticFiles();
|
|
388
395
|
|
|
389
396
|
// Process all pages (can overwrite static files)
|
|
390
|
-
processAllPages('');
|
|
397
|
+
await processAllPages('');
|
|
391
398
|
|
|
392
399
|
if (!quiet) console.log('Build complete!');
|
|
393
400
|
};
|
package/src/rtglMarkdown.js
CHANGED
|
@@ -1,126 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Simple slug generation function
|
|
4
|
-
function generateSlug(text) {
|
|
1
|
+
// Custom slug generation function
|
|
2
|
+
function slugify(text) {
|
|
5
3
|
return text
|
|
6
4
|
.toLowerCase()
|
|
7
5
|
.trim()
|
|
8
|
-
.replace(/[^\w\s-]/g, '')
|
|
9
|
-
.replace(
|
|
10
|
-
.replace(
|
|
6
|
+
.replace(/[^\w\s-]/g, '') // Remove non-word chars (except spaces and hyphens)
|
|
7
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
8
|
+
.replace(/--+/g, '-') // Replace multiple hyphens with single hyphen
|
|
9
|
+
.replace(/^-+/, '') // Remove leading hyphens
|
|
10
|
+
.replace(/-+$/, '') // Remove trailing hyphens
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Paragraph configuration
|
|
40
|
-
md.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => {
|
|
41
|
-
// Check if we're inside a list item
|
|
42
|
-
let isInListItem = false;
|
|
43
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
44
|
-
if (tokens[i].type === 'list_item_open') {
|
|
45
|
-
isInListItem = true;
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
if (tokens[i].type === 'list_item_close') {
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Don't wrap paragraphs in list items with rtgl-text
|
|
54
|
-
if (isInListItem) {
|
|
55
|
-
return '';
|
|
56
|
-
}
|
|
57
|
-
return `<rtgl-text s="bl" mb="lg">`;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
md.renderer.rules.paragraph_close = (tokens, idx, options, env, self) => {
|
|
61
|
-
// Check if we're inside a list item
|
|
62
|
-
let isInListItem = false;
|
|
63
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
64
|
-
if (tokens[i].type === 'list_item_open') {
|
|
65
|
-
isInListItem = true;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
if (tokens[i].type === 'list_item_close') {
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Don't wrap paragraphs in list items with rtgl-text
|
|
74
|
-
if (isInListItem) {
|
|
75
|
-
return '\n';
|
|
76
|
-
}
|
|
77
|
-
return "</rtgl-text>\n";
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// Table configuration
|
|
81
|
-
md.renderer.rules.table_open = () => '<rtgl-view w="f">\n<table>';
|
|
82
|
-
md.renderer.rules.table_close = () => "</table>\n</rtgl-view>";
|
|
83
|
-
|
|
84
|
-
// Link configuration - add target="_blank" to all external links
|
|
85
|
-
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|
86
|
-
const token = tokens[idx];
|
|
87
|
-
const targetIndex = token.attrIndex("target");
|
|
88
|
-
const href =
|
|
89
|
-
(token.attrs && token.attrs.find((attr) => attr[0] === "href")?.[1]) ||
|
|
90
|
-
"";
|
|
91
|
-
const isExternal = href.startsWith("http") || href.startsWith("//");
|
|
92
|
-
|
|
93
|
-
// If this is an external link or already has target="_blank"
|
|
94
|
-
if (isExternal || targetIndex >= 0) {
|
|
95
|
-
if (targetIndex < 0) {
|
|
96
|
-
token.attrPush(["target", "_blank"]);
|
|
97
|
-
}
|
|
98
|
-
token.attrPush(["rel", "noreferrer"]);
|
|
99
|
-
|
|
100
|
-
// Find the next text token to use for the aria-label
|
|
101
|
-
let nextIdx = idx + 1;
|
|
102
|
-
let textContent = "";
|
|
103
|
-
while (nextIdx < tokens.length && tokens[nextIdx].type !== "link_close") {
|
|
104
|
-
if (tokens[nextIdx].type === "text") {
|
|
105
|
-
textContent += tokens[nextIdx].content;
|
|
106
|
-
}
|
|
107
|
-
nextIdx++;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Add aria-label for external links
|
|
111
|
-
if (textContent.trim() && token.attrIndex("aria-label") < 0) {
|
|
112
|
-
token.attrPush([
|
|
113
|
-
"aria-label",
|
|
114
|
-
`${textContent.trim()} (opens in new tab)`,
|
|
115
|
-
]);
|
|
116
|
-
}
|
|
13
|
+
export default function configureMarkdown(markdownit) {
|
|
14
|
+
const md = markdownit({
|
|
15
|
+
html: true,
|
|
16
|
+
linkify: true,
|
|
17
|
+
typographer: false
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Override heading renderer to add IDs and wrap with anchor links
|
|
21
|
+
const defaultHeadingRender = md.renderer.rules.heading_open || function (tokens, idx, options, env, renderer) {
|
|
22
|
+
return renderer.renderToken(tokens, idx, options)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultHeadingCloseRender = md.renderer.rules.heading_close || function (tokens, idx, options, env, renderer) {
|
|
26
|
+
return renderer.renderToken(tokens, idx, options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
md.renderer.rules.heading_open = function (tokens, idx, options, env, renderer) {
|
|
30
|
+
const token = tokens[idx]
|
|
31
|
+
const nextToken = tokens[idx + 1]
|
|
32
|
+
let slug = ''
|
|
33
|
+
|
|
34
|
+
if (nextToken && nextToken.type === 'inline') {
|
|
35
|
+
const headingText = nextToken.content
|
|
36
|
+
slug = slugify(headingText)
|
|
37
|
+
token.attrSet('id', slug)
|
|
117
38
|
}
|
|
118
39
|
|
|
119
|
-
|
|
120
|
-
|
|
40
|
+
const headingHtml = defaultHeadingRender(tokens, idx, options, env, renderer)
|
|
41
|
+
return `<a href="#${slug}" style="display: contents; text-decoration: none; color: inherit;">` + headingHtml
|
|
42
|
+
}
|
|
121
43
|
|
|
122
|
-
|
|
123
|
-
|
|
44
|
+
md.renderer.rules.heading_close = function (tokens, idx, options, env, renderer) {
|
|
45
|
+
return defaultHeadingCloseRender(tokens, idx, options, env, renderer) + '</a>'
|
|
46
|
+
}
|
|
124
47
|
|
|
125
|
-
|
|
126
|
-
|
|
48
|
+
return md
|
|
49
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
|
|
7
|
+
// Module state for browser instance
|
|
8
|
+
let browser = null;
|
|
9
|
+
|
|
10
|
+
async function initBrowser() {
|
|
11
|
+
if (browser) return browser;
|
|
12
|
+
|
|
13
|
+
console.log('🎬 Initializing Playwright browser...');
|
|
14
|
+
browser = await chromium.launch({
|
|
15
|
+
headless: true
|
|
16
|
+
});
|
|
17
|
+
console.log('✅ Browser initialized for screenshots');
|
|
18
|
+
return browser;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function closeBrowser() {
|
|
22
|
+
if (browser) {
|
|
23
|
+
await browser.close();
|
|
24
|
+
browser = null;
|
|
25
|
+
console.log('🔚 Browser closed');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function capturePageScreenshot(pagePath, options = {}) {
|
|
30
|
+
const {
|
|
31
|
+
port = 3001,
|
|
32
|
+
outputDir = '_screenshots',
|
|
33
|
+
context = null
|
|
34
|
+
} = options;
|
|
35
|
+
|
|
36
|
+
const browserInstance = await initBrowser();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Convert file path to URL path
|
|
40
|
+
// pages/index.md -> /
|
|
41
|
+
// pages/about.md -> /about
|
|
42
|
+
// pages/store/index.md -> /store/
|
|
43
|
+
// pages/store/products.md -> /store/products
|
|
44
|
+
let urlPath = pagePath
|
|
45
|
+
.replace(/^pages\//, '')
|
|
46
|
+
.replace(/\.(md|yaml|yml)$/, '');
|
|
47
|
+
|
|
48
|
+
if (urlPath === 'index') {
|
|
49
|
+
urlPath = '/';
|
|
50
|
+
} else if (urlPath.endsWith('/index')) {
|
|
51
|
+
urlPath = urlPath.replace(/\/index$/, '/');
|
|
52
|
+
} else if (urlPath) {
|
|
53
|
+
// Add leading slash if not root
|
|
54
|
+
urlPath = '/' + urlPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const url = `http://localhost:${port}${urlPath}?screenshot=true`;
|
|
58
|
+
|
|
59
|
+
// Determine screenshot path
|
|
60
|
+
// / -> index.webp
|
|
61
|
+
// /about -> about.webp
|
|
62
|
+
// /store/ -> store/index.webp
|
|
63
|
+
// /store/products -> store/products.webp
|
|
64
|
+
let screenshotPath;
|
|
65
|
+
if (urlPath === '/') {
|
|
66
|
+
screenshotPath = 'index.webp';
|
|
67
|
+
} else if (urlPath.endsWith('/')) {
|
|
68
|
+
screenshotPath = urlPath.slice(1, -1) + '/index.webp';
|
|
69
|
+
} else {
|
|
70
|
+
screenshotPath = urlPath.slice(1) + '.webp';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fullScreenshotPath = path.join(outputDir, screenshotPath);
|
|
74
|
+
const tempPngPath = fullScreenshotPath.replace('.webp', '.png');
|
|
75
|
+
|
|
76
|
+
// Ensure directory exists
|
|
77
|
+
const screenshotDir = path.dirname(fullScreenshotPath);
|
|
78
|
+
if (!fs.existsSync(screenshotDir)) {
|
|
79
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`📸 Capturing screenshot: ${url} -> ${fullScreenshotPath}`);
|
|
83
|
+
|
|
84
|
+
// Create a context if not provided
|
|
85
|
+
const shouldCloseContext = !context;
|
|
86
|
+
let contextToUse = context;
|
|
87
|
+
if (!contextToUse) {
|
|
88
|
+
contextToUse = await browserInstance.newContext({
|
|
89
|
+
viewport: { width: 1366, height: 768 }, // Most common laptop viewport
|
|
90
|
+
deviceScaleFactor: 0.75 // Reduce pixel density for smaller PNG files
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const page = await contextToUse.newPage();
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Navigate to the page
|
|
98
|
+
await page.goto(url, {
|
|
99
|
+
waitUntil: 'networkidle',
|
|
100
|
+
timeout: 30000
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// // Wait a bit for content to load
|
|
104
|
+
// await page.waitForTimeout(2000);
|
|
105
|
+
|
|
106
|
+
// Take screenshot as PNG first (Playwright doesn't support WebP)
|
|
107
|
+
await page.screenshot({
|
|
108
|
+
path: tempPngPath,
|
|
109
|
+
fullPage: true,
|
|
110
|
+
type: 'png'
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Convert PNG to WebP using Sharp
|
|
114
|
+
await sharp(tempPngPath)
|
|
115
|
+
.webp({ quality: 100 })
|
|
116
|
+
.toFile(fullScreenshotPath);
|
|
117
|
+
|
|
118
|
+
// Remove temporary PNG file
|
|
119
|
+
if (fs.existsSync(tempPngPath)) {
|
|
120
|
+
fs.unlinkSync(tempPngPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`✅ Screenshot saved: ${fullScreenshotPath}`);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error(`❌ Failed to capture ${url}:`, error.message);
|
|
126
|
+
// Clean up temp PNG file if it exists
|
|
127
|
+
if (fs.existsSync(tempPngPath)) {
|
|
128
|
+
fs.unlinkSync(tempPngPath);
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
await page.close();
|
|
132
|
+
// Close context if we created it
|
|
133
|
+
if (shouldCloseContext && contextToUse) {
|
|
134
|
+
await contextToUse.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('❌ Screenshot error:', error);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function captureAllPages(pagesDir, options = {}) {
|
|
143
|
+
const {
|
|
144
|
+
port = 3001,
|
|
145
|
+
outputDir = '_screenshots',
|
|
146
|
+
ignorePatterns = []
|
|
147
|
+
} = options;
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(pagesDir)) {
|
|
150
|
+
console.log('Pages directory does not exist:', pagesDir);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Helper function to check if a path should be ignored
|
|
155
|
+
const shouldIgnore = (relativePath) => {
|
|
156
|
+
// Remove 'pages/' prefix for matching
|
|
157
|
+
const pathToMatch = relativePath.replace(/^pages\//, '');
|
|
158
|
+
|
|
159
|
+
return ignorePatterns.some(pattern => {
|
|
160
|
+
// Handle exact matches and glob patterns
|
|
161
|
+
return minimatch(pathToMatch, pattern, {
|
|
162
|
+
matchBase: true, // Allow patterns to match basename
|
|
163
|
+
dot: true // Allow patterns to match files starting with dot
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// First, collect all page paths
|
|
169
|
+
const pagePaths = [];
|
|
170
|
+
|
|
171
|
+
const collectPaths = (dir, basePath = '') => {
|
|
172
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
173
|
+
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const fullPath = path.join(dir, entry.name);
|
|
176
|
+
const relativePath = path.join(basePath, entry.name);
|
|
177
|
+
|
|
178
|
+
if (entry.isDirectory()) {
|
|
179
|
+
// Check if directory should be ignored
|
|
180
|
+
const dirPath = path.join('pages', relativePath);
|
|
181
|
+
if (shouldIgnore(dirPath)) {
|
|
182
|
+
console.log(`⏭️ Ignoring directory: ${relativePath}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Recurse into subdirectories
|
|
186
|
+
collectPaths(fullPath, relativePath);
|
|
187
|
+
} else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))) {
|
|
188
|
+
// Add page path to the list
|
|
189
|
+
const pagePath = path.join('pages', relativePath);
|
|
190
|
+
if (shouldIgnore(pagePath)) {
|
|
191
|
+
console.log(`⏭️ Ignoring file: ${relativePath}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
pagePaths.push(pagePath);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
collectPaths(pagesDir);
|
|
200
|
+
|
|
201
|
+
console.log(`📸 Found ${pagePaths.length} pages to capture`);
|
|
202
|
+
|
|
203
|
+
// Process pages in parallel with concurrency limit of 12
|
|
204
|
+
const concurrency = 12;
|
|
205
|
+
const results = [];
|
|
206
|
+
const browserInstance = await initBrowser();
|
|
207
|
+
|
|
208
|
+
// Create contexts for parallel processing
|
|
209
|
+
const contexts = await Promise.all(
|
|
210
|
+
Array(Math.min(concurrency, pagePaths.length))
|
|
211
|
+
.fill(null)
|
|
212
|
+
.map(() => browserInstance.newContext({
|
|
213
|
+
viewport: { width: 1366, height: 768 }, // Most common laptop viewport
|
|
214
|
+
deviceScaleFactor: 0.75 // Reduce pixel density for smaller PNG files
|
|
215
|
+
}))
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
for (let i = 0; i < pagePaths.length; i += concurrency) {
|
|
220
|
+
const batch = pagePaths.slice(i, i + concurrency);
|
|
221
|
+
console.log(`📸 Processing batch ${Math.floor(i / concurrency) + 1}/${Math.ceil(pagePaths.length / concurrency)} (${batch.length} pages)`);
|
|
222
|
+
|
|
223
|
+
const batchPromises = batch.map((pagePath, index) =>
|
|
224
|
+
capturePageScreenshot(pagePath, { port, outputDir, context: contexts[index] }).catch(error => {
|
|
225
|
+
console.error(`Failed to capture ${pagePath}:`, error);
|
|
226
|
+
return null;
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const batchResults = await Promise.all(batchPromises);
|
|
231
|
+
results.push(...batchResults);
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
// Clean up contexts
|
|
235
|
+
await Promise.all(contexts.map(ctx => ctx.close()));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log(`✅ Completed capturing ${pagePaths.length} screenshots`);
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function createScreenshotCapture(port = 3001, outputDir = '_screenshots') {
|
|
243
|
+
await initBrowser();
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
capturePageScreenshot: (pagePath) => capturePageScreenshot(pagePath, { port, outputDir }),
|
|
247
|
+
captureAllPages: (pagesDir, options = {}) => captureAllPages(pagesDir, { port, outputDir, ...options }),
|
|
248
|
+
close: closeBrowser
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { buildSite } from './cli/build.js';
|
|
6
|
+
import { createScreenshotCapture } from './screenshot.js';
|
|
7
|
+
import { loadSiteConfig } from './utils/loadSiteConfig.js';
|
|
8
|
+
|
|
9
|
+
function getContentType(ext) {
|
|
10
|
+
const types = {
|
|
11
|
+
'.html': 'text/html',
|
|
12
|
+
'.css': 'text/css',
|
|
13
|
+
'.js': 'application/javascript',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.gif': 'image/gif',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.ico': 'image/x-icon'
|
|
20
|
+
};
|
|
21
|
+
return types[ext] || 'application/octet-stream';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function serveFile(filePath, res, skipWebSocket = false) {
|
|
25
|
+
const ext = path.extname(filePath);
|
|
26
|
+
const contentType = getContentType(ext);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(filePath);
|
|
30
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
31
|
+
res.end(content);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('Error serving file:', err);
|
|
34
|
+
res.writeHead(500);
|
|
35
|
+
res.end('Internal Server Error');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleRequest(req, res, siteDir = '_site') {
|
|
40
|
+
const urlParts = req.url.split('?');
|
|
41
|
+
let urlPath = urlParts[0];
|
|
42
|
+
const queryString = urlParts[1] || '';
|
|
43
|
+
|
|
44
|
+
// Check if this is a screenshot request
|
|
45
|
+
const isScreenshotRequest = queryString.includes('screenshot=true');
|
|
46
|
+
|
|
47
|
+
// Default to index.html for root
|
|
48
|
+
if (urlPath === '/') {
|
|
49
|
+
urlPath = '/index.html';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Handle trailing slash - remove it for processing
|
|
53
|
+
const hasTrailingSlash = urlPath.endsWith('/') && urlPath !== '/';
|
|
54
|
+
if (hasTrailingSlash) {
|
|
55
|
+
urlPath = urlPath.slice(0, -1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Handle paths without extensions
|
|
59
|
+
if (!path.extname(urlPath)) {
|
|
60
|
+
// First try as .html file
|
|
61
|
+
const htmlPath = path.join(siteDir, urlPath + '.html');
|
|
62
|
+
if (existsSync(htmlPath)) {
|
|
63
|
+
urlPath = urlPath + '.html';
|
|
64
|
+
} else {
|
|
65
|
+
// Try as directory with index.html
|
|
66
|
+
const indexPath = path.join(siteDir, urlPath, 'index.html');
|
|
67
|
+
if (existsSync(indexPath)) {
|
|
68
|
+
urlPath = path.join(urlPath, 'index.html');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filePath = path.join(siteDir, urlPath);
|
|
74
|
+
|
|
75
|
+
// Check if file exists
|
|
76
|
+
if (!existsSync(filePath)) {
|
|
77
|
+
res.writeHead(404);
|
|
78
|
+
res.end('404 Not Found');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check if it's a directory
|
|
83
|
+
const stats = fs.statSync(filePath);
|
|
84
|
+
if (stats.isDirectory()) {
|
|
85
|
+
// Try to serve index.html from the directory
|
|
86
|
+
const indexPath = path.join(filePath, 'index.html');
|
|
87
|
+
if (existsSync(indexPath)) {
|
|
88
|
+
return serveFile(indexPath, res, isScreenshotRequest);
|
|
89
|
+
} else {
|
|
90
|
+
res.writeHead(404);
|
|
91
|
+
res.end('404 Not Found');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Serve the file
|
|
97
|
+
serveFile(filePath, res, isScreenshotRequest);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createTempServer(port = 3001, siteDir = '_site') {
|
|
101
|
+
const httpServer = http.createServer((req, res) => {
|
|
102
|
+
handleRequest(req, res, siteDir);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const start = () => {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
httpServer.listen(port, '0.0.0.0', () => {
|
|
108
|
+
console.log(`📡 Temp server running at http://localhost:${port}/`);
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
httpServer.on('error', reject);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const close = () => {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
httpServer.close(() => {
|
|
119
|
+
console.log('📡 Temp server closed');
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return { start, close };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const screenshotCommand = async (options = {}) => {
|
|
129
|
+
const {
|
|
130
|
+
port = 3001,
|
|
131
|
+
rootDir = '.'
|
|
132
|
+
} = options;
|
|
133
|
+
|
|
134
|
+
console.log('📸 Starting screenshot capture for all pages...');
|
|
135
|
+
|
|
136
|
+
// Load config to get ignore patterns
|
|
137
|
+
const config = await loadSiteConfig(rootDir, false);
|
|
138
|
+
const ignorePatterns = config?.screenshots?.ignore || [];
|
|
139
|
+
|
|
140
|
+
if (ignorePatterns.length > 0) {
|
|
141
|
+
console.log('📋 Ignore patterns:', ignorePatterns);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build the site first
|
|
145
|
+
console.log('Building site...');
|
|
146
|
+
await buildSite({ rootDir });
|
|
147
|
+
console.log('Build complete');
|
|
148
|
+
|
|
149
|
+
// Start temporary server
|
|
150
|
+
const server = createTempServer(port);
|
|
151
|
+
await server.start();
|
|
152
|
+
|
|
153
|
+
// Initialize screenshot capture
|
|
154
|
+
const screenshotCapture = await createScreenshotCapture(port);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Capture screenshots for all pages
|
|
158
|
+
const pagesDir = path.join(rootDir, 'pages');
|
|
159
|
+
await screenshotCapture.captureAllPages(pagesDir, { ignorePatterns });
|
|
160
|
+
|
|
161
|
+
console.log('✅ All screenshots captured successfully!');
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('❌ Error capturing screenshots:', error);
|
|
164
|
+
} finally {
|
|
165
|
+
// Clean up
|
|
166
|
+
await screenshotCapture.close();
|
|
167
|
+
await server.close();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export default screenshotCommand;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { pathToFileURL } from 'url';
|
|
3
|
+
import MarkdownIt from 'markdown-it';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Load the sites.config.js file from a given directory
|
|
@@ -19,7 +20,16 @@ export async function loadSiteConfig(rootDir, throwOnError = true, bustCache = f
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const configModule = await import(importUrl);
|
|
22
|
-
|
|
23
|
+
const configExport = configModule.default;
|
|
24
|
+
|
|
25
|
+
// Check if the export is a function
|
|
26
|
+
if (typeof configExport === 'function') {
|
|
27
|
+
// Call the function with markdownit constructor
|
|
28
|
+
return configExport({ markdownit: MarkdownIt }) || {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Otherwise return as is (for backward compatibility)
|
|
32
|
+
return configExport || {};
|
|
23
33
|
} catch (e) {
|
|
24
34
|
// Only ignore file not found errors
|
|
25
35
|
if (e.code === 'ENOENT') {
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
5
|
-
import { buildSite } from './build.js';
|
|
6
|
-
import ScreenshotCapture from './screenshot.js';
|
|
7
|
-
|
|
8
|
-
class TempDevServer {
|
|
9
|
-
constructor(port = 3001) {
|
|
10
|
-
this.port = port;
|
|
11
|
-
this.siteDir = '_site';
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
start() {
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
this.httpServer = http.createServer((req, res) => {
|
|
17
|
-
this.handleRequest(req, res);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
this.httpServer.listen(this.port, '0.0.0.0', () => {
|
|
21
|
-
console.log(`📡 Temp server running at http://localhost:${this.port}/`);
|
|
22
|
-
resolve();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
this.httpServer.on('error', reject);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
handleRequest(req, res) {
|
|
30
|
-
const urlParts = req.url.split('?');
|
|
31
|
-
let urlPath = urlParts[0];
|
|
32
|
-
const queryString = urlParts[1] || '';
|
|
33
|
-
|
|
34
|
-
// Check if this is a screenshot request
|
|
35
|
-
const isScreenshotRequest = queryString.includes('screenshot=true');
|
|
36
|
-
|
|
37
|
-
// Default to index.html for root
|
|
38
|
-
if (urlPath === '/') {
|
|
39
|
-
urlPath = '/index.html';
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Handle trailing slash - remove it for processing
|
|
43
|
-
const hasTrailingSlash = urlPath.endsWith('/') && urlPath !== '/';
|
|
44
|
-
if (hasTrailingSlash) {
|
|
45
|
-
urlPath = urlPath.slice(0, -1);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Handle paths without extensions
|
|
49
|
-
if (!path.extname(urlPath)) {
|
|
50
|
-
// First try as .html file
|
|
51
|
-
const htmlPath = path.join(this.siteDir, urlPath + '.html');
|
|
52
|
-
if (existsSync(htmlPath)) {
|
|
53
|
-
urlPath = urlPath + '.html';
|
|
54
|
-
} else {
|
|
55
|
-
// Try as directory with index.html
|
|
56
|
-
const indexPath = path.join(this.siteDir, urlPath, 'index.html');
|
|
57
|
-
if (existsSync(indexPath)) {
|
|
58
|
-
urlPath = path.join(urlPath, 'index.html');
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const filePath = path.join(this.siteDir, urlPath);
|
|
64
|
-
|
|
65
|
-
// Check if file exists
|
|
66
|
-
if (!existsSync(filePath)) {
|
|
67
|
-
res.writeHead(404);
|
|
68
|
-
res.end('404 Not Found');
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Check if it's a directory
|
|
73
|
-
const stats = fs.statSync(filePath);
|
|
74
|
-
if (stats.isDirectory()) {
|
|
75
|
-
// Try to serve index.html from the directory
|
|
76
|
-
const indexPath = path.join(filePath, 'index.html');
|
|
77
|
-
if (existsSync(indexPath)) {
|
|
78
|
-
return this.serveFile(indexPath, res, isScreenshotRequest);
|
|
79
|
-
} else {
|
|
80
|
-
res.writeHead(404);
|
|
81
|
-
res.end('404 Not Found');
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Serve the file
|
|
87
|
-
this.serveFile(filePath, res, isScreenshotRequest);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
serveFile(filePath, res, skipWebSocket = false) {
|
|
91
|
-
const ext = path.extname(filePath);
|
|
92
|
-
const contentType = this.getContentType(ext);
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
let content = fs.readFileSync(filePath);
|
|
96
|
-
|
|
97
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
98
|
-
res.end(content);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error('Error serving file:', err);
|
|
101
|
-
res.writeHead(500);
|
|
102
|
-
res.end('Internal Server Error');
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
getContentType(ext) {
|
|
107
|
-
const types = {
|
|
108
|
-
'.html': 'text/html',
|
|
109
|
-
'.css': 'text/css',
|
|
110
|
-
'.js': 'application/javascript',
|
|
111
|
-
'.json': 'application/json',
|
|
112
|
-
'.png': 'image/png',
|
|
113
|
-
'.jpg': 'image/jpeg',
|
|
114
|
-
'.gif': 'image/gif',
|
|
115
|
-
'.svg': 'image/svg+xml',
|
|
116
|
-
'.ico': 'image/x-icon'
|
|
117
|
-
};
|
|
118
|
-
return types[ext] || 'application/octet-stream';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
close() {
|
|
122
|
-
return new Promise((resolve) => {
|
|
123
|
-
this.httpServer.close(() => {
|
|
124
|
-
console.log('📡 Temp server closed');
|
|
125
|
-
resolve();
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const screenshotCommand = async (options = {}) => {
|
|
132
|
-
const {
|
|
133
|
-
port = 3001,
|
|
134
|
-
rootDir = '.'
|
|
135
|
-
} = options;
|
|
136
|
-
|
|
137
|
-
console.log('📸 Starting screenshot capture for all pages...');
|
|
138
|
-
|
|
139
|
-
// Build the site first
|
|
140
|
-
console.log('Building site...');
|
|
141
|
-
await buildSite({ rootDir });
|
|
142
|
-
console.log('Build complete');
|
|
143
|
-
|
|
144
|
-
// Start temporary server
|
|
145
|
-
const server = new TempDevServer(port);
|
|
146
|
-
await server.start();
|
|
147
|
-
|
|
148
|
-
// Initialize screenshot capture
|
|
149
|
-
const screenshotCapture = new ScreenshotCapture(port);
|
|
150
|
-
await screenshotCapture.init();
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
// Capture screenshots for all pages
|
|
154
|
-
const pagesDir = path.join(rootDir, 'pages');
|
|
155
|
-
await screenshotCapture.captureAllPages(pagesDir);
|
|
156
|
-
|
|
157
|
-
console.log('✅ All screenshots captured successfully!');
|
|
158
|
-
} catch (error) {
|
|
159
|
-
console.error('❌ Error capturing screenshots:', error);
|
|
160
|
-
} finally {
|
|
161
|
-
// Clean up
|
|
162
|
-
await screenshotCapture.close();
|
|
163
|
-
await server.close();
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
export default screenshotCommand;
|
package/src/cli/screenshot.js
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { chromium } from 'playwright';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
|
|
5
|
-
class ScreenshotCapture {
|
|
6
|
-
constructor(port = 3001, outputDir = '_screenshots') {
|
|
7
|
-
this.port = port;
|
|
8
|
-
this.outputDir = outputDir;
|
|
9
|
-
this.browser = null;
|
|
10
|
-
this.context = null;
|
|
11
|
-
this.isInitialized = false;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async init() {
|
|
15
|
-
if (this.isInitialized) return;
|
|
16
|
-
|
|
17
|
-
console.log('🎬 Initializing Playwright browser...');
|
|
18
|
-
this.browser = await chromium.launch({
|
|
19
|
-
headless: true
|
|
20
|
-
});
|
|
21
|
-
this.context = await this.browser.newContext({
|
|
22
|
-
viewport: { width: 1366, height: 768 }, // Most common laptop viewport
|
|
23
|
-
deviceScaleFactor: 0.75 // Reduce pixel density for smaller PNG files
|
|
24
|
-
});
|
|
25
|
-
this.isInitialized = true;
|
|
26
|
-
console.log('✅ Browser initialized for screenshots');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async capturePageScreenshot(pagePath) {
|
|
30
|
-
if (!this.isInitialized) {
|
|
31
|
-
await this.init();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
// Convert file path to URL path
|
|
36
|
-
// pages/index.md -> /
|
|
37
|
-
// pages/about.md -> /about
|
|
38
|
-
// pages/store/index.md -> /store/
|
|
39
|
-
// pages/store/products.md -> /store/products
|
|
40
|
-
let urlPath = pagePath
|
|
41
|
-
.replace(/^pages\//, '')
|
|
42
|
-
.replace(/\.(md|yaml|yml)$/, '');
|
|
43
|
-
|
|
44
|
-
if (urlPath === 'index') {
|
|
45
|
-
urlPath = '/';
|
|
46
|
-
} else if (urlPath.endsWith('/index')) {
|
|
47
|
-
urlPath = urlPath.replace(/\/index$/, '/');
|
|
48
|
-
} else if (urlPath) {
|
|
49
|
-
// Add leading slash if not root
|
|
50
|
-
urlPath = '/' + urlPath;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const url = `http://localhost:${this.port}${urlPath}?screenshot=true`;
|
|
54
|
-
|
|
55
|
-
// Determine screenshot path
|
|
56
|
-
// / -> index.png
|
|
57
|
-
// /about -> about.png
|
|
58
|
-
// /store/ -> store/index.png
|
|
59
|
-
// /store/products -> store/products.png
|
|
60
|
-
let screenshotPath;
|
|
61
|
-
if (urlPath === '/') {
|
|
62
|
-
screenshotPath = 'index.png';
|
|
63
|
-
} else if (urlPath.endsWith('/')) {
|
|
64
|
-
screenshotPath = urlPath.slice(1, -1) + '/index.png';
|
|
65
|
-
} else {
|
|
66
|
-
screenshotPath = urlPath.slice(1) + '.png';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const fullScreenshotPath = path.join(this.outputDir, screenshotPath);
|
|
70
|
-
|
|
71
|
-
// Ensure directory exists
|
|
72
|
-
const screenshotDir = path.dirname(fullScreenshotPath);
|
|
73
|
-
if (!fs.existsSync(screenshotDir)) {
|
|
74
|
-
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
console.log(`📸 Capturing screenshot: ${url} -> ${fullScreenshotPath}`);
|
|
78
|
-
|
|
79
|
-
const page = await this.context.newPage();
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
// Navigate to the page
|
|
83
|
-
await page.goto(url, {
|
|
84
|
-
waitUntil: 'networkidle',
|
|
85
|
-
timeout: 30000
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// // Wait a bit for content to load
|
|
89
|
-
// await page.waitForTimeout(2000);
|
|
90
|
-
|
|
91
|
-
// Take screenshot with PNG optimization
|
|
92
|
-
await page.screenshot({
|
|
93
|
-
path: fullScreenshotPath,
|
|
94
|
-
fullPage: true,
|
|
95
|
-
type: 'png'
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
console.log(`✅ Screenshot saved: ${fullScreenshotPath}`);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
console.error(`❌ Failed to capture ${url}:`, error.message);
|
|
101
|
-
} finally {
|
|
102
|
-
await page.close();
|
|
103
|
-
}
|
|
104
|
-
} catch (error) {
|
|
105
|
-
console.error('❌ Screenshot error:', error);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async captureAllPages(pagesDir) {
|
|
110
|
-
if (!fs.existsSync(pagesDir)) {
|
|
111
|
-
console.log('Pages directory does not exist:', pagesDir);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const captureRecursive = async (dir, basePath = '') => {
|
|
116
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
117
|
-
|
|
118
|
-
for (const entry of entries) {
|
|
119
|
-
const fullPath = path.join(dir, entry.name);
|
|
120
|
-
const relativePath = path.join(basePath, entry.name);
|
|
121
|
-
|
|
122
|
-
if (entry.isDirectory()) {
|
|
123
|
-
// Recurse into subdirectories
|
|
124
|
-
await captureRecursive(fullPath, relativePath);
|
|
125
|
-
} else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.yaml') || entry.name.endsWith('.yml'))) {
|
|
126
|
-
// Capture screenshot for this page
|
|
127
|
-
const pagePath = path.join('pages', relativePath);
|
|
128
|
-
await this.capturePageScreenshot(pagePath);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
await captureRecursive(pagesDir);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async close() {
|
|
137
|
-
if (this.browser) {
|
|
138
|
-
await this.browser.close();
|
|
139
|
-
this.isInitialized = false;
|
|
140
|
-
console.log('🔚 Browser closed');
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export default ScreenshotCapture;
|