html-component-engine 0.1.2 → 0.1.4

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.
@@ -1,312 +1,403 @@
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
- }
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 src="...">...children...</Component>
29
+ * Also supports legacy: <Component name="...">...children...</Component>
30
+ */
31
+ async function processComponentsWithChildren(html, root, projectRoot) {
32
+ // Regex to match ONLY non-self-closing <Component ...>...</Component>
33
+ // Prevents accidentally matching <Component ... /> as an opening tag.
34
+ const componentRegex = /<Component\b(?![^>]*\/\s*>)([^>]*)>([\s\S]*?)<\/Component>/g;
35
+
36
+ let result = html;
37
+ let matches = [...html.matchAll(componentRegex)];
38
+
39
+ for (const match of matches) {
40
+ const fullTag = match[0];
41
+ const attrsStr = match[1];
42
+ const children = match[2];
43
+
44
+ // Parse additional attributes
45
+ const attrs = parseAttributes(attrsStr);
46
+ const componentName = attrs.src || attrs.name;
47
+
48
+ if (!componentName) {
49
+ continue;
50
+ }
51
+
52
+ // Load component
53
+ let componentContent = await loadComponent(componentName, root, projectRoot, attrs);
54
+
55
+ if (componentContent === null) {
56
+ console.error(`Component "${componentName}" not found`);
57
+ result = result.replace(fullTag, `<!-- Component "${componentName}" not found -->`);
58
+ continue;
59
+ }
60
+
61
+ // Replace children and named slots
62
+ const { defaultChildren, namedSlots } = extractSlots(children);
63
+ componentContent = componentContent.replace(/\{\{\s*children\s*\}\}/g, defaultChildren);
64
+ for (const [slotName, slotContent] of Object.entries(namedSlots)) {
65
+ componentContent = componentContent.replace(
66
+ new RegExp(`\\{\\{\\s*slot:${slotName}\\s*\\}\\}`, 'g'),
67
+ slotContent,
68
+ );
69
+ }
70
+
71
+ // Replace props with {{key}} placeholders
72
+ for (const [key, value] of Object.entries(attrs)) {
73
+ componentContent = componentContent.replace(new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'), value);
74
+ }
75
+
76
+ // Recursively compile nested components
77
+ const compiledComponent = await compileHtml(componentContent, root, projectRoot);
78
+ result = result.replace(fullTag, compiledComponent);
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Process self-closing components
86
+ * Matches: <Component src="..." />
87
+ */
88
+ async function processSelfClosingComponents(html, root, projectRoot) {
89
+ const componentRegex = /<Component[^>]+\/>/g;
90
+
91
+ let result = html;
92
+ const matches = [...html.matchAll(componentRegex)];
93
+
94
+ for (const match of matches) {
95
+ const tag = match[0];
96
+ const attrs = parseSelfClosingComponentTag(tag);
97
+ if (!attrs) continue;
98
+
99
+ const name = attrs.src || attrs.name;
100
+ if (!name) continue;
101
+ let componentContent = await loadComponent(name, root, projectRoot, attrs);
102
+
103
+ if (componentContent === null) {
104
+ console.error(`Component "${name}" not found`);
105
+ result = result.replace(tag, `<!-- Component "${name}" not found -->`);
106
+ continue;
107
+ }
108
+
109
+ // Parse variants (only for HTML content)
110
+ const variants = parseVariants(componentContent);
111
+
112
+ // Replace props with {{key}} placeholders
113
+ for (const [key, value] of Object.entries(attrs)) {
114
+ if (key === 'variant' && variants[value]) {
115
+ componentContent = componentContent.replace(/\{\{\s*variantClasses\s*\}\}/g, variants[value]);
116
+ } else if (key !== 'src' && key !== 'variant') {
117
+ componentContent = componentContent.replace(new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'), value);
118
+ }
119
+ }
120
+
121
+ // If no variant specified, replace {{variantClasses}} with empty
122
+ componentContent = componentContent.replace(/\{\{\s*variantClasses\s*\}\}/g, '');
123
+
124
+ // Recursively compile nested components
125
+ const compiledComponent = await compileHtml(componentContent, root, projectRoot);
126
+ result = result.replace(tag, compiledComponent);
127
+ }
128
+
129
+ return result;
130
+ }
131
+
132
+ /**
133
+ * Load a component by name
134
+ * @param {string} name - Component name (e.g., "Card" or "main/Button")
135
+ * @param {string} root - The pages root directory
136
+ * @param {string} projectRoot - The project root directory
137
+ * @param {object} attrs - Component attributes/props
138
+ * @returns {Promise<string|null>} - Component content or null if not found
139
+ */
140
+ async function loadComponent(name, root, projectRoot, attrs = {}) {
141
+ // Normalize name for path construction (handle both / and \)
142
+ const normalizedName = name.replace(/\\/g, '/').replace(/\.html$/i, '');
143
+
144
+ // root = srcRoot (e.g., example/src)
145
+ // projectRoot = project root (e.g., example)
146
+ // Components are in srcRoot/components
147
+ const possiblePaths = [
148
+ path.join(root, 'components', `${normalizedName}.html`), // srcRoot/components/
149
+ path.join(projectRoot, 'src', 'components', `${normalizedName}.html`), // projectRoot/src/components/
150
+ path.join(projectRoot, 'components', `${normalizedName}.html`), // projectRoot/components/
151
+ ];
152
+
153
+ for (const componentPath of possiblePaths) {
154
+ try {
155
+ await fs.access(componentPath); // Check if file exists first
156
+ const content = await fs.readFile(componentPath, 'utf8');
157
+ return content;
158
+ } catch {
159
+ // Try .js file at the same location
160
+ const jsPath = componentPath.replace('.html', '.js');
161
+ try {
162
+ const componentModule = await import(pathToFileURL(jsPath));
163
+ const componentExport = componentModule.default || componentModule;
164
+
165
+ if (typeof componentExport === 'function') {
166
+ const props = { ...attrs };
167
+ delete props.src;
168
+ delete props.name;
169
+ return componentExport(props);
170
+ } else {
171
+ return String(componentExport);
172
+ }
173
+ } catch {
174
+ continue;
175
+ }
176
+ }
177
+
178
+ // Try case-insensitive HTML lookup to support mixed-case component tags
179
+ try {
180
+ const caseInsensitivePath = await findCaseInsensitivePath(componentPath);
181
+ if (caseInsensitivePath) {
182
+ const content = await fs.readFile(caseInsensitivePath, 'utf8');
183
+ return content;
184
+ }
185
+ } catch {
186
+ // ignore
187
+ }
188
+
189
+ // Try case-insensitive JS component lookup
190
+ try {
191
+ const jsPath = componentPath.replace('.html', '.js');
192
+ const caseInsensitiveJsPath = await findCaseInsensitivePath(jsPath);
193
+ if (caseInsensitiveJsPath) {
194
+ const componentModule = await import(pathToFileURL(caseInsensitiveJsPath));
195
+ const componentExport = componentModule.default || componentModule;
196
+
197
+ if (typeof componentExport === 'function') {
198
+ const props = { ...attrs };
199
+ delete props.src;
200
+ delete props.name;
201
+ return componentExport(props);
202
+ } else {
203
+ return String(componentExport);
204
+ }
205
+ }
206
+ } catch {
207
+ // ignore
208
+ }
209
+ }
210
+
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Find a file path in a case-insensitive way
216
+ * @param {string} targetPath
217
+ * @returns {Promise<string|null>}
218
+ */
219
+ async function findCaseInsensitivePath(targetPath) {
220
+ const dir = path.dirname(targetPath);
221
+ const base = path.basename(targetPath).toLowerCase();
222
+
223
+ try {
224
+ const entries = await fs.readdir(dir);
225
+ const matched = entries.find((entry) => entry.toLowerCase() === base);
226
+ if (!matched) return null;
227
+ return path.join(dir, matched);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Parse attributes from an attribute string
235
+ * @param {string} attrsStr - Attribute string like ' class="foo" id="bar"'
236
+ * @returns {object} - Object with attribute key-value pairs
237
+ */
238
+ function parseAttributes(attrsStr) {
239
+ const attrs = {};
240
+ const attrRegex = /([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'))?/g;
241
+ let attrMatch;
242
+ while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
243
+ const key = attrMatch[1];
244
+ const value = attrMatch[2] ?? attrMatch[3] ?? 'true';
245
+ attrs[key] = value;
246
+ }
247
+ return attrs;
248
+ }
249
+
250
+ /**
251
+ * Extract named slots from component children content
252
+ * Supported syntax:
253
+ * <template slot="actions">...</template>
254
+ * {{ slot:actions }}
255
+ * @param {string} children
256
+ * @returns {{defaultChildren: string, namedSlots: Record<string, string>}}
257
+ */
258
+ function extractSlots(children) {
259
+ const namedSlots = {};
260
+ const slotRegex = /<template\s+slot=["']([\w-]+)["']\s*>([\s\S]*?)<\/template>/g;
261
+
262
+ let defaultChildren = children;
263
+ defaultChildren = defaultChildren.replace(slotRegex, (_, slotName, slotContent) => {
264
+ namedSlots[slotName] = slotContent;
265
+ return '';
266
+ });
267
+
268
+ return {
269
+ defaultChildren,
270
+ namedSlots,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Inline CSS from <link> tags into <style> tags
276
+ * @param {string} html - The HTML content
277
+ * @param {string} root - The src root directory
278
+ * @param {string} projectRoot - The project root directory
279
+ * @returns {Promise<string>} - HTML with inlined CSS
280
+ */
281
+ export async function inlineCss(html, root, projectRoot) {
282
+ const linkRegex = /<link[^>]+rel=["']stylesheet["'][^>]*>/gi;
283
+ const matches = [...html.matchAll(linkRegex)];
284
+
285
+ let result = html;
286
+
287
+ for (const match of matches) {
288
+ const linkTag = match[0];
289
+ const hrefMatch = linkTag.match(/href=["']([^"']+)["']/);
290
+
291
+ if (!hrefMatch) continue;
292
+
293
+ let href = hrefMatch[1];
294
+
295
+ // Skip external URLs
296
+ if (href.startsWith('http://') || href.startsWith('https://')) {
297
+ continue;
298
+ }
299
+
300
+ // Resolve the CSS file path - try multiple locations
301
+ let cssPath = null;
302
+ let cssContent = null;
303
+
304
+ // Possible paths to check
305
+ const pathsToTry = [];
306
+
307
+ if (href.startsWith('/')) {
308
+ const cleanHref = href.slice(1);
309
+ // /styles/styles.css -> src/assets/styles/styles.css (publicDir pattern)
310
+ pathsToTry.push(path.join(root, 'assets', cleanHref));
311
+ // /assets/styles/styles.css -> src/assets/styles/styles.css
312
+ pathsToTry.push(path.join(root, cleanHref));
313
+ // Try project root
314
+ pathsToTry.push(path.join(projectRoot, 'src', 'assets', cleanHref));
315
+ } else {
316
+ // Relative path
317
+ pathsToTry.push(path.join(root, href));
318
+ }
319
+
320
+ for (const tryPath of pathsToTry) {
321
+ if (await fileExists(tryPath)) {
322
+ cssPath = tryPath;
323
+ break;
324
+ }
325
+ }
326
+
327
+ if (cssPath) {
328
+ try {
329
+ cssContent = await fs.readFile(cssPath, 'utf8');
330
+ const styleTag = `<style>\n${cssContent}\n</style>`;
331
+ result = result.replace(linkTag, styleTag);
332
+ } catch (error) {
333
+ console.warn(`Could not inline CSS from ${href}: ${error.message}`);
334
+ }
335
+ } else {
336
+ console.warn(`Could not find CSS file for ${href}`);
337
+ }
338
+ }
339
+
340
+ return result;
341
+ }
342
+
343
+ /**
344
+ * Inline JS from <script src="..."> tags into inline <script> tags
345
+ * @param {string} html - The HTML content
346
+ * @param {string} root - The pages root directory
347
+ * @param {string} projectRoot - The project root directory
348
+ * @returns {Promise<string>} - HTML with inlined JS
349
+ */
350
+ export async function inlineJs(html, root, projectRoot) {
351
+ const scriptRegex = /<script[^>]+src=["']([^"']+)["'][^>]*><\/script>/gi;
352
+ const matches = [...html.matchAll(scriptRegex)];
353
+
354
+ let result = html;
355
+
356
+ for (const match of matches) {
357
+ const scriptTag = match[0];
358
+ let src = match[1];
359
+
360
+ // Skip external URLs and Vite client
361
+ if (src.startsWith('http://') || src.startsWith('https://') || src.includes('@vite')) {
362
+ continue;
363
+ }
364
+
365
+ // Resolve the JS file path
366
+ let jsPath;
367
+ if (src.startsWith('/')) {
368
+ jsPath = path.join(projectRoot, 'src', 'assets', src.slice(1));
369
+ if (!await fileExists(jsPath)) {
370
+ jsPath = path.join(root, 'assets', src.slice(1));
371
+ }
372
+ if (!await fileExists(jsPath)) {
373
+ jsPath = path.join(root, src.slice(1));
374
+ }
375
+ } else {
376
+ jsPath = path.join(root, src);
377
+ }
378
+
379
+ try {
380
+ const jsContent = await fs.readFile(jsPath, 'utf8');
381
+ const inlineScriptTag = `<script>\n${jsContent}\n</script>`;
382
+ result = result.replace(scriptTag, inlineScriptTag);
383
+ } catch (error) {
384
+ console.warn(`Could not inline JS from ${src}: ${error.message}`);
385
+ }
386
+ }
387
+
388
+ return result;
389
+ }
390
+
391
+ /**
392
+ * Check if a file exists
393
+ * @param {string} filePath - Path to check
394
+ * @returns {Promise<boolean>}
395
+ */
396
+ async function fileExists(filePath) {
397
+ try {
398
+ await fs.access(filePath);
399
+ return true;
400
+ } catch {
401
+ return false;
402
+ }
403
+ }