@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 +3 -0
- package/package.json +23 -0
- package/src/cli/accept.js +75 -0
- package/src/cli/generate.js +104 -0
- package/src/cli/index.js +9 -0
- package/src/cli/report.js +214 -0
- package/src/cli/static/public/theme.css +155 -0
- package/src/cli/templates/default.html +49 -0
- package/src/cli/templates/index.html +109 -0
- package/src/cli/templates/report.html +56 -0
- package/src/cli.js +36 -0
- package/src/common.js +479 -0
- package/src/index.js +0 -0
package/README.md
ADDED
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;
|
package/src/cli/index.js
ADDED
|
@@ -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
|