@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/sites",
3
- "version": "0.2.0-rc6",
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.mdRender - Optional markdown renderer
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(), mdRender, functions, quiet = false } = options;
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 (!mdRender || !functions) {
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
- mdRender: mdRender || config.mdRender,
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
@@ -1,6 +1,6 @@
1
1
  import { buildSite } from './build.js';
2
2
  import watchSite from './watch.js';
3
- import screenshotCommand from './screenshot-command.js';
3
+ import screenshotCommand from '../screenshotRunner.js';
4
4
 
5
5
  export {
6
6
  buildSite,
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 ScreenshotCapture from './screenshot.js';
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
- mdRender: config.mdRender || options.mdRender,
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.mdRender) {
344
- console.log('✅ Custom mdRender function found');
343
+ if (config.md) {
344
+ console.log('✅ Custom md function found');
345
345
  } else {
346
- console.log('ℹ️ No custom mdRender function in config');
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
- mdRender: config.mdRender,
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 = new ScreenshotCapture(port);
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
- mdRender: config.mdRender,
384
+ md: config.md,
386
385
  functions: config.functions || {}
387
386
  }, server, screenshotCapture);
388
387
  }
@@ -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 = '.', mdRender, functions = {}, quiet = false }) {
34
- return function build() {
35
- // Use provided mdRender or default to standard markdown-it
36
- const md = mdRender || MarkdownIt();
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
- const htmlContent = md.render(rawContent);
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
  };
@@ -1,126 +1,49 @@
1
- import MarkdownIt from 'markdown-it';
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(/[\s_-]+/g, '-')
10
- .replace(/^-+|-+$/g, '');
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
- * Custom Markdown renderer configuration for Rettangoli
15
- * Adds rtgl-specific elements and styling
16
- */
17
- export const createRtglMarkdown = () => {
18
- const md = MarkdownIt({
19
- // Additional configuration can be added here
20
- });
21
-
22
- // Header configuration
23
- md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
24
- const token = tokens[idx];
25
- const level = token.markup.length;
26
- const inlineToken = tokens[idx + 1];
27
- const headingText = inlineToken.content;
28
- const id = generateSlug(headingText);
29
-
30
- // Map heading levels to size values
31
- const sizes = { 1: "h1", 2: "h2", 3: "h3", 4: "h4" };
32
- const size = sizes[level] || "md";
33
-
34
- return `<rtgl-text id="${id}" mt="lg" s="${size}" mb="md"> <a href="#${id}" style="display: contents;">`;
35
- };
36
-
37
- md.renderer.rules.heading_close = () => "</a></rtgl-text>\n";
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
- return self.renderToken(tokens, idx, options);
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
- return md;
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
- // Export a default instance for convenience
126
- export default createRtglMarkdown();
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
- return configModule.default || {};
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;
@@ -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;