html-component-engine 0.1.0

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.
@@ -0,0 +1,312 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ import { parseComponentTag, parseVariants, parseSelfClosingComponentTag } from './utils.js';
5
+
6
+ /**
7
+ * Compile HTML by resolving all components
8
+ * @param {string} html - The HTML content to compile
9
+ * @param {string} root - The root directory (pages directory)
10
+ * @param {string} projectRoot - The project root directory
11
+ * @returns {Promise<string>} - Compiled HTML
12
+ */
13
+ export async function compileHtml(html, root, projectRoot = null) {
14
+ const effectiveProjectRoot = projectRoot || path.dirname(root);
15
+ let result = html;
16
+
17
+ // First, process components with children: <Component name="...">children</Component>
18
+ result = await processComponentsWithChildren(result, root, effectiveProjectRoot);
19
+
20
+ // Then, process self-closing components: <Component src="..." />
21
+ result = await processSelfClosingComponents(result, root, effectiveProjectRoot);
22
+
23
+ return result;
24
+ }
25
+
26
+ /**
27
+ * Process components with children (slot-based)
28
+ * Matches: <Component name="ComponentName">...children...</Component>
29
+ */
30
+ async function processComponentsWithChildren(html, root, projectRoot) {
31
+ // Regex to match <Component name="...">...</Component>
32
+ const componentRegex = /<Component\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/Component>/g;
33
+
34
+ let result = html;
35
+ let matches = [...html.matchAll(componentRegex)];
36
+
37
+ for (const match of matches) {
38
+ const fullTag = match[0];
39
+ const componentName = match[1];
40
+ const attrsStr = match[2];
41
+ const children = match[3].trim();
42
+
43
+ // Parse additional attributes
44
+ const attrs = parseAttributes(attrsStr);
45
+
46
+ // Load component
47
+ let componentContent = await loadComponent(componentName, root, projectRoot, attrs);
48
+
49
+ if (componentContent === null) {
50
+ console.error(`Component "${componentName}" not found`);
51
+ result = result.replace(fullTag, `<!-- Component "${componentName}" not found -->`);
52
+ continue;
53
+ }
54
+
55
+ // Replace {{ children }} placeholder with actual children content
56
+ componentContent = componentContent.replace(/\{\{\s*children\s*\}\}/g, children);
57
+
58
+ // Replace props with {{key}} placeholders
59
+ for (const [key, value] of Object.entries(attrs)) {
60
+ componentContent = componentContent.replace(new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'), value);
61
+ }
62
+
63
+ // Recursively compile nested components
64
+ const compiledComponent = await compileHtml(componentContent, root, projectRoot);
65
+ result = result.replace(fullTag, compiledComponent);
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Process self-closing components
73
+ * Matches: <Component src="..." />
74
+ */
75
+ async function processSelfClosingComponents(html, root, projectRoot) {
76
+ const componentRegex = /<Component[^>]+\/>/g;
77
+
78
+ let result = html;
79
+ const matches = [...html.matchAll(componentRegex)];
80
+
81
+ for (const match of matches) {
82
+ const tag = match[0];
83
+ const attrs = parseSelfClosingComponentTag(tag);
84
+ if (!attrs || !attrs.src) continue;
85
+
86
+ const name = attrs.src;
87
+ let componentContent = await loadComponent(name, root, projectRoot, attrs);
88
+
89
+ if (componentContent === null) {
90
+ console.error(`Component "${name}" not found`);
91
+ result = result.replace(tag, `<!-- Component "${name}" not found -->`);
92
+ continue;
93
+ }
94
+
95
+ // Parse variants (only for HTML content)
96
+ const variants = parseVariants(componentContent);
97
+
98
+ // Replace props with {{key}} placeholders
99
+ for (const [key, value] of Object.entries(attrs)) {
100
+ if (key === 'variant' && variants[value]) {
101
+ componentContent = componentContent.replace(/\{\{\s*variantClasses\s*\}\}/g, variants[value]);
102
+ } else if (key !== 'src' && key !== 'variant') {
103
+ componentContent = componentContent.replace(new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'), value);
104
+ }
105
+ }
106
+
107
+ // If no variant specified, replace {{variantClasses}} with empty
108
+ componentContent = componentContent.replace(/\{\{\s*variantClasses\s*\}\}/g, '');
109
+
110
+ // Recursively compile nested components
111
+ const compiledComponent = await compileHtml(componentContent, root, projectRoot);
112
+ result = result.replace(tag, compiledComponent);
113
+ }
114
+
115
+ return result;
116
+ }
117
+
118
+ /**
119
+ * Load a component by name
120
+ * @param {string} name - Component name (e.g., "Card" or "main/Button")
121
+ * @param {string} root - The pages root directory
122
+ * @param {string} projectRoot - The project root directory
123
+ * @param {object} attrs - Component attributes/props
124
+ * @returns {Promise<string|null>} - Component content or null if not found
125
+ */
126
+ async function loadComponent(name, root, projectRoot, attrs = {}) {
127
+ // Normalize name for path construction (handle both / and \)
128
+ const normalizedName = name.replace(/\\/g, '/');
129
+
130
+ // root = srcRoot (e.g., example/src)
131
+ // projectRoot = project root (e.g., example)
132
+ // Components are in srcRoot/components
133
+ const possiblePaths = [
134
+ path.join(root, 'components', `${normalizedName}.html`), // srcRoot/components/
135
+ path.join(projectRoot, 'src', 'components', `${normalizedName}.html`), // projectRoot/src/components/
136
+ path.join(projectRoot, 'components', `${normalizedName}.html`), // projectRoot/components/
137
+ ];
138
+
139
+ for (const componentPath of possiblePaths) {
140
+ try {
141
+ await fs.access(componentPath); // Check if file exists first
142
+ const content = await fs.readFile(componentPath, 'utf8');
143
+ return content;
144
+ } catch {
145
+ // Try .js file at the same location
146
+ const jsPath = componentPath.replace('.html', '.js');
147
+ try {
148
+ const componentModule = await import(pathToFileURL(jsPath));
149
+ const componentExport = componentModule.default || componentModule;
150
+
151
+ if (typeof componentExport === 'function') {
152
+ const props = { ...attrs };
153
+ delete props.src;
154
+ delete props.name;
155
+ return componentExport(props);
156
+ } else {
157
+ return String(componentExport);
158
+ }
159
+ } catch {
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Parse attributes from an attribute string
170
+ * @param {string} attrsStr - Attribute string like ' class="foo" id="bar"'
171
+ * @returns {object} - Object with attribute key-value pairs
172
+ */
173
+ function parseAttributes(attrsStr) {
174
+ const attrs = {};
175
+ const attrRegex = /(\w+)="([^"]*)"/g;
176
+ let attrMatch;
177
+ while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
178
+ attrs[attrMatch[1]] = attrMatch[2];
179
+ }
180
+ return attrs;
181
+ }
182
+
183
+ /**
184
+ * Inline CSS from <link> tags into <style> tags
185
+ * @param {string} html - The HTML content
186
+ * @param {string} root - The src root directory
187
+ * @param {string} projectRoot - The project root directory
188
+ * @returns {Promise<string>} - HTML with inlined CSS
189
+ */
190
+ export async function inlineCss(html, root, projectRoot) {
191
+ const linkRegex = /<link[^>]+rel=["']stylesheet["'][^>]*>/gi;
192
+ const matches = [...html.matchAll(linkRegex)];
193
+
194
+ let result = html;
195
+
196
+ for (const match of matches) {
197
+ const linkTag = match[0];
198
+ const hrefMatch = linkTag.match(/href=["']([^"']+)["']/);
199
+
200
+ if (!hrefMatch) continue;
201
+
202
+ let href = hrefMatch[1];
203
+
204
+ // Skip external URLs
205
+ if (href.startsWith('http://') || href.startsWith('https://')) {
206
+ continue;
207
+ }
208
+
209
+ // Resolve the CSS file path - try multiple locations
210
+ let cssPath = null;
211
+ let cssContent = null;
212
+
213
+ // Possible paths to check
214
+ const pathsToTry = [];
215
+
216
+ if (href.startsWith('/')) {
217
+ const cleanHref = href.slice(1);
218
+ // /styles/styles.css -> src/assets/styles/styles.css (publicDir pattern)
219
+ pathsToTry.push(path.join(root, 'assets', cleanHref));
220
+ // /assets/styles/styles.css -> src/assets/styles/styles.css
221
+ pathsToTry.push(path.join(root, cleanHref));
222
+ // Try project root
223
+ pathsToTry.push(path.join(projectRoot, 'src', 'assets', cleanHref));
224
+ } else {
225
+ // Relative path
226
+ pathsToTry.push(path.join(root, href));
227
+ }
228
+
229
+ for (const tryPath of pathsToTry) {
230
+ if (await fileExists(tryPath)) {
231
+ cssPath = tryPath;
232
+ break;
233
+ }
234
+ }
235
+
236
+ if (cssPath) {
237
+ try {
238
+ cssContent = await fs.readFile(cssPath, 'utf8');
239
+ const styleTag = `<style>\n${cssContent}\n</style>`;
240
+ result = result.replace(linkTag, styleTag);
241
+ } catch (error) {
242
+ console.warn(`Could not inline CSS from ${href}: ${error.message}`);
243
+ }
244
+ } else {
245
+ console.warn(`Could not find CSS file for ${href}`);
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * Inline JS from <script src="..."> tags into inline <script> tags
254
+ * @param {string} html - The HTML content
255
+ * @param {string} root - The pages root directory
256
+ * @param {string} projectRoot - The project root directory
257
+ * @returns {Promise<string>} - HTML with inlined JS
258
+ */
259
+ export async function inlineJs(html, root, projectRoot) {
260
+ const scriptRegex = /<script[^>]+src=["']([^"']+)["'][^>]*><\/script>/gi;
261
+ const matches = [...html.matchAll(scriptRegex)];
262
+
263
+ let result = html;
264
+
265
+ for (const match of matches) {
266
+ const scriptTag = match[0];
267
+ let src = match[1];
268
+
269
+ // Skip external URLs and Vite client
270
+ if (src.startsWith('http://') || src.startsWith('https://') || src.includes('@vite')) {
271
+ continue;
272
+ }
273
+
274
+ // Resolve the JS file path
275
+ let jsPath;
276
+ if (src.startsWith('/')) {
277
+ jsPath = path.join(projectRoot, 'src', 'assets', src.slice(1));
278
+ if (!await fileExists(jsPath)) {
279
+ jsPath = path.join(root, 'assets', src.slice(1));
280
+ }
281
+ if (!await fileExists(jsPath)) {
282
+ jsPath = path.join(root, src.slice(1));
283
+ }
284
+ } else {
285
+ jsPath = path.join(root, src);
286
+ }
287
+
288
+ try {
289
+ const jsContent = await fs.readFile(jsPath, 'utf8');
290
+ const inlineScriptTag = `<script>\n${jsContent}\n</script>`;
291
+ result = result.replace(scriptTag, inlineScriptTag);
292
+ } catch (error) {
293
+ console.warn(`Could not inline JS from ${src}: ${error.message}`);
294
+ }
295
+ }
296
+
297
+ return result;
298
+ }
299
+
300
+ /**
301
+ * Check if a file exists
302
+ * @param {string} filePath - Path to check
303
+ * @returns {Promise<boolean>}
304
+ */
305
+ async function fileExists(filePath) {
306
+ try {
307
+ await fs.access(filePath);
308
+ return true;
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
@@ -0,0 +1,74 @@
1
+ // Utility functions for the HTML Component Engine
2
+
3
+ /**
4
+ * Parse a self-closing Component tag to extract attributes
5
+ * @param {string} tag - The full component tag, e.g., <Component src="Button" text="Click" />
6
+ * @returns {object|null} - Object with attribute key-value pairs, or null if invalid
7
+ */
8
+ export function parseSelfClosingComponentTag(tag) {
9
+ const regex = /<Component\s+([^>]+)\s*\/>/;
10
+ const match = tag.match(regex);
11
+ if (!match) return null;
12
+
13
+ const attrs = {};
14
+ // Support hyphenated attributes like data-test, aria-label
15
+ const attrRegex = /([\w-]+)="([^"]*)"/g;
16
+ let attrMatch;
17
+ while ((attrMatch = attrRegex.exec(match[1])) !== null) {
18
+ attrs[attrMatch[1]] = attrMatch[2];
19
+ }
20
+ return attrs;
21
+ }
22
+
23
+ /**
24
+ * Parse a Component tag to extract attributes (legacy alias)
25
+ * @param {string} tag - The full component tag
26
+ * @returns {object|null} - Object with attribute key-value pairs, or null if invalid
27
+ */
28
+ export function parseComponentTag(tag) {
29
+ return parseSelfClosingComponentTag(tag);
30
+ }
31
+
32
+ /**
33
+ * Parse variants from component HTML
34
+ * Looks for <!-- variants: primary=class1 class2, secondary=class3 -->
35
+ * @param {string} html - The component HTML
36
+ * @returns {object} - Map of variant name to classes
37
+ */
38
+ export function parseVariants(html) {
39
+ const variants = {};
40
+ // Match HTML comment with variants - use non-greedy match
41
+ const regex = /<!--\s*variants:\s*(.+?)\s*-->/;
42
+ const match = html.match(regex);
43
+ if (match) {
44
+ const variantsStr = match[1];
45
+ const variantPairs = variantsStr.split(',');
46
+ for (const pair of variantPairs) {
47
+ const [name, ...classParts] = pair.split('=');
48
+ const classes = classParts.join('='); // Handle = in class names (unlikely but safe)
49
+ if (name && classes) {
50
+ variants[name.trim()] = classes.trim();
51
+ }
52
+ }
53
+ }
54
+ return variants;
55
+ }
56
+
57
+ /**
58
+ * Clean unused placeholders from compiled HTML
59
+ * @param {string} html - The compiled HTML
60
+ * @returns {string} - Cleaned HTML
61
+ */
62
+ export function cleanUnusedPlaceholders(html) {
63
+ // Remove any remaining {{ ... }} placeholders
64
+ return html.replace(/\{\{\s*\w+\s*\}\}/g, '');
65
+ }
66
+
67
+ /**
68
+ * Normalize path separators to forward slashes
69
+ * @param {string} p - Path to normalize
70
+ * @returns {string} - Normalized path
71
+ */
72
+ export function normalizePath(p) {
73
+ return p.replace(/\\/g, '/');
74
+ }
package/src/index.js ADDED
@@ -0,0 +1,279 @@
1
+ import { compileHtml, inlineCss, inlineJs } from './engine/compiler.js';
2
+ import { cleanUnusedPlaceholders, normalizePath } from './engine/utils.js';
3
+ import path from 'path';
4
+ import fs from 'fs/promises';
5
+
6
+ /**
7
+ * HTML Component Engine - Vite Plugin
8
+ * A lightweight static site compiler with component support
9
+ *
10
+ * @param {object} options - Plugin options
11
+ * @param {string} options.srcDir - Source directory relative to project root (default: 'src')
12
+ * @param {string} options.componentsDir - Components directory name inside srcDir (default: 'components')
13
+ * @param {string} options.assetsDir - Assets directory name inside srcDir (default: 'assets')
14
+ * @param {boolean} options.inlineStyles - Whether to inline CSS (default: true for build)
15
+ * @param {boolean} options.inlineScripts - Whether to inline JS (default: true for build)
16
+ */
17
+ export default function htmlComponentEngine(options = {}) {
18
+ // Handle null/undefined options
19
+ const opts = options || {};
20
+ const {
21
+ srcDir = 'src',
22
+ componentsDir = 'components',
23
+ assetsDir = 'assets',
24
+ inlineStyles = true,
25
+ inlineScripts = true,
26
+ } = opts;
27
+
28
+ let projectRoot;
29
+ let srcRoot;
30
+ let componentsRoot;
31
+ let assetsRoot;
32
+ let resolvedConfig;
33
+
34
+ return {
35
+ name: 'html-component-engine',
36
+
37
+ configResolved(config) {
38
+ resolvedConfig = config;
39
+ // Use process.cwd() as the project root - this is where the vite command is run from
40
+ // config.root can be unreliable with linked packages
41
+ projectRoot = process.cwd();
42
+ srcRoot = path.resolve(projectRoot, srcDir);
43
+ componentsRoot = path.join(srcRoot, componentsDir);
44
+ assetsRoot = path.join(srcRoot, assetsDir);
45
+ },
46
+
47
+ configureServer(server) {
48
+ // Add middleware BEFORE internal middlewares to intercept HTML requests
49
+ server.middlewares.use(async (req, res, next) => {
50
+ // Skip non-HTML requests and special Vite paths
51
+ if (req.url.startsWith('/@') || req.url.startsWith('/__')) {
52
+ return next();
53
+ }
54
+
55
+ // Skip asset requests (let Vite handle them via publicDir)
56
+ if (req.url.match(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|json)$/i)) {
57
+ return next();
58
+ }
59
+
60
+ let url;
61
+ if (req.url === '/' || req.url === '/index' || req.url === '/index.html') {
62
+ url = 'index.html';
63
+ } else if (req.url.endsWith('.html')) {
64
+ url = req.url.slice(1);
65
+ } else {
66
+ // Check if req.url + '.html' exists
67
+ const potential = req.url.slice(1) + '.html';
68
+ const filePathCheck = path.join(srcRoot, potential);
69
+ try {
70
+ await fs.access(filePathCheck);
71
+ url = potential;
72
+ } catch {
73
+ return next();
74
+ }
75
+ }
76
+
77
+ const filePath = path.join(srcRoot, url);
78
+
79
+ try {
80
+ let html = await fs.readFile(filePath, 'utf8');
81
+
82
+ // Compile components
83
+ html = await compileHtml(html, srcRoot, projectRoot);
84
+
85
+ // Clean unused placeholders
86
+ html = cleanUnusedPlaceholders(html);
87
+
88
+ // Inject Vite HMR client script
89
+ if (html.includes('</body>')) {
90
+ html = html.replace('</body>', '<script type="module" src="/@vite/client"></script></body>');
91
+ } else {
92
+ html += '<script type="module" src="/@vite/client"></script>';
93
+ }
94
+
95
+ res.setHeader('Content-Type', 'text/html');
96
+ res.end(html);
97
+ return;
98
+ } catch (error) {
99
+ console.error(`[html-component-engine] Error processing ${filePath}:`, error.message);
100
+ next();
101
+ }
102
+ });
103
+ },
104
+
105
+ handleHotUpdate(ctx) {
106
+ const normalizedFile = normalizePath(ctx.file);
107
+ const normalizedSrcRoot = normalizePath(srcRoot);
108
+
109
+ // Reload on changes to src directory
110
+ if (normalizedFile.startsWith(normalizedSrcRoot)) {
111
+ console.log('Hot update for:', ctx.file, '- sending full-reload');
112
+ ctx.server.ws.send({ type: 'full-reload' });
113
+ return [];
114
+ }
115
+ },
116
+
117
+ /**
118
+ * Build hook - process all HTML files for production
119
+ */
120
+ async buildStart() {
121
+ if (resolvedConfig.command !== 'build') return;
122
+
123
+ console.log('\n🔨 HTML Component Engine - Build Started');
124
+ console.log(` Source: ${srcRoot}`);
125
+ console.log(` Components: ${componentsRoot}`);
126
+ console.log(` Assets: ${assetsRoot}`);
127
+ },
128
+
129
+ /**
130
+ * Generate bundle - compile HTML and copy assets
131
+ */
132
+ async generateBundle(outputOptions, bundle) {
133
+ const outDir = outputOptions.dir || path.resolve(projectRoot, 'dist');
134
+
135
+ console.log(`\n📦 Generating output to: ${outDir}`);
136
+
137
+ // Get all HTML files from src directory (excluding components)
138
+ const htmlFiles = await getHtmlFiles(srcRoot, '', componentsDir);
139
+
140
+ console.log(` Found ${htmlFiles.length} HTML file(s)`);
141
+
142
+ for (const htmlFile of htmlFiles) {
143
+ const filePath = path.join(srcRoot, htmlFile);
144
+ let html = await fs.readFile(filePath, 'utf8');
145
+
146
+ // Compile components
147
+ html = await compileHtml(html, srcRoot, projectRoot);
148
+
149
+ // Inline CSS if enabled
150
+ if (inlineStyles) {
151
+ html = await inlineCss(html, srcRoot, projectRoot);
152
+ }
153
+
154
+ // Inline JS if enabled
155
+ if (inlineScripts) {
156
+ html = await inlineJs(html, srcRoot, projectRoot);
157
+ }
158
+
159
+ // Clean unused placeholders
160
+ html = cleanUnusedPlaceholders(html);
161
+
162
+ // Remove Vite-specific scripts
163
+ html = html.replace(/<script[^>]*@vite[^>]*>[\s\S]*?<\/script>/gi, '');
164
+
165
+ // Add to bundle
166
+ const outputFileName = htmlFile;
167
+ this.emitFile({
168
+ type: 'asset',
169
+ fileName: outputFileName,
170
+ source: html,
171
+ });
172
+
173
+ console.log(` ✓ Compiled: ${htmlFile}`);
174
+ }
175
+
176
+ // Copy ALL assets to dist/assets (including CSS for reference)
177
+ await copyAllAssets(assetsRoot, this);
178
+ },
179
+
180
+ /**
181
+ * Close bundle - cleanup and final processing
182
+ */
183
+ async closeBundle() {
184
+ if (resolvedConfig.command !== 'build') return;
185
+
186
+ console.log('\n✅ HTML Component Engine - Build Complete\n');
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Get all HTML files recursively from a directory
193
+ * @param {string} dir - Directory to search
194
+ * @param {string} base - Base directory for relative paths
195
+ * @param {string} componentsDir - Components directory name to skip
196
+ * @returns {Promise<string[]>} - Array of relative file paths
197
+ */
198
+ async function getHtmlFiles(dir, base = '', componentsDir = 'components') {
199
+ const files = [];
200
+
201
+ try {
202
+ const entries = await fs.readdir(dir, { withFileTypes: true });
203
+
204
+ for (const entry of entries) {
205
+ const relativePath = base ? path.join(base, entry.name) : entry.name;
206
+
207
+ if (entry.isDirectory()) {
208
+ // Skip only the components directory
209
+ if (entry.name !== componentsDir) {
210
+ const subFiles = await getHtmlFiles(path.join(dir, entry.name), relativePath, componentsDir);
211
+ files.push(...subFiles);
212
+ }
213
+ } else if (entry.name.endsWith('.html')) {
214
+ files.push(relativePath);
215
+ }
216
+ }
217
+ } catch (error) {
218
+ console.warn(`Could not read directory ${dir}: ${error.message}`);
219
+ }
220
+
221
+ return files;
222
+ }
223
+
224
+ /**
225
+ * Copy ALL assets to output directory (including CSS/JS)
226
+ * @param {string} assetsPath - Assets directory path
227
+ * @param {object} context - Rollup plugin context
228
+ */
229
+ async function copyAllAssets(assetsPath, context) {
230
+ try {
231
+ await fs.access(assetsPath);
232
+ } catch {
233
+ console.log(' No assets directory found, skipping asset copy');
234
+ return;
235
+ }
236
+
237
+ const assetFiles = await getAllFiles(assetsPath);
238
+
239
+ console.log(` Copying ${assetFiles.length} asset file(s)`);
240
+
241
+ for (const file of assetFiles) {
242
+ const relativePath = path.relative(assetsPath, file);
243
+ const content = await fs.readFile(file);
244
+
245
+ context.emitFile({
246
+ type: 'asset',
247
+ fileName: path.join('assets', relativePath).replace(/\\/g, '/'),
248
+ source: content,
249
+ });
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Get all files recursively from a directory
255
+ * @param {string} dir - Directory to search
256
+ * @returns {Promise<string[]>} - Array of absolute file paths
257
+ */
258
+ async function getAllFiles(dir) {
259
+ const files = [];
260
+
261
+ try {
262
+ const entries = await fs.readdir(dir, { withFileTypes: true });
263
+
264
+ for (const entry of entries) {
265
+ const fullPath = path.join(dir, entry.name);
266
+
267
+ if (entry.isDirectory()) {
268
+ const subFiles = await getAllFiles(fullPath);
269
+ files.push(...subFiles);
270
+ } else {
271
+ files.push(fullPath);
272
+ }
273
+ }
274
+ } catch (error) {
275
+ console.warn(`Could not read directory ${dir}: ${error.message}`);
276
+ }
277
+
278
+ return files;
279
+ }