@rettangoli/vt 0.0.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/README.md ADDED
@@ -0,0 +1,3 @@
1
+
2
+ # Rettangoli Visual Testing
3
+
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@rettangoli/vt",
3
+ "version": "0.0.1",
4
+ "description": "Rettangoli Visual Testing",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./cli": "./src/cli/index.js"
10
+ },
11
+ "scripts": {
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "dependencies": {
15
+ "commander": "^13.1.0",
16
+ "js-yaml": "^4.1.0",
17
+ "liquidjs": "^10.21.0",
18
+ "looks-same": "^9.0.0",
19
+ "playwright": "^1.52.0",
20
+ "sharp": "^0.33.0",
21
+ "shiki": "^3.3.0"
22
+ }
23
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { rm, cp, mkdir } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
+
5
+ /**
6
+ * Recursively copy only WebP files from source to destination
7
+ */
8
+ async function copyWebpFiles(sourceDir, destDir) {
9
+ if (!existsSync(sourceDir)) {
10
+ return;
11
+ }
12
+
13
+ const items = readdirSync(sourceDir);
14
+
15
+ for (const item of items) {
16
+ const sourcePath = join(sourceDir, item);
17
+ const destPath = join(destDir, item);
18
+
19
+ if (statSync(sourcePath).isDirectory()) {
20
+ // Recursively copy subdirectories
21
+ await copyWebpFiles(sourcePath, destPath);
22
+ } else if (item.endsWith('.webp')) {
23
+ // Copy WebP files only
24
+ await mkdir(dirname(destPath), { recursive: true });
25
+ await cp(sourcePath, destPath);
26
+ console.log(`Copied: ${sourcePath} -> ${destPath}`);
27
+ }
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Accepts candidate screenshots as the new reference by removing the existing reference
33
+ * directory and copying the candidate directory to reference.
34
+ */
35
+ async function acceptReference(options = {}) {
36
+ const {
37
+ vizPath = "./viz",
38
+ } = options;
39
+
40
+ const referenceDir = join(vizPath, "reference");
41
+ const siteOutputPath = join(".rettangoli", "vt", "_site");
42
+ const candidateDir = join(siteOutputPath, "candidate");
43
+
44
+ console.log('Accepting candidate as new reference...');
45
+
46
+ // Check if candidate directory exists
47
+ if (!existsSync(candidateDir)) {
48
+ console.error('Error: Candidate directory does not exist!');
49
+ process.exit(1);
50
+ }
51
+
52
+ try {
53
+ // Remove reference directory if it exists
54
+ if (existsSync(referenceDir)) {
55
+ console.log('Removing existing reference directory...');
56
+ await rm(referenceDir, { recursive: true, force: true });
57
+ }
58
+
59
+ // Wait for 100ms to ensure the directory is removed
60
+ await new Promise((resolve) => {
61
+ setTimeout(resolve, 100);
62
+ })
63
+
64
+ // Copy only WebP files from candidate to reference
65
+ console.log('Copying WebP files from candidate to reference...');
66
+ await copyWebpFiles(candidateDir, referenceDir);
67
+
68
+ console.log('Done! New reference accepted.');
69
+ } catch (error) {
70
+ console.error('Error:', error.message);
71
+ process.exit(1);
72
+ }
73
+ }
74
+
75
+ export default acceptReference;
@@ -0,0 +1,104 @@
1
+ import { cp, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import {
5
+ generateHtml,
6
+ startWebServer,
7
+ takeScreenshots,
8
+ generateOverview,
9
+ readYaml,
10
+ } from "../common.js";
11
+
12
+ const libraryTemplatesPath = new URL('./templates', import.meta.url).pathname;
13
+ const libraryStaticPath = new URL('./static', import.meta.url).pathname;
14
+
15
+
16
+ /**
17
+ * Main function that orchestrates the entire process
18
+ */
19
+ async function main(options) {
20
+ const {
21
+ skipScreenshots = false,
22
+ vizPath = "./vt",
23
+ screenshotWaitTime = 0,
24
+ port = 3001
25
+ } = options;
26
+
27
+ const specsPath = join(vizPath, "specs");
28
+ const mainConfigPath = "rettangoli.config.yaml";
29
+ const siteOutputPath = join(".rettangoli", "vt", "_site");
30
+ const candidatePath = join(siteOutputPath, "candidate");
31
+
32
+ // Read VT config from main rettangoli.config.yaml
33
+ let configData = {};
34
+ try {
35
+ const mainConfig = await readYaml(mainConfigPath);
36
+ configData = mainConfig.vt || {};
37
+ } catch (error) {
38
+ console.log("Main config file not found, using defaults");
39
+ }
40
+
41
+ // Clear candidate directory
42
+ await rm(candidatePath, { recursive: true, force: true });
43
+ await new Promise((resolve) => setTimeout(resolve, 100));
44
+
45
+ // Copy static files from library to site directory
46
+ await cp(libraryStaticPath, siteOutputPath, { recursive: true });
47
+
48
+ // Copy user's static files if they exist
49
+ const userStaticPath = join(vizPath, "static");
50
+ if (existsSync(userStaticPath)) {
51
+ await cp(userStaticPath, siteOutputPath, { recursive: true });
52
+ }
53
+
54
+ // Check for local templates first, fallback to library templates
55
+ const localTemplatesPath = join(vizPath, "templates");
56
+ const defaultTemplatePath = existsSync(join(localTemplatesPath, "default.html"))
57
+ ? join(localTemplatesPath, "default.html")
58
+ : join(libraryTemplatesPath, "default.html");
59
+
60
+ const indexTemplatePath = existsSync(join(localTemplatesPath, "index.html"))
61
+ ? join(localTemplatesPath, "index.html")
62
+ : join(libraryTemplatesPath, "index.html");
63
+
64
+ // Generate HTML files
65
+ const generatedFiles = await generateHtml(
66
+ specsPath,
67
+ defaultTemplatePath,
68
+ candidatePath
69
+ );
70
+
71
+ // Generate overview page with all files
72
+ generateOverview(
73
+ generatedFiles,
74
+ indexTemplatePath,
75
+ join(siteOutputPath, "index.html"),
76
+ configData
77
+ );
78
+
79
+ if (!skipScreenshots) {
80
+ // Start web server from site output path to serve both /public and /candidate
81
+ const server = startWebServer(
82
+ siteOutputPath,
83
+ vizPath,
84
+ port
85
+ );
86
+ try {
87
+ // Take screenshots with specified concurrency
88
+ await takeScreenshots(
89
+ generatedFiles,
90
+ `http://localhost:${port}`,
91
+ candidatePath,
92
+ 24,
93
+ screenshotWaitTime
94
+ );
95
+ } finally {
96
+ // Stop server
97
+ server.stop();
98
+ console.log("Server stopped");
99
+ }
100
+ }
101
+
102
+ }
103
+
104
+ export default main;
@@ -0,0 +1,9 @@
1
+ import generate from './generate.js';
2
+ import report from './report.js';
3
+ import accept from './accept.js';
4
+
5
+ export {
6
+ generate,
7
+ report,
8
+ accept,
9
+ }
@@ -0,0 +1,214 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import looksSame from "looks-same";
4
+ import sharp from "sharp";
5
+ import { Liquid } from "liquidjs";
6
+ import { cp } from "node:fs/promises";
7
+
8
+ const libraryTemplatesPath = new URL('./templates', import.meta.url).pathname;
9
+
10
+ // Initialize Liquid engine
11
+ const engine = new Liquid();
12
+
13
+ // Add custom filter to convert string to lowercase and replace spaces with hyphens
14
+ engine.registerFilter("slug", (value) => {
15
+ if (typeof value !== "string") return "";
16
+ return value.toLowerCase().replace(/\s+/g, "-");
17
+ });
18
+
19
+ // Recursively get all files in a directory
20
+ function getAllFiles(dir, fileList = []) {
21
+ const files = fs.readdirSync(dir);
22
+
23
+ files.forEach((file) => {
24
+ const filePath = path.join(dir, file);
25
+ if (fs.statSync(filePath).isDirectory()) {
26
+ fileList = getAllFiles(filePath, fileList);
27
+ } else {
28
+ fileList.push(filePath);
29
+ }
30
+ });
31
+
32
+ return fileList;
33
+ }
34
+
35
+ async function compareImages(artifactPath, goldPath) {
36
+ try {
37
+ const {equal, diffBounds, diffClusters} = await looksSame(artifactPath, goldPath, {
38
+ strict: true,
39
+ ignoreAntialiasing: true,
40
+ ignoreCaret: true,
41
+ shouldCluster: true,
42
+ clustersSize: 10
43
+ });
44
+
45
+ return {
46
+ equal,
47
+ error: false,
48
+ diffBounds,
49
+ diffClusters
50
+ };
51
+ } catch (error) {
52
+ console.error("Error comparing images:", error);
53
+ return {
54
+ equal: false,
55
+ error: true,
56
+ diffBounds: null,
57
+ diffClusters: null
58
+ };
59
+ }
60
+ }
61
+
62
+ async function generateReport({ results, templatePath, outputPath }) {
63
+ try {
64
+ // Read the template file
65
+ const templateContent = fs.readFileSync(templatePath, "utf8");
66
+
67
+ // Render the template with the results data
68
+ const renderedHtml = await engine.parseAndRender(templateContent, {
69
+ files: results,
70
+ });
71
+
72
+ // Write the rendered HTML to the output file
73
+ fs.writeFileSync(outputPath, renderedHtml);
74
+
75
+ console.log(`Report generated successfully at ${outputPath}`);
76
+ } catch (error) {
77
+ console.error("Error generating report:", error);
78
+ }
79
+ }
80
+
81
+ async function main(options = {}) {
82
+ const { vizPath = "./viz" } = options;
83
+
84
+ const siteOutputPath = path.join(".rettangoli", "vt", "_site");
85
+ const candidateDir = path.join(siteOutputPath, "candidate");
86
+ const originalReferenceDir = path.join(vizPath, "reference");
87
+ const siteReferenceDir = path.join(siteOutputPath, "reference");
88
+ const templatePath = path.join(libraryTemplatesPath, "report.html");
89
+ const outputPath = path.join(siteOutputPath, "report.html");
90
+
91
+ if (!fs.existsSync(originalReferenceDir)) {
92
+ console.log("Reference directory does not exist, creating it...");
93
+ fs.mkdirSync(originalReferenceDir, { recursive: true });
94
+ }
95
+
96
+ // Copy reference directory to _site for web access
97
+ if (fs.existsSync(originalReferenceDir)) {
98
+ console.log("Copying reference directory to _site...");
99
+ await cp(originalReferenceDir, siteReferenceDir, { recursive: true });
100
+ }
101
+
102
+ try {
103
+ // Get all WebP files recursively (only compare screenshots, not HTML)
104
+ const candidateFiles = getAllFiles(candidateDir).filter(file => file.endsWith('.webp'));
105
+ const referenceFiles = getAllFiles(originalReferenceDir).filter(file => file.endsWith('.webp'));
106
+
107
+ console.log("Candidate Screenshots:", candidateFiles.length);
108
+ console.log("Reference Screenshots:", referenceFiles.length);
109
+
110
+ const results = [];
111
+
112
+ // Get relative paths for comparison
113
+ const candidateRelativePaths = candidateFiles.map((file) =>
114
+ path.relative(candidateDir, file)
115
+ );
116
+ const referenceRelativePaths = referenceFiles.map((file) =>
117
+ path.relative(originalReferenceDir, file)
118
+ );
119
+
120
+ // Get all unique paths from both directories
121
+ const allPaths = [
122
+ ...new Set([...candidateRelativePaths, ...referenceRelativePaths]),
123
+ ];
124
+
125
+ for (const relativePath of allPaths) {
126
+ const candidatePath = path.join(candidateDir, relativePath);
127
+ const referencePath = path.join(originalReferenceDir, relativePath);
128
+ const siteReferencePath = path.join(siteReferenceDir, relativePath);
129
+
130
+ const candidateExists = fs.existsSync(candidatePath);
131
+ const referenceExists = fs.existsSync(referencePath);
132
+
133
+ // Skip if neither file exists (shouldn't happen, but just in case)
134
+ if (!candidateExists && !referenceExists) continue;
135
+
136
+ let equal = true;
137
+ let error = false;
138
+ let diffBounds = null;
139
+ let diffClusters = null;
140
+
141
+ // Compare images if both exist
142
+ if (candidateExists && referenceExists) {
143
+ const comparison = await compareImages(candidatePath, referencePath);
144
+ equal = comparison.equal;
145
+ error = comparison.error;
146
+ diffBounds = comparison.diffBounds;
147
+ diffClusters = comparison.diffClusters;
148
+ } else {
149
+ equal = false; // If one file is missing, they're not equal
150
+ }
151
+
152
+ if (!error) {
153
+ results.push({
154
+ candidatePath: candidateExists ? candidatePath : null,
155
+ referencePath: referenceExists ? siteReferencePath : null, // Use site reference path for HTML report
156
+ path: relativePath,
157
+ equal: candidateExists && referenceExists ? equal : false,
158
+ diffBounds,
159
+ diffClusters,
160
+ onlyInCandidate: candidateExists && !referenceExists,
161
+ onlyInReference: !candidateExists && referenceExists,
162
+ });
163
+ }
164
+ }
165
+
166
+ const mismatchingItems = results
167
+ .filter(
168
+ (result) =>
169
+ !result.equal || result.onlyInCandidate || result.onlyInReference
170
+ )
171
+ .map((result) => {
172
+ return {
173
+ candidatePath: result.candidatePath
174
+ ? path.relative(siteOutputPath, result.candidatePath)
175
+ : null,
176
+ referencePath: result.referencePath
177
+ ? path.relative(siteOutputPath, result.referencePath)
178
+ : null,
179
+ equal: result.equal,
180
+ diffBounds: result.diffBounds,
181
+ diffClusters: result.diffClusters,
182
+ onlyInCandidate: result.onlyInCandidate,
183
+ onlyInReference: result.onlyInReference,
184
+ };
185
+ });
186
+ console.log("Mismatching Items (JSON):");
187
+ mismatchingItems.forEach(item => {
188
+ const logData = {
189
+ candidatePath: item.candidatePath,
190
+ referencePath: item.referencePath,
191
+ equal: item.equal,
192
+ diffBounds: item.diffBounds,
193
+ diffClusters: item.diffClusters
194
+ };
195
+ console.log(JSON.stringify(logData, null, 2));
196
+ });
197
+
198
+ // Summary at the end
199
+ console.log(`\nSummary:`);
200
+ console.log(`Total images: ${results.length}`);
201
+ console.log(`Mismatched images: ${mismatchingItems.length}`);
202
+
203
+ // Generate HTML report
204
+ await generateReport({
205
+ results: mismatchingItems,
206
+ templatePath,
207
+ outputPath,
208
+ });
209
+ } catch (error) {
210
+ console.error("Error reading directories:", error);
211
+ }
212
+ }
213
+
214
+ export default main;
@@ -0,0 +1,155 @@
1
+ html {
2
+ height: 100%;
3
+ margin: 0;
4
+ -ms-overflow-style: none; /* IE and Edge */
5
+ scrollbar-width: none; /* Firefox */
6
+ }
7
+
8
+ :root {
9
+ --width-stretch: 100%;
10
+ }
11
+
12
+ /* Override in supported browsers */
13
+ @supports (width: -webkit-fill-available) {
14
+ :root {
15
+ --width-stretch: -webkit-fill-available;
16
+ }
17
+ }
18
+ @supports (width: -moz-available) {
19
+ :root {
20
+ --width-stretch: -moz-available;
21
+ }
22
+ }
23
+
24
+ /* Hide scrollbar for Chrome, Safari */
25
+ html::-webkit-scrollbar {
26
+ display: none;
27
+ }
28
+
29
+ body::-webkit-scrollbar {
30
+ display: none;
31
+ }
32
+
33
+ body {
34
+ -ms-overflow-style: none;
35
+ scrollbar-width: none;
36
+ height: 100%;
37
+ margin: 0;
38
+ font-family: Roboto, -apple-system, "Helvetica Neue", sans-serif;
39
+ display: flex;
40
+ flex-direction: column;
41
+ background-color: var(--background);
42
+ color: var(--foreground);
43
+ }
44
+
45
+ :root {
46
+ --spacing-xs: 2px;
47
+ --spacing-sm: 4px;
48
+ --spacing-md: 8px;
49
+ --spacing-lg: 16px;
50
+ --spacing-xl: 32px;
51
+
52
+ --border-radius-xs: 1px;
53
+ --border-radius-sm: 2px;
54
+ --border-radius-md: 4px;
55
+ --border-radius-lg: 8px;
56
+ --border-radius-xl: 16px;
57
+ --border-radius-f: 50%;
58
+
59
+ --border-width-xs: 1px;
60
+ --border-width-sm: 2px;
61
+ --border-width-md: 4px;
62
+ --border-width-lg: 8px;
63
+ --border-width-xl: 16px;
64
+
65
+ --shadow-sm: 0px 2px 6px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
66
+ --shadow-md: 0px 5px 12px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
67
+ --shadow-lg: 0px 10px 24px rgba(0, 0, 0, .45), 0px 3px 5px rgba(0, 0, 0, .35), inset 0px .5px 0px rgba(255, 255, 255, .08), inset 0px 0px .5px rgba(255, 255, 255, .35);
68
+
69
+ --h1-font-size: 3rem;
70
+ --h1-font-weight: 800;
71
+ --h1-line-height: 1;
72
+ --h1-letter-spacing: -.025em;
73
+
74
+ --h2-font-size: 1.875rem;
75
+ --h2-font-weight: 600;
76
+ --h2-line-height: 2.25rem;
77
+ --h2-letter-spacing: -.025em;
78
+
79
+ --h3-font-size: 1.5rem;
80
+ --h3-font-weight: 600;
81
+ --h3-line-height: 2rem;
82
+ --h3-letter-spacing: -.025em;
83
+
84
+ --h4-font-size: 1.25rem;
85
+ --h4-font-weight: 600;
86
+ --h4-line-height: 1.75rem;
87
+ --h4-letter-spacing: -.025em;
88
+
89
+ --lg-font-size: 1.125rem;
90
+ --lg-font-weight: 400;
91
+ --lg-line-height: 1.75rem;
92
+ --lg-letter-spacing: normal;
93
+
94
+ --md-font-size: 1rem;
95
+ --md-font-weight: normal;
96
+ --md-line-height: 1.5rem;
97
+ --md-letter-spacing: normal;
98
+
99
+ --sm-font-size: .875rem;
100
+ --sm-font-weight: 400;
101
+ --sm-line-height: 1;
102
+ --sm-letter-spacing: normal;
103
+
104
+ --xs-font-size: .75rem;
105
+ --xs-font-weight: normal;
106
+ --xs-line-height: 1;
107
+ --xs-letter-spacing: normal;
108
+
109
+
110
+ --primary: oklch(0.205 0 0);
111
+ --primary-foreground: oklch(0.985 0 0);
112
+ --secondary: oklch(0.97 0 0);
113
+ --secondary-foreground: oklch(0.205 0 0);
114
+ --destructive: oklch(0.577 0.245 27.325);
115
+ --destructive-foreground: oklch(0.145 0 0);
116
+
117
+ /* surface color */
118
+ --background: oklch(1 0 0);
119
+ --foreground: oklch(0.145 0 0);
120
+
121
+ /* item hovered color */
122
+ /* can also be used with opacity 0.5 for hover */
123
+ --muted: oklch(0.97 0 0);
124
+ --muted-foreground: oklch(0.556 0 0);
125
+
126
+ /* item selected color */
127
+ --accent: oklch(0.95 0 0);
128
+ --accent-foreground: oklch(0.205 0 0);
129
+
130
+ /* border/outline */
131
+ --border: oklch(0.922 0 0);
132
+
133
+ /* background of text inputs */
134
+ --input: oklch(0.922 0 0);
135
+
136
+ /* ring of text inputs */
137
+ --ring: oklch(0.708 0 0);
138
+ }
139
+
140
+ .dark {
141
+ --background: oklch(0.145 0 0);
142
+ --foreground: oklch(0.985 0 0);
143
+ --primary: oklch(0.922 0 0);
144
+ --primary-foreground: oklch(0.305 0 0);
145
+ --secondary: oklch(0.269 0 0);
146
+ --secondary-foreground: oklch(0.985 0 0);
147
+ --muted: oklch(0.269 0 0);
148
+ --muted-foreground: oklch(0.708 0 0);
149
+ --accent: oklch(0.371 0 0);
150
+ --accent-foreground: oklch(0.985 0 0);
151
+ --destructive: oklch(0.704 0.191 22.216);
152
+ --border: oklch(1 0 0 / 10%);
153
+ --input: oklch(1 0 0 / 15%);
154
+ --ring: oklch(0.556 0 0);
155
+ }
@@ -0,0 +1,49 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="stylesheet" href="/public/theme.css">
7
+ <script>
8
+ window.rtglIcons = {
9
+ text: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12H20M4 8H20M4 16H12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
10
+ home: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.121,9.069,15.536,1.483a5.008,5.008,0,0,0-7.072,0L.879,9.069A2.978,2.978,0,0,0,0,11.19v9.817a3,3,0,0,0,3,3H21a3,3,0,0,0,3-3V11.19A2.978,2.978,0,0,0,23.121,9.069ZM15,22.007H9V18.073a3,3,0,0,1,6,0Zm7-1a1,1,0,0,1-1,1H17V18.073a5,5,0,0,0-10,0v3.934H3a1,1,0,0,1-1-1V11.19a1.008,1.008,0,0,1,.293-.707L9.878,2.9a3.008,3.008,0,0,1,4.244,0l7.585,7.586A1.008,1.008,0,0,1,22,11.19Z"/></svg>`,
11
+ threeDots: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" d="M18 12H18.01M12 12H12.01M6 12H6.01M13 12C13 12.5523 12.5523 13 12 13C11.4477 13 11 12.5523 11 12C11 11.4477 11.4477 11 12 11C12.5523 11 13 11.4477 13 12ZM19 12C19 12.5523 18.5523 13 18 13C17.4477 13 17 12.5523 17 12C17 11.4477 17.4477 11 18 11C18.5523 11 19 11.4477 19 12ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12Z"/></svg>`,
12
+ play: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
13
+ <path d="M16.6582 9.28663C18.098 10.1864 18.8178 10.6363 19.0647 11.2124C19.2803 11.7154 19.2803 12.2849 19.0647 12.788C18.8178 13.364 18.098 13.8139 16.6582 14.7138L9.896 18.9402C8.29805 19.9389 7.49907 20.4383 6.83973 20.3852C6.26501 20.339 5.73818 20.0471 5.3944 19.5842C5 19.0532 5 18.111 5 16.2266V7.77382C5 5.88944 5 4.94726 5.3944 4.41623C5.73818 3.95335 6.26501 3.66136 6.83973 3.61515C7.49907 3.56215 8.29805 4.06151 9.896 5.06023L16.6582 9.28663Z" fill="currentColor"/>
14
+ </svg>
15
+ `,
16
+ plus: `<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
17
+ <g clip-path="url(#clip0_911_81)">
18
+ <path d="M106.25 50H93.75V93.75H50V106.25H93.75V150H106.25V106.25H150V93.75H106.25V50Z" fill="currentColor"/>
19
+ <path d="M100 0C44.7691 0 0 44.7691 0 100C0 155.231 44.7691 200 100 200C155.231 200 200 155.231 200 100C200 44.7691 155.231 0 100 0ZM100 184.375C53.3996 184.375 15.625 146.6 15.625 100C15.625 53.3996 53.3996 15.625 100 15.625C146.6 15.625 184.375 53.3996 184.375 100C184.375 146.6 146.6 184.375 100 184.375Z" fill="currentColor"/>
20
+ </g>
21
+ <defs>
22
+ <clipPath id="clip0_911_81">
23
+ <rect width="200" height="200" fill="currentColor"/>
24
+ </clipPath>
25
+ </defs>
26
+ </svg>
27
+ `,
28
+ music: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
29
+ <path d="M9 19C9 20.1046 7.65685 21 6 21C4.34315 21 3 20.1046 3 19C3 17.8954 4.34315 17 6 17C7.65685 17 9 17.8954 9 19ZM9 19V5L21 3V17M21 17C21 18.1046 19.6569 19 18 19C16.3431 19 15 18.1046 15 17C15 15.8954 16.3431 15 18 15C19.6569 15 21 15.8954 21 17ZM9 9L21 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
30
+ </svg>`,
31
+ arrowLeft: `
32
+ <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M685.248 104.704a64 64 0 0 1 0 90.496L368.448 512l316.8 316.8a64 64 0 0 1-90.496 90.496L232.704 557.248a64 64 0 0 1 0-90.496l362.048-362.048a64 64 0 0 1 90.496 0z"/></svg>
33
+ `,
34
+ arrowRight: `
35
+ <svg viewBox="0 0 212 212" fill="none" xmlns="http://www.w3.org/2000/svg">
36
+ <path d="M70.132 21.6763C67.648 24.161 66.2526 27.5306 66.2526 31.044C66.2526 34.5575 67.648 37.9271 70.132 40.4118L135.719 105.999L70.132 171.587C67.7184 174.086 66.3829 177.433 66.4131 180.907C66.4432 184.381 67.8367 187.704 70.2934 190.161C72.75 192.618 76.0733 194.011 79.5474 194.041C83.0215 194.071 86.3685 192.736 88.8675 190.322L163.823 115.367C166.307 112.882 167.702 109.513 167.702 105.999C167.702 102.486 166.307 99.1163 163.823 96.6315L88.8675 21.6763C86.3827 19.1923 83.0132 17.7969 79.4997 17.7969C75.9863 17.7969 72.6167 19.1923 70.132 21.6763Z" fill="currentColor"/>
37
+ </svg>
38
+ `,
39
+ }
40
+ </script>
41
+ <script src="https://cdn.jsdelivr.net/npm/rettangoli-ui@0.1.0-rc2/dist/rettangoli-iife-ui.min.js"></script>
42
+ <script src="/public/main.js"></script>
43
+ </head>
44
+ <body class="dark">
45
+ <div class="container">
46
+ {{ content }}
47
+ </div>
48
+ </body>
49
+ </html>
@@ -0,0 +1,109 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="stylesheet" href="/public/theme.css">
8
+ <script>
9
+ window.addEventListener('DOMContentLoaded', () => {
10
+ if (location.hash) {
11
+ const el = document.getElementById(location.hash.substring(1));
12
+ if (el) {
13
+ el.scrollIntoView({ behavior: 'auto' });
14
+ }
15
+ }
16
+ });
17
+ </script>
18
+ <script src="https://cdn.jsdelivr.net/npm/rettangoli-ui@0.1.0-rc2/dist/rettangoli-iife-ui.min.js"></script>
19
+
20
+ <style>
21
+ pre {
22
+ width: 100%;
23
+ margin: 0px;
24
+ }
25
+
26
+ code {
27
+ white-space: pre-wrap;
28
+ display: block;
29
+ overflow: auto;
30
+ max-height: 400px;
31
+ word-break: break-all;
32
+ padding: 1rem;
33
+ width: -webkit-fill-available;
34
+ }
35
+
36
+ iframe {
37
+ height: 400px;
38
+ border: none;
39
+ border-color: var(--border);
40
+ border-width: 1px;
41
+ border-style: solid;
42
+ box-sizing: border-box;
43
+ }
44
+ </style>
45
+ </head>
46
+
47
+ <body class="dark">
48
+ <rtgl-view d="h" w="100vw" h="100vh">
49
+
50
+ <rtgl-view h="f" sm-hidden>
51
+ <rtgl-sidebar items="{{ sidebarItems }}">
52
+ </rtgl-sidebar>
53
+ </rtgl-view>
54
+
55
+ <rtgl-view id="content" h="100vh" w="f" flex="1" p="lg" g="lg" style="flex-wrap: nowrap;" sv ah="c">
56
+ <rtgl-view w="f" g="xl">
57
+ <rtgl-text s="h2">{{ currentSection.title }} </rtgl-text>
58
+ {% for file in files %}
59
+ <rtgl-view w="f">
60
+ <a style="display: contents; text-decoration: none; color: inherit;"
61
+ href="#{{ file.frontMatter.title | slug }}">
62
+ <rtgl-text id="{{ file.frontMatter.title | slug }}" s="h3">{{ file.frontMatter.title | default: file.path
63
+ }}</rtgl-text>
64
+ </a>
65
+
66
+ {% if file.frontMatter.description %}
67
+ <rtgl-text>{{ file.frontMatter.description }}</rtgl-text>
68
+ {% endif %}
69
+
70
+ {% if file.frontMatter.specs %}
71
+ <ul>
72
+ {% for spec in file.frontMatter.specs %}
73
+ <li>{{ spec }}</li>
74
+ {% endfor %}
75
+ </ul>
76
+ {% endif %}
77
+
78
+ <rtgl-view w="f" mt="sm">
79
+ <rtgl-view w="f">
80
+ {{ file.contentShiki }}
81
+ </rtgl-view>
82
+ <rtgl-view w="f">
83
+ <iframe
84
+ loading="lazy"
85
+ width="100%"
86
+ src="/candidate/{{ file.path }}"
87
+ frameborder="0"
88
+ style="
89
+ width: 1080px;
90
+ height: 720px;
91
+ transform: scale(0.66);
92
+ transform-origin: top left;
93
+ border: none;
94
+ "
95
+ ></iframe>
96
+ </rtgl-view>
97
+ </rtgl-view>
98
+ </rtgl-view>
99
+ {% endfor %}
100
+ <rtgl-view h="33vh"></rtgl-view>
101
+ </rtgl-view>
102
+ </rtgl-view>
103
+ <rtgl-view lg-hidden>
104
+ <rtgl-page-outline id="page-outline" target-id="content"></rtgl-page-outline>
105
+ </rtgl-view>
106
+ </rtgl-view>
107
+ </body>
108
+
109
+ </html>
@@ -0,0 +1,56 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <link rel="stylesheet" href="/public/theme.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/rettangoli-ui@0.1.0-rc2/dist/rettangoli-iife-ui.min.js"></script>
9
+ <style>
10
+ code {
11
+ white-space: pre-wrap;
12
+ display: block;
13
+ padding: 10px;
14
+ overflow: auto;
15
+ max-height: 400px;
16
+ background-color: var(--color-surface-container-low);
17
+ width: 100%;
18
+ word-break: break-all;
19
+ }
20
+ iframe {
21
+ height: 400px;
22
+ border: none;
23
+ }
24
+ </style>
25
+ </head>
26
+
27
+ <body class="dark">
28
+ <rtgl-view d="h" w="100vw" h="100vh">
29
+ <rtgl-view h="f" w="200" bgc="su">
30
+ <rtgl-view p="sm">
31
+ </rtgl-view>
32
+ </rtgl-view>
33
+ <rtgl-view h="100vh" w="f" flex="1" bgc="su" p="lg" g="lg" style="flex-wrap: nowrap;" sv>
34
+ <rtgl-view>
35
+ <rtgl-text s="tl">Rettangoli Components</rtgl-text>
36
+ <rtgl-text s="sm">{{ files.length }} components failed</rtgl-text>
37
+ </rtgl-view>
38
+ {% for file in files %}
39
+ <rtgl-view w="f">
40
+ <rtgl-view w="f" d="h" g="lg">
41
+ <rtgl-view flex="1">
42
+ <rtgl-text>{{ file.referencePath }}</rtgl-text>
43
+ <img width="100%" src="/{{ file.referencePath }}">
44
+ </rtgl-view>
45
+ <rtgl-view flex="1">
46
+ <rtgl-text>{{ file.candidatePath }}</rtgl-text>
47
+ <img width="100%" src="/{{ file.candidatePath }}">
48
+ </rtgl-view>
49
+ </rtgl-view>
50
+ </rtgl-view>
51
+ {% endfor %}
52
+ </rtgl-view>
53
+ </rtgl-view>
54
+ </body>
55
+
56
+ </html>
package/src/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ import { Command } from 'commander';
2
+ import generate from './generate.js';
3
+ import accept from './accept.js';
4
+ import report from './report.js';
5
+
6
+ const program = new Command();
7
+
8
+ program
9
+ .version('0.0.1')
10
+ .description('Rettangoli visualization CLI');
11
+
12
+ program
13
+ .command('generate')
14
+ .description('Generate visualizations')
15
+ .option('--skip-screenshots', 'Skip screenshot generation')
16
+ .option('--screenshot-wait-time <time>', 'Wait time between screenshots', '0')
17
+ .option('--viz-path <path>', 'Path to the viz directory', './viz')
18
+ .action((options) => {
19
+ generate(options);
20
+ });
21
+
22
+ program
23
+ .command('report')
24
+ .description('Create reports')
25
+ .action(() => {
26
+ report();
27
+ });
28
+
29
+ program
30
+ .command('accept')
31
+ .description('Accept changes')
32
+ .action(() => {
33
+ accept();
34
+ });
35
+
36
+ program.parse(process.argv);
package/src/common.js ADDED
@@ -0,0 +1,479 @@
1
+ import {
2
+ readdirSync,
3
+ statSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ mkdirSync,
7
+ existsSync,
8
+ } from "fs";
9
+ import { join, dirname, resolve, extname } from "path";
10
+ import { load as loadYaml } from "js-yaml";
11
+ import { Liquid } from "liquidjs";
12
+ import { chromium } from "playwright";
13
+ import { codeToHtml } from "shiki";
14
+ import sharp from "sharp";
15
+ import path from "path";
16
+
17
+ const convertToHtmlExtension = (filePath) => {
18
+ if (filePath.endsWith(".html")) {
19
+ return filePath;
20
+ }
21
+ // Remove existing extension and add .html
22
+ const baseName = filePath.replace(/\.[^/.]+$/, "");
23
+ return baseName + ".html";
24
+ };
25
+
26
+ // Initialize LiquidJS with output escaping disabled
27
+ const engine = new Liquid();
28
+
29
+ /**
30
+ * Read and parse a YAML file
31
+ */
32
+ async function readYaml(filePath) {
33
+ try {
34
+ const fileContent = readFileSync(filePath, "utf8");
35
+ return loadYaml(fileContent);
36
+ } catch (error) {
37
+ console.error(`Error reading YAML file ${filePath}:`, error);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ // Add custom filter to convert string to lowercase and replace spaces with hyphens
43
+ engine.registerFilter("slug", (value) => {
44
+ if (typeof value !== "string") return "";
45
+ return value.toLowerCase().replace(/\s+/g, "-");
46
+ });
47
+
48
+ /**
49
+ * Get all files from a directory recursively
50
+ */
51
+ function getAllFiles(dirPath, arrayOfFiles = []) {
52
+ if (!existsSync(dirPath)) {
53
+ console.log(`Directory ${dirPath} does not exist, skipping...`);
54
+ return arrayOfFiles;
55
+ }
56
+
57
+ const files = readdirSync(dirPath);
58
+
59
+ files.forEach((file) => {
60
+ const filePath = join(dirPath, file);
61
+ if (statSync(filePath).isDirectory()) {
62
+ arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
63
+ } else {
64
+ arrayOfFiles.push(filePath);
65
+ }
66
+ });
67
+
68
+ return arrayOfFiles;
69
+ }
70
+
71
+ /**
72
+ * Extract frontmatter from content
73
+ */
74
+ function extractFrontMatter(content) {
75
+ const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
76
+ const match = content.match(frontMatterRegex);
77
+
78
+ if (!match) {
79
+ return {
80
+ content: content,
81
+ frontMatter: null,
82
+ };
83
+ }
84
+
85
+ const frontMatter = match[1].trim();
86
+ const contentWithoutFrontMatter = content.slice(match[0].length).trim();
87
+
88
+ return {
89
+ content: contentWithoutFrontMatter,
90
+ frontMatter: frontMatter,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Ensure directory exists
96
+ */
97
+ function ensureDirectoryExists(dirPath) {
98
+ const absolutePath = resolve(dirPath);
99
+ if (!statSync(absolutePath, { throwIfNoEntry: false })) {
100
+ mkdirSync(absolutePath, { recursive: true });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Main function to generate HTML files from specs
106
+ */
107
+ async function generateHtml(specsDir, templatePath, outputDir) {
108
+ try {
109
+ // Initialize LiquidJS engine
110
+
111
+ // Read template
112
+ const templateContent = readFileSync(templatePath, "utf8");
113
+
114
+ // Ensure output directory exists
115
+ ensureDirectoryExists(outputDir);
116
+
117
+ // Get all files from specs directory
118
+ const allFiles = getAllFiles(specsDir);
119
+
120
+ // Process each file
121
+ const processedFiles = [];
122
+ for (const filePath of allFiles) {
123
+ const fileContent = readFileSync(filePath, "utf8");
124
+ const { content, frontMatter } = extractFrontMatter(fileContent);
125
+ const lang = filePath.includes(".yaml") ? "yaml" : "html";
126
+ const contentShiki = await codeToHtml(content, {
127
+ lang,
128
+ theme: "slack-dark",
129
+ });
130
+
131
+ // Parse YAML frontmatter
132
+ let frontMatterObj = null;
133
+ if (frontMatter) {
134
+ try {
135
+ frontMatterObj = loadYaml(frontMatter);
136
+ } catch (e) {
137
+ console.error(`Error parsing frontmatter in ${filePath}:`, e.message);
138
+ }
139
+ }
140
+
141
+ // Render template
142
+ let renderedContent = "";
143
+ try {
144
+ renderedContent = engine.parseAndRenderSync(templateContent, {
145
+ content: content,
146
+ frontMatter: frontMatterObj || {},
147
+ });
148
+ } catch (error) {
149
+ console.error(`Error rendering template for ${filePath}:`, error);
150
+ renderedContent = `<html><body><h1>Error rendering template</h1><p>${error.message}</p><pre>${content}</pre></body></html>`;
151
+ }
152
+
153
+ // Get relative path from specs directory
154
+ const relativePath = path.relative(specsDir, filePath);
155
+
156
+ // Save file
157
+ const outputPath = join(outputDir, convertToHtmlExtension(relativePath));
158
+ ensureDirectoryExists(dirname(outputPath));
159
+ writeFileSync(outputPath, renderedContent, "utf8");
160
+ console.log(`Generated: ${outputPath}`);
161
+
162
+ processedFiles.push({
163
+ path: relativePath,
164
+ content,
165
+ contentShiki,
166
+ frontMatter: frontMatterObj,
167
+ fullContent: fileContent,
168
+ renderedContent,
169
+ });
170
+ }
171
+
172
+ console.log(`Successfully generated ${processedFiles.length} files`);
173
+ return processedFiles;
174
+ } catch (error) {
175
+ console.error("Error generating HTML:", error);
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Start a web server to serve static files
182
+ */
183
+ function startWebServer(artifactsDir, staticDir, port) {
184
+ const server = Bun.serve({
185
+ port: port,
186
+ fetch(req) {
187
+ const url = new URL(req.url);
188
+ let path = url.pathname;
189
+
190
+ // Default to index.html for root path
191
+ if (path === "/") {
192
+ path = "/index.html";
193
+ }
194
+
195
+ // Remove leading slash for file path
196
+ const filePath = path.startsWith("/") ? path.slice(1) : path;
197
+
198
+ // Try to serve from artifacts directory first
199
+ const artifactsPath = join(artifactsDir, filePath);
200
+ if (existsSync(artifactsPath) && statSync(artifactsPath).isFile()) {
201
+ const fileContent = readFileSync(artifactsPath);
202
+ const contentType = getContentType(artifactsPath);
203
+ return new Response(fileContent, {
204
+ headers: { "Content-Type": contentType },
205
+ });
206
+ }
207
+
208
+ // Then try to serve from static directory
209
+ const staticPath = join(staticDir, filePath);
210
+ if (existsSync(staticPath) && statSync(staticPath).isFile()) {
211
+ const fileContent = readFileSync(staticPath);
212
+ const contentType = getContentType(staticPath);
213
+ return new Response(fileContent, {
214
+ headers: { "Content-Type": contentType },
215
+ });
216
+ }
217
+
218
+ // If not found in either directory, return 404
219
+ return new Response("Not Found", {
220
+ status: 404,
221
+ headers: { "Content-Type": "text/plain" },
222
+ });
223
+ },
224
+ });
225
+
226
+ console.log(`Server started at http://localhost:${port}`);
227
+ return server;
228
+ }
229
+
230
+ /**
231
+ * Get content type based on file extension
232
+ */
233
+ function getContentType(filePath) {
234
+ const ext = extname(filePath).toLowerCase();
235
+ const contentTypes = {
236
+ ".html": "text/html",
237
+ ".css": "text/css",
238
+ ".js": "application/javascript",
239
+ ".json": "application/json",
240
+ ".png": "image/png",
241
+ ".jpg": "image/jpeg",
242
+ ".jpeg": "image/jpeg",
243
+ ".gif": "image/gif",
244
+ ".svg": "image/svg+xml",
245
+ };
246
+
247
+ return contentTypes[ext] || "application/octet-stream";
248
+ }
249
+
250
+ /**
251
+ * Take screenshots of all generated HTML files
252
+ */
253
+ async function takeScreenshots(
254
+ generatedFiles,
255
+ serverUrl,
256
+ screenshotsDir,
257
+ concurrency = 8,
258
+ waitTime = 0
259
+ ) {
260
+ // Ensure screenshots directory exists
261
+ ensureDirectoryExists(screenshotsDir);
262
+
263
+ // Launch browser
264
+ console.log("Launching browser to take screenshots...");
265
+ const browser = await chromium.launch();
266
+
267
+ try {
268
+ // Process files in parallel with limited concurrency
269
+ const files = [...generatedFiles]; // Create a copy to work with
270
+ const total = files.length;
271
+ let completed = 0;
272
+
273
+ // Process files in batches based on concurrency
274
+ while (files.length > 0) {
275
+ const batch = files.splice(0, concurrency);
276
+ const batchPromises = batch.map(async (file) => {
277
+ // Create a new context and page for each file (for parallelism)
278
+ const context = await browser.newContext();
279
+ const page = await context.newPage();
280
+
281
+ try {
282
+ // Construct URL from file path (add /candidate prefix since server serves from parent)
283
+ const fileUrl = convertToHtmlExtension(
284
+ `${serverUrl}/candidate/${file.path.replace(/\\/g, '/')}`
285
+ );
286
+ console.log(`Taking screenshot of ${fileUrl}`);
287
+
288
+ // Navigate to the page
289
+ await page.goto(fileUrl, { waitUntil: "networkidle" });
290
+
291
+ // Create screenshot output path (remove extension and add .webp)
292
+ const baseName = file.path.replace(/\.[^/.]+$/, "");
293
+ const tempPngPath = join(screenshotsDir, `${baseName}.png`);
294
+ const screenshotPath = join(screenshotsDir, `${baseName}.webp`);
295
+ ensureDirectoryExists(dirname(screenshotPath));
296
+
297
+ if (waitTime > 0) {
298
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
299
+ }
300
+
301
+ // Take screenshot as PNG first (Playwright doesn't support WebP)
302
+ await page.screenshot({
303
+ path: tempPngPath,
304
+ fullPage: true
305
+ });
306
+
307
+ // Convert PNG to WebP using Sharp
308
+ await sharp(tempPngPath)
309
+ .webp({ quality: 85 })
310
+ .toFile(screenshotPath);
311
+
312
+ // Remove temporary PNG file
313
+ if (existsSync(tempPngPath)) {
314
+ await import('fs').then(fs => fs.promises.unlink(tempPngPath));
315
+ }
316
+
317
+ // example instructions:
318
+ for (const instruction of file.frontMatter?.instructions || []) {
319
+ const [command, ...args] = instruction.split(" ");
320
+ switch (command) {
321
+ case "lc":
322
+ const x = Number(args[0]);
323
+ const y = Number(args[1]);
324
+ await page.mouse.click(x, y, {
325
+ button: "left",
326
+ });
327
+ break;
328
+ case "ss":
329
+ const baseName = file.path.replace(/\.[^/.]+$/, "");
330
+ const tempAdditionalPngPath = join(
331
+ screenshotsDir,
332
+ `${baseName}-${args[0]}.png`
333
+ );
334
+ const additonalScreenshotPath = join(
335
+ screenshotsDir,
336
+ `${baseName}-${args[0]}.webp`
337
+ );
338
+ console.log(`Taking additional screenshot at ${additonalScreenshotPath}`);
339
+
340
+ // Take screenshot as PNG first
341
+ await page.screenshot({
342
+ path: tempAdditionalPngPath,
343
+ fullPage: true
344
+ });
345
+
346
+ // Convert PNG to WebP using Sharp
347
+ await sharp(tempAdditionalPngPath)
348
+ .webp({ quality: 85 })
349
+ .toFile(additonalScreenshotPath);
350
+
351
+ // Remove temporary PNG file
352
+ if (existsSync(tempAdditionalPngPath)) {
353
+ await import('fs').then(fs => fs.promises.unlink(tempAdditionalPngPath));
354
+ }
355
+
356
+ console.log(
357
+ `Additional screenshot taken at ${additonalScreenshotPath}`
358
+ );
359
+ break;
360
+ }
361
+ }
362
+
363
+ completed++;
364
+ console.log(
365
+ `Screenshot saved: ${screenshotPath} (${completed}/${total})`
366
+ );
367
+ } catch (error) {
368
+ console.error(`Error taking screenshot for ${file.path}:`, error);
369
+ } finally {
370
+ // Close the context when done
371
+ await context.close();
372
+ }
373
+ });
374
+
375
+ // Wait for current batch to complete before processing next batch
376
+ await Promise.all(batchPromises);
377
+ }
378
+ } catch (error) {
379
+ console.error("Error taking screenshots:", error);
380
+ } finally {
381
+ // Close browser
382
+ await browser.close();
383
+ console.log("Browser closed");
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Generate overview HTML from template and data
389
+ */
390
+ function generateOverview(data, templatePath, outputPath, configData) {
391
+ try {
392
+ // Read template
393
+ const templateContent = readFileSync(templatePath, "utf8");
394
+
395
+ // Ensure output directory exists
396
+ ensureDirectoryExists(dirname(outputPath));
397
+
398
+ // Process sections to extract all items (flat or grouped)
399
+ const allSections = [];
400
+ configData.sections.forEach((section) => {
401
+ if (section.type === "groupLabel" && section.items) {
402
+ // It's a group, add all items from the group
403
+ section.items.forEach((item) => {
404
+ allSections.push(item);
405
+ });
406
+ } else if (section.files) {
407
+ // It's a flat section
408
+ allSections.push(section);
409
+ }
410
+ });
411
+
412
+ // Transform sections for sidebar (maintaining group structure)
413
+ const sidebarItems = configData.sections.map((section) => {
414
+ if (section.type === "groupLabel") {
415
+ return {
416
+ title: section.title,
417
+ type: "groupLabel",
418
+ items: section.items.map((item) => ({
419
+ id: item.title.toLowerCase().replace(/\s+/g, "-"),
420
+ title: item.title,
421
+ href: `/${item.title.toLowerCase().replace(/\s+/g, "-")}.html`,
422
+ })),
423
+ };
424
+ } else {
425
+ // Flat item (backwards compatibility)
426
+ return {
427
+ id: section.title.toLowerCase().replace(/\s+/g, "-"),
428
+ title: section.title,
429
+ href: `/${section.title.toLowerCase().replace(/\s+/g, "-")}.html`,
430
+ };
431
+ }
432
+ });
433
+
434
+ // Generate pages for each section
435
+ allSections.forEach((section) => {
436
+ // Render template with data
437
+ let renderedContent = "";
438
+ try {
439
+ renderedContent = engine.parseAndRenderSync(templateContent, {
440
+ ...configData,
441
+ files: data.filter((file) => {
442
+ const filePath = path.normalize(file.path);
443
+ const sectionPath = path.normalize(section.files);
444
+ // Check if file is in the folder or any subfolder
445
+ const fileDir = path.dirname(filePath);
446
+ return fileDir === sectionPath || fileDir.startsWith(sectionPath + path.sep);
447
+ }),
448
+ currentSection: section,
449
+ sidebarItems: encodeURIComponent(JSON.stringify(sidebarItems)),
450
+ });
451
+ } catch (error) {
452
+ console.error(`Error rendering overview template:`, error);
453
+ renderedContent = `<html><body><h1>Error rendering overview template</h1><p>${error.message}</p></body></html>`;
454
+ }
455
+
456
+ const finalOutputPath = outputPath.replace(
457
+ "index.html",
458
+ `${section.title.toLowerCase().replace(/\s+/g, "-")}.html`
459
+ );
460
+ // Save file
461
+ writeFileSync(finalOutputPath, renderedContent, "utf8");
462
+ console.log(`Generated overview: ${finalOutputPath}`);
463
+ });
464
+ } catch (error) {
465
+ console.error("Error generating overview HTML:", error);
466
+ throw error;
467
+ }
468
+ }
469
+
470
+ export {
471
+ generateHtml,
472
+ getAllFiles,
473
+ extractFrontMatter,
474
+ ensureDirectoryExists,
475
+ startWebServer,
476
+ takeScreenshots,
477
+ generateOverview,
478
+ readYaml,
479
+ };
package/src/index.js ADDED
File without changes