@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 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-rc5",
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.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.md,
25
25
  functions: functions || config.functions || {},
26
26
  quiet
27
27
  });
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,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
- export function createSiteBuilder({ fs, rootDir = '.', mdRender, functions = {}, quiet = false }) {
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 mdRender or default to standard markdown-it
11
- const md = mdRender || MarkdownIt();
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
- const url = '/' + outputRelativePath.replace(/\\/g, '/');
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
- // Merge global data with frontmatter and collections for the page context
197
- const pageData = { ...globalData, ...frontmatter, collections };
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 = md.render(rawContent);
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 {
@@ -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;