@rettangoli/sites 0.2.0-rc5 → 0.2.0-rc7
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/README.md +30 -0
- package/package.json +3 -1
- package/src/cli/build.js +4 -4
- package/src/cli/index.js +1 -1
- package/src/cli/watch.js +8 -9
- package/src/createSiteBuilder.js +52 -7
- 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/README.md
CHANGED
|
@@ -172,6 +172,36 @@ title: Blog
|
|
|
172
172
|
- ${post.data.title}
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
### Page Variables
|
|
176
|
+
|
|
177
|
+
Every page has access to a `page.url` variable that contains the page's URL path. All frontmatter variables (like `title`) are available directly:
|
|
178
|
+
|
|
179
|
+
**pages/about.yaml**
|
|
180
|
+
```yaml
|
|
181
|
+
---
|
|
182
|
+
title: About Us
|
|
183
|
+
---
|
|
184
|
+
- paragraph: Current page URL is ${page.url}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**templates/base.yaml**
|
|
188
|
+
```yaml
|
|
189
|
+
- tag: head
|
|
190
|
+
children:
|
|
191
|
+
- tag: title
|
|
192
|
+
children: ${title}
|
|
193
|
+
- tag: link
|
|
194
|
+
rel: canonical
|
|
195
|
+
href: ${page.url}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The `page.url` variable contains the page's URL path without the `.html` extension, with trailing slashes:
|
|
199
|
+
- `/index.html` → `page.url` = `/`
|
|
200
|
+
- `/about.html` → `page.url` = `/about/`
|
|
201
|
+
- `/docs/guide.html` → `page.url` = `/docs/guide/`
|
|
202
|
+
|
|
203
|
+
This variable is available in both pages and templates, making it useful for navigation, breadcrumbs, and canonical URLs.
|
|
204
|
+
|
|
175
205
|
### Static Files
|
|
176
206
|
|
|
177
207
|
Everything in the `static/` directory is copied directly to the output:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/sites",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-rc7",
|
|
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,7 +20,9 @@
|
|
|
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
|
},
|
package/src/cli/build.js
CHANGED
|
@@ -6,22 +6,22 @@ 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.md,
|
|
25
25
|
functions: functions || config.functions || {},
|
|
26
26
|
quiet
|
|
27
27
|
});
|
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,11 +4,37 @@ 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';
|
|
8
|
+
|
|
9
|
+
// Deep merge utility function
|
|
10
|
+
function deepMerge(target, source) {
|
|
11
|
+
const output = { ...target };
|
|
12
|
+
|
|
13
|
+
if (isObject(target) && isObject(source)) {
|
|
14
|
+
Object.keys(source).forEach(key => {
|
|
15
|
+
if (isObject(source[key])) {
|
|
16
|
+
if (!(key in target)) {
|
|
17
|
+
Object.assign(output, { [key]: source[key] });
|
|
18
|
+
} else {
|
|
19
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
Object.assign(output, { [key]: source[key] });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return output;
|
|
28
|
+
}
|
|
7
29
|
|
|
8
|
-
|
|
30
|
+
function isObject(item) {
|
|
31
|
+
return item && typeof item === 'object' && !Array.isArray(item);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createSiteBuilder({ fs, rootDir = '.', md, functions = {}, quiet = false }) {
|
|
9
35
|
return function build() {
|
|
10
|
-
// Use provided
|
|
11
|
-
const
|
|
36
|
+
// Use provided md or default to rtglMarkdown
|
|
37
|
+
const mdInstance = md || rtglMarkdown(MarkdownIt);
|
|
12
38
|
|
|
13
39
|
// Read all partials and create a JSON object
|
|
14
40
|
const partialsDir = path.join(rootDir, 'partials');
|
|
@@ -124,7 +150,14 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
124
150
|
// Calculate URL
|
|
125
151
|
const outputFileName = item.name.replace(/\.(yaml|md)$/, '.html');
|
|
126
152
|
const outputRelativePath = basePath ? path.join(basePath, outputFileName) : outputFileName;
|
|
127
|
-
|
|
153
|
+
let url = '/' + outputRelativePath.replace(/\\/g, '/').replace(/\.html$/, '');
|
|
154
|
+
// Special case: /index becomes /
|
|
155
|
+
if (url === '/index') {
|
|
156
|
+
url = '/';
|
|
157
|
+
} else {
|
|
158
|
+
// Add trailing slash for all non-root URLs
|
|
159
|
+
url = url + '/';
|
|
160
|
+
}
|
|
128
161
|
|
|
129
162
|
// Process tags
|
|
130
163
|
if (frontmatter.tags) {
|
|
@@ -193,14 +226,26 @@ export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {},
|
|
|
193
226
|
const contentStart = frontmatterEnd + 1;
|
|
194
227
|
const rawContent = lines.slice(contentStart).join('\n').trim();
|
|
195
228
|
|
|
196
|
-
//
|
|
197
|
-
|
|
229
|
+
// Calculate URL for current page
|
|
230
|
+
let url = '/' + outputRelativePath.replace(/\\/g, '/').replace(/\.html$/, '');
|
|
231
|
+
// Special case: /index becomes /
|
|
232
|
+
if (url === '/index') {
|
|
233
|
+
url = '/';
|
|
234
|
+
} else {
|
|
235
|
+
// Add trailing slash for all non-root URLs
|
|
236
|
+
url = url + '/';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Deep merge global data with frontmatter and collections for the page context
|
|
240
|
+
const pageData = deepMerge(globalData, frontmatter);
|
|
241
|
+
pageData.collections = collections;
|
|
242
|
+
pageData.page = { url };
|
|
198
243
|
|
|
199
244
|
let processedPageContent;
|
|
200
245
|
|
|
201
246
|
if (isMarkdown) {
|
|
202
247
|
// Process markdown content with MarkdownIt
|
|
203
|
-
const htmlContent =
|
|
248
|
+
const htmlContent = mdInstance.render(rawContent);
|
|
204
249
|
// For markdown, store as raw HTML that will be inserted directly
|
|
205
250
|
processedPageContent = { __html: htmlContent };
|
|
206
251
|
} else {
|
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;
|