@webmate-studio/builder 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.
- package/package.json +34 -0
- package/src/build.js +398 -0
- package/src/html-cleaner.js +123 -0
- package/src/index.js +3 -0
- package/src/manifest.js +72 -0
- package/src/tailwind-generator-v4.js +317 -0
- package/src/tailwind-generator.js +288 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webmate-studio/builder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Webmate Studio Component Builder",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"webmate",
|
|
8
|
+
"builder",
|
|
9
|
+
"web-components"
|
|
10
|
+
],
|
|
11
|
+
"author": "Michael Wischang",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/webmate-studio/builder.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.js"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"dom-serializer": "^2.0.0",
|
|
28
|
+
"esbuild": "^0.19.0",
|
|
29
|
+
"glob": "^10.3.0",
|
|
30
|
+
"htmlparser2": "^9.0.0",
|
|
31
|
+
"tailwindcss": "^4.1.0",
|
|
32
|
+
"@tailwindcss/cli": "^4.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, cpSync } from 'fs';
|
|
2
|
+
import { join, dirname, relative, extname, basename } from 'path';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { parseComponent } from '../../parser/src/index.js';
|
|
5
|
+
import { loadConfig, logger } from '../../core/src/index.js';
|
|
6
|
+
import { cleanComponentHTML, extractStyles } from './html-cleaner.js';
|
|
7
|
+
import { generateManifest } from './manifest.js';
|
|
8
|
+
import { generateComponentCSS } from './tailwind-generator-v4.js';
|
|
9
|
+
import { bundleComponentIslands } from '../../cli/src/utils/bundler.js';
|
|
10
|
+
import { parseDocument } from 'htmlparser2';
|
|
11
|
+
import { DomUtils } from 'htmlparser2';
|
|
12
|
+
import render from 'dom-serializer';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Add scoping attribute to root element of component
|
|
16
|
+
* @param {string} html - Component HTML
|
|
17
|
+
* @param {string} componentName - Name of component
|
|
18
|
+
* @returns {string} HTML with scoping attribute
|
|
19
|
+
*/
|
|
20
|
+
function addScopingAttribute(html, componentName) {
|
|
21
|
+
const dom = parseDocument(html);
|
|
22
|
+
const scopeId = `wm-${componentName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
|
23
|
+
|
|
24
|
+
// Find first element (skip text nodes and comments)
|
|
25
|
+
const rootElement = DomUtils.findOne((elem) => elem.type === 'tag', dom.children, true);
|
|
26
|
+
|
|
27
|
+
if (rootElement) {
|
|
28
|
+
// Add data-wm-component attribute
|
|
29
|
+
if (!rootElement.attribs) {
|
|
30
|
+
rootElement.attribs = {};
|
|
31
|
+
}
|
|
32
|
+
rootElement.attribs['data-wm-component'] = scopeId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return render(dom, {
|
|
36
|
+
encodeEntities: false,
|
|
37
|
+
selfClosingTags: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scope CSS to component using attribute selector
|
|
43
|
+
* @param {string} css - Component CSS
|
|
44
|
+
* @param {string} componentName - Name of component
|
|
45
|
+
* @returns {string} Scoped CSS
|
|
46
|
+
*/
|
|
47
|
+
function scopeCSS(css, componentName) {
|
|
48
|
+
if (!css || css.trim() === '') return '';
|
|
49
|
+
|
|
50
|
+
const scopeId = `wm-${componentName.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
|
51
|
+
const scopeSelector = `[data-wm-component="${scopeId}"]`;
|
|
52
|
+
|
|
53
|
+
// Split CSS into rules
|
|
54
|
+
const rules = [];
|
|
55
|
+
let currentRule = '';
|
|
56
|
+
let braceDepth = 0;
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < css.length; i++) {
|
|
59
|
+
const char = css[i];
|
|
60
|
+
currentRule += char;
|
|
61
|
+
|
|
62
|
+
if (char === '{') braceDepth++;
|
|
63
|
+
if (char === '}') {
|
|
64
|
+
braceDepth--;
|
|
65
|
+
if (braceDepth === 0) {
|
|
66
|
+
rules.push(currentRule.trim());
|
|
67
|
+
currentRule = '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Scope each rule
|
|
73
|
+
const scopedRules = rules.map(rule => {
|
|
74
|
+
// Skip @keyframes, @media, @supports, etc.
|
|
75
|
+
if (rule.startsWith('@')) {
|
|
76
|
+
// For @media, @supports, etc., scope the rules inside
|
|
77
|
+
if (rule.includes('{')) {
|
|
78
|
+
const parts = rule.split('{');
|
|
79
|
+
const atRule = parts[0] + '{';
|
|
80
|
+
const innerCSS = parts.slice(1).join('{');
|
|
81
|
+
const scopedInner = scopeCSS(innerCSS, componentName);
|
|
82
|
+
return atRule + scopedInner;
|
|
83
|
+
}
|
|
84
|
+
return rule;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract selector and declarations
|
|
88
|
+
const selectorEnd = rule.indexOf('{');
|
|
89
|
+
if (selectorEnd === -1) return rule;
|
|
90
|
+
|
|
91
|
+
const selectors = rule.substring(0, selectorEnd).trim();
|
|
92
|
+
const declarations = rule.substring(selectorEnd);
|
|
93
|
+
|
|
94
|
+
// Split multiple selectors
|
|
95
|
+
const selectorList = selectors.split(',').map(s => s.trim());
|
|
96
|
+
|
|
97
|
+
// Scope each selector
|
|
98
|
+
const scopedSelectors = selectorList.map(selector => {
|
|
99
|
+
// Skip :root, html, body
|
|
100
|
+
if (selector.match(/^(:root|html|body)(\s|$|:)/)) {
|
|
101
|
+
return selector;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Add scope attribute before first selector
|
|
105
|
+
return `${scopeSelector} ${selector}`;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return scopedSelectors.join(', ') + declarations;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return scopedRules.join('\n\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build components from source to distribution
|
|
116
|
+
* @param {Object} options - Build options
|
|
117
|
+
* @returns {Promise<Object>} Build result with manifest
|
|
118
|
+
*/
|
|
119
|
+
export async function build(options = {}) {
|
|
120
|
+
const config = await loadConfig();
|
|
121
|
+
const componentsDir = options.componentsDir || config.components.path;
|
|
122
|
+
const outputDir = options.outputDir || config.output.dir;
|
|
123
|
+
const minify = options.minify ?? config.output.minify;
|
|
124
|
+
|
|
125
|
+
logger.info('Building components...');
|
|
126
|
+
logger.debug('Components dir:', componentsDir);
|
|
127
|
+
logger.debug('Output dir:', outputDir);
|
|
128
|
+
|
|
129
|
+
// Create output directory
|
|
130
|
+
if (!existsSync(outputDir)) {
|
|
131
|
+
mkdirSync(outputDir, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Find all component files (both single-file and directory-based)
|
|
135
|
+
const componentFiles = await glob('**/*.html', {
|
|
136
|
+
cwd: componentsDir,
|
|
137
|
+
absolute: false,
|
|
138
|
+
ignore: ['**/node_modules/**']
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Also find directory-based components (with component.html)
|
|
142
|
+
const dirComponents = await glob('**/component.html', {
|
|
143
|
+
cwd: componentsDir,
|
|
144
|
+
absolute: false,
|
|
145
|
+
ignore: ['**/node_modules/**']
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Combine and deduplicate
|
|
149
|
+
const allComponents = [...new Set([...componentFiles, ...dirComponents])];
|
|
150
|
+
|
|
151
|
+
logger.info(`Found ${allComponents.length} components`);
|
|
152
|
+
|
|
153
|
+
const manifest = {
|
|
154
|
+
version: '1.0.0',
|
|
155
|
+
components: [],
|
|
156
|
+
styles: [],
|
|
157
|
+
metadata: {
|
|
158
|
+
buildTime: new Date().toISOString(),
|
|
159
|
+
minified: minify
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Process each component
|
|
164
|
+
for (const file of allComponents) {
|
|
165
|
+
const componentPath = join(componentsDir, file);
|
|
166
|
+
const html = readFileSync(componentPath, 'utf8');
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// Parse component to extract schema
|
|
170
|
+
const component = parseComponent(html, file);
|
|
171
|
+
|
|
172
|
+
// Extract and scope component styles
|
|
173
|
+
const { html: htmlWithoutStyles, css: componentCSS } = extractStyles(html);
|
|
174
|
+
|
|
175
|
+
// Clean HTML (remove wm: attributes)
|
|
176
|
+
const cleanHTML = cleanComponentHTML(htmlWithoutStyles);
|
|
177
|
+
|
|
178
|
+
// Add scoping attribute to root element
|
|
179
|
+
const scopedHTML = addScopingAttribute(cleanHTML, component.name);
|
|
180
|
+
|
|
181
|
+
// Determine if this is a directory-based component
|
|
182
|
+
const isDirectoryComponent = file.endsWith('component.html');
|
|
183
|
+
const componentDir = isDirectoryComponent ? dirname(join(componentsDir, file)) : null;
|
|
184
|
+
|
|
185
|
+
// For directory-based components, load component.json to get the correct name
|
|
186
|
+
if (isDirectoryComponent && componentDir) {
|
|
187
|
+
const componentJsonPath = join(componentDir, 'component.json');
|
|
188
|
+
if (existsSync(componentJsonPath)) {
|
|
189
|
+
try {
|
|
190
|
+
const componentJson = JSON.parse(readFileSync(componentJsonPath, 'utf8'));
|
|
191
|
+
if (componentJson.name) {
|
|
192
|
+
component.name = componentJson.name;
|
|
193
|
+
// Also merge props from component.json
|
|
194
|
+
if (componentJson.props) {
|
|
195
|
+
component.props = { ...component.props, ...componentJson.props };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
logger.warn(`Failed to parse component.json for ${file}: ${error.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Generate output filename
|
|
205
|
+
const outputFile = file.replace(/\.html$/, '.wm.html');
|
|
206
|
+
const outputPath = join(outputDir, outputFile);
|
|
207
|
+
|
|
208
|
+
// Ensure output directory exists
|
|
209
|
+
const outputFileDir = dirname(outputPath);
|
|
210
|
+
if (!existsSync(outputFileDir)) {
|
|
211
|
+
mkdirSync(outputFileDir, { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Write scoped HTML
|
|
215
|
+
writeFileSync(outputPath, scopedHTML, 'utf8');
|
|
216
|
+
|
|
217
|
+
// Scope and write component CSS if it exists
|
|
218
|
+
let componentCSSFile = null;
|
|
219
|
+
if (componentCSS) {
|
|
220
|
+
const scopedCSS = scopeCSS(componentCSS, component.name);
|
|
221
|
+
const componentCSSFileName = file.replace(/\.html$/, '.component.css');
|
|
222
|
+
const componentCSSPath = join(outputDir, componentCSSFileName);
|
|
223
|
+
writeFileSync(componentCSSPath, scopedCSS, 'utf8');
|
|
224
|
+
componentCSSFile = componentCSSFileName;
|
|
225
|
+
logger.debug(`Generated scoped CSS for ${component.name}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Collect all HTML for Tailwind scanning (including islands)
|
|
229
|
+
let allHTML = scopedHTML;
|
|
230
|
+
|
|
231
|
+
// For directory-based components, also scan island files for Tailwind classes
|
|
232
|
+
if (isDirectoryComponent && componentDir) {
|
|
233
|
+
const islandsSourceDir = join(componentDir, 'islands');
|
|
234
|
+
if (existsSync(islandsSourceDir)) {
|
|
235
|
+
// Scan Svelte, JS, and JSX files for Tailwind classes
|
|
236
|
+
const islandFiles = readdirSync(islandsSourceDir).filter(f =>
|
|
237
|
+
f.endsWith('.svelte') || f.endsWith('.js') || f.endsWith('.jsx')
|
|
238
|
+
);
|
|
239
|
+
for (const islandFile of islandFiles) {
|
|
240
|
+
const islandPath = join(islandsSourceDir, islandFile);
|
|
241
|
+
const islandContent = readFileSync(islandPath, 'utf8');
|
|
242
|
+
allHTML += '\n' + islandContent;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Generate Tailwind CSS for this component
|
|
248
|
+
logger.info(`Generating Tailwind CSS for ${component.name}...`);
|
|
249
|
+
const { css, classes } = await generateComponentCSS(allHTML, {
|
|
250
|
+
minify: minify,
|
|
251
|
+
designTokens: options.designTokens // Pass design tokens to Tailwind generator
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Write CSS file
|
|
255
|
+
const cssFile = file.replace(/\.html$/, '.tailwind.css');
|
|
256
|
+
const cssPath = join(outputDir, cssFile);
|
|
257
|
+
writeFileSync(cssPath, css, 'utf8');
|
|
258
|
+
|
|
259
|
+
logger.debug(`Found ${classes.length} Tailwind classes in ${component.name}`);
|
|
260
|
+
|
|
261
|
+
// Component manifest entry
|
|
262
|
+
const manifestEntry = {
|
|
263
|
+
name: component.name,
|
|
264
|
+
type: component.type,
|
|
265
|
+
file: outputFile,
|
|
266
|
+
cssFile: cssFile,
|
|
267
|
+
componentCSSFile: componentCSSFile, // Scoped component CSS
|
|
268
|
+
tailwindClasses: classes,
|
|
269
|
+
props: component.props,
|
|
270
|
+
conditionals: component.conditionals,
|
|
271
|
+
loops: component.loops,
|
|
272
|
+
source: file
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Handle directory-based components (islands + assets)
|
|
276
|
+
if (isDirectoryComponent && componentDir) {
|
|
277
|
+
const componentOutputDir = dirname(outputPath);
|
|
278
|
+
|
|
279
|
+
// Bundle islands if they exist
|
|
280
|
+
const islandsSourceDir = join(componentDir, 'islands');
|
|
281
|
+
if (existsSync(islandsSourceDir)) {
|
|
282
|
+
logger.info(`Bundling islands for ${component.name}...`);
|
|
283
|
+
const islandsOutputDir = join(componentOutputDir, 'islands');
|
|
284
|
+
|
|
285
|
+
const bundleResult = await bundleComponentIslands(componentDir, islandsOutputDir);
|
|
286
|
+
|
|
287
|
+
if (bundleResult.success && bundleResult.islands.length > 0) {
|
|
288
|
+
manifestEntry.islands = bundleResult.islands.map((island) => ({
|
|
289
|
+
file: `${dirname(file)}/islands/${island.file}`,
|
|
290
|
+
size: island.size
|
|
291
|
+
}));
|
|
292
|
+
logger.success(`Bundled ${bundleResult.islands.length} islands for ${component.name}`);
|
|
293
|
+
} else if (!bundleResult.success) {
|
|
294
|
+
logger.error(`Failed to bundle islands for ${component.name}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Copy assets if they exist
|
|
299
|
+
const assetsSourceDir = join(componentDir, 'assets');
|
|
300
|
+
if (existsSync(assetsSourceDir)) {
|
|
301
|
+
logger.info(`Copying assets for ${component.name}...`);
|
|
302
|
+
const assetsOutputDir = join(componentOutputDir, 'assets');
|
|
303
|
+
|
|
304
|
+
cpSync(assetsSourceDir, assetsOutputDir, { recursive: true });
|
|
305
|
+
|
|
306
|
+
const assetFiles = readdirSync(assetsSourceDir, { recursive: true });
|
|
307
|
+
manifestEntry.assets = assetFiles
|
|
308
|
+
.filter((f) => statSync(join(assetsSourceDir, f)).isFile())
|
|
309
|
+
.map((f) => `${dirname(file)}/assets/${f}`);
|
|
310
|
+
|
|
311
|
+
logger.success(`Copied ${manifestEntry.assets.length} assets for ${component.name}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Add to manifest
|
|
316
|
+
manifest.components.push(manifestEntry);
|
|
317
|
+
|
|
318
|
+
logger.success(`Built ${component.name} → ${outputFile} + ${cssFile}`);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
logger.error(`Failed to build ${file}:`, error.message);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Copy styles
|
|
326
|
+
if (config.components.styles && config.components.styles.length > 0) {
|
|
327
|
+
const stylesDir = join(outputDir, 'styles');
|
|
328
|
+
if (!existsSync(stylesDir)) {
|
|
329
|
+
mkdirSync(stylesDir, { recursive: true });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const stylePath of config.components.styles) {
|
|
333
|
+
const absolutePath = join(process.cwd(), stylePath);
|
|
334
|
+
if (existsSync(absolutePath)) {
|
|
335
|
+
const styleContent = readFileSync(absolutePath, 'utf8');
|
|
336
|
+
const fileName = basename(stylePath);
|
|
337
|
+
const outputPath = join(stylesDir, fileName);
|
|
338
|
+
writeFileSync(outputPath, styleContent, 'utf8');
|
|
339
|
+
|
|
340
|
+
manifest.styles.push({
|
|
341
|
+
file: `styles/${fileName}`,
|
|
342
|
+
source: stylePath
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
logger.success(`Copied ${stylePath} → styles/${fileName}`);
|
|
346
|
+
} else {
|
|
347
|
+
logger.warn(`Style file not found: ${stylePath}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Copy islands if configured
|
|
353
|
+
if (config.components.islands?.path) {
|
|
354
|
+
const islandsDir = config.components.islands.path;
|
|
355
|
+
if (existsSync(islandsDir)) {
|
|
356
|
+
const islandFiles = await glob('**/*.{js,ts,lit.js}', {
|
|
357
|
+
cwd: islandsDir,
|
|
358
|
+
absolute: false
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (islandFiles.length > 0) {
|
|
362
|
+
const outputIslandsDir = join(outputDir, 'islands');
|
|
363
|
+
if (!existsSync(outputIslandsDir)) {
|
|
364
|
+
mkdirSync(outputIslandsDir, { recursive: true });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const file of islandFiles) {
|
|
368
|
+
const sourcePath = join(islandsDir, file);
|
|
369
|
+
const content = readFileSync(sourcePath, 'utf8');
|
|
370
|
+
const outputPath = join(outputIslandsDir, file);
|
|
371
|
+
|
|
372
|
+
// Ensure subdirectories exist
|
|
373
|
+
const outputFileDir = dirname(outputPath);
|
|
374
|
+
if (!existsSync(outputFileDir)) {
|
|
375
|
+
mkdirSync(outputFileDir, { recursive: true });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
writeFileSync(outputPath, content, 'utf8');
|
|
379
|
+
logger.success(`Copied island: ${file}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
manifest.islands = {
|
|
383
|
+
framework: config.components.islands.framework || 'lit',
|
|
384
|
+
files: islandFiles
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Write manifest
|
|
391
|
+
const manifestPath = join(outputDir, 'manifest.json');
|
|
392
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
393
|
+
|
|
394
|
+
logger.success(`\nBuild complete! ${manifest.components.length} components built.`);
|
|
395
|
+
logger.info(`Output: ${outputDir}`);
|
|
396
|
+
|
|
397
|
+
return manifest;
|
|
398
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { parseDocument } from 'htmlparser2';
|
|
2
|
+
import { DomUtils } from 'htmlparser2';
|
|
3
|
+
import render from 'dom-serializer';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clean component HTML by removing all wm: attributes
|
|
7
|
+
* Keeps the HTML structure intact, only removes markers
|
|
8
|
+
*
|
|
9
|
+
* @param {string} html - Original component HTML
|
|
10
|
+
* @returns {string} Cleaned HTML without wm: attributes
|
|
11
|
+
*/
|
|
12
|
+
export function cleanComponentHTML(html) {
|
|
13
|
+
const dom = parseDocument(html);
|
|
14
|
+
|
|
15
|
+
// Remove all wm: attributes from all elements
|
|
16
|
+
removeWmAttributes(dom);
|
|
17
|
+
|
|
18
|
+
// Remove <script wm:schema> tags
|
|
19
|
+
removeSchemaScripts(dom);
|
|
20
|
+
|
|
21
|
+
// Serialize back to HTML
|
|
22
|
+
return render(dom, {
|
|
23
|
+
encodeEntities: false,
|
|
24
|
+
selfClosingTags: true
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively remove metadata wm: attributes from DOM tree
|
|
30
|
+
* KEEPS runtime attributes like wm:if, wm:for, wm:prop:*
|
|
31
|
+
* @param {Object} node - DOM node
|
|
32
|
+
*/
|
|
33
|
+
function removeWmAttributes(node) {
|
|
34
|
+
if (node.type === 'tag' && node.attribs) {
|
|
35
|
+
// Only remove metadata attributes, keep runtime directives!
|
|
36
|
+
const metadataAttributes = ['wm:component', 'wm:description', 'wm:props', 'wm:schema'];
|
|
37
|
+
|
|
38
|
+
for (const attr in node.attribs) {
|
|
39
|
+
// Remove only metadata attributes, keep wm:if, wm:for, wm:prop:*, etc.
|
|
40
|
+
if (metadataAttributes.includes(attr)) {
|
|
41
|
+
delete node.attribs[attr];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Also remove standalone 'wm' attribute
|
|
46
|
+
if ('wm' in node.attribs) {
|
|
47
|
+
delete node.attribs.wm;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Process children
|
|
52
|
+
if (node.children) {
|
|
53
|
+
for (const child of node.children) {
|
|
54
|
+
removeWmAttributes(child);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Process childNodes (alternative structure)
|
|
59
|
+
if (node.childNodes) {
|
|
60
|
+
for (const child of node.childNodes) {
|
|
61
|
+
removeWmAttributes(child);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remove all metadata <script> tags with wm: attributes
|
|
68
|
+
* Removes: wm:schema, wm:description, wm:props, wm=""
|
|
69
|
+
* @param {Object} dom - DOM tree
|
|
70
|
+
*/
|
|
71
|
+
function removeSchemaScripts(dom) {
|
|
72
|
+
const schemaScripts = DomUtils.findAll(
|
|
73
|
+
(elem) =>
|
|
74
|
+
elem.type === 'script' &&
|
|
75
|
+
elem.attribs &&
|
|
76
|
+
(elem.attribs['wm:schema'] !== undefined ||
|
|
77
|
+
elem.attribs['wm:description'] !== undefined ||
|
|
78
|
+
elem.attribs['wm:props'] !== undefined ||
|
|
79
|
+
elem.attribs.wm === ''),
|
|
80
|
+
dom.children || []
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
for (const script of schemaScripts) {
|
|
84
|
+
DomUtils.removeElement(script);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract inline styles from component HTML
|
|
90
|
+
* Returns both cleaned HTML and extracted CSS
|
|
91
|
+
*
|
|
92
|
+
* @param {string} html - Component HTML
|
|
93
|
+
* @returns {Object} { html: string, css: string }
|
|
94
|
+
*/
|
|
95
|
+
export function extractStyles(html) {
|
|
96
|
+
const dom = parseDocument(html);
|
|
97
|
+
let css = '';
|
|
98
|
+
|
|
99
|
+
// Find all <style> tags
|
|
100
|
+
const styleTags = DomUtils.findAll(
|
|
101
|
+
(elem) => elem.name === 'style',
|
|
102
|
+
dom.children
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
for (const styleTag of styleTags) {
|
|
106
|
+
const styleContent = DomUtils.textContent(styleTag);
|
|
107
|
+
css += styleContent + '\n';
|
|
108
|
+
|
|
109
|
+
// Remove style tag from DOM
|
|
110
|
+
DomUtils.removeElement(styleTag);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Serialize remaining HTML
|
|
114
|
+
const cleanHtml = render(dom, {
|
|
115
|
+
encodeEntities: false,
|
|
116
|
+
selfClosingTags: true
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
html: cleanHtml.trim(),
|
|
121
|
+
css: css.trim()
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/index.js
ADDED
package/src/manifest.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate component collection manifest
|
|
3
|
+
* The manifest contains all metadata needed by the CMS to:
|
|
4
|
+
* - Register components
|
|
5
|
+
* - Validate props
|
|
6
|
+
* - Render component forms
|
|
7
|
+
*
|
|
8
|
+
* @param {Array} components - Parsed components
|
|
9
|
+
* @param {Object} config - Build configuration
|
|
10
|
+
* @returns {Object} Manifest object
|
|
11
|
+
*/
|
|
12
|
+
export function generateManifest(components, config = {}) {
|
|
13
|
+
return {
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
name: config.name || 'Component Collection',
|
|
16
|
+
description: config.description || '',
|
|
17
|
+
author: config.author || '',
|
|
18
|
+
components: components.map((component) => ({
|
|
19
|
+
name: component.name,
|
|
20
|
+
type: component.type,
|
|
21
|
+
file: component.file,
|
|
22
|
+
category: component.category || 'general',
|
|
23
|
+
description: component.description || '',
|
|
24
|
+
template: component.template || null,
|
|
25
|
+
props: component.props,
|
|
26
|
+
slots: component.slots || {},
|
|
27
|
+
conditionals: component.conditionals,
|
|
28
|
+
loops: component.loops,
|
|
29
|
+
preview: component.preview || null,
|
|
30
|
+
tags: component.tags || []
|
|
31
|
+
})),
|
|
32
|
+
styles: config.styles || [],
|
|
33
|
+
fonts: config.fonts || [],
|
|
34
|
+
islands: config.islands || null,
|
|
35
|
+
metadata: {
|
|
36
|
+
buildTime: new Date().toISOString(),
|
|
37
|
+
minified: config.minified || false,
|
|
38
|
+
framework: config.framework || null
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate manifest structure
|
|
45
|
+
* @param {Object} manifest - Manifest to validate
|
|
46
|
+
* @throws {Error} If manifest is invalid
|
|
47
|
+
*/
|
|
48
|
+
export function validateManifest(manifest) {
|
|
49
|
+
if (!manifest.version) {
|
|
50
|
+
throw new Error('Manifest missing version');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!Array.isArray(manifest.components)) {
|
|
54
|
+
throw new Error('Manifest components must be an array');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const component of manifest.components) {
|
|
58
|
+
if (!component.name) {
|
|
59
|
+
throw new Error(`Component missing name: ${JSON.stringify(component)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!component.file) {
|
|
63
|
+
throw new Error(`Component ${component.name} missing file`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!component.type) {
|
|
67
|
+
throw new Error(`Component ${component.name} missing type`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind CSS v4 Generator for Components
|
|
3
|
+
* Generates scoped Tailwind CSS using Tailwind v4's new CSS-based configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { writeFileSync, unlinkSync, mkdtempSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
// Get the directory where this module is located
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
|
|
19
|
+
// Find tailwindcss binary in builder package's node_modules
|
|
20
|
+
const tailwindBinary = join(__dirname, '..', 'node_modules', '.bin', 'tailwindcss');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract Tailwind classes from HTML content
|
|
24
|
+
* @param {string} html - HTML content
|
|
25
|
+
* @returns {Set<string>} Set of unique Tailwind classes
|
|
26
|
+
*/
|
|
27
|
+
export function extractTailwindClasses(html) {
|
|
28
|
+
const classes = new Set();
|
|
29
|
+
|
|
30
|
+
// Match class="..." and class='...'
|
|
31
|
+
const classRegex = /class=["']([^"']+)["']/g;
|
|
32
|
+
let match;
|
|
33
|
+
|
|
34
|
+
while ((match = classRegex.exec(html)) !== null) {
|
|
35
|
+
const classString = match[1];
|
|
36
|
+
// Split by whitespace and add each class
|
|
37
|
+
classString.split(/\s+/).forEach(cls => {
|
|
38
|
+
if (cls.trim()) {
|
|
39
|
+
classes.add(cls.trim());
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return classes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate CSS with custom theme colors and design tokens for Tailwind v4
|
|
49
|
+
* @param {Object} colors - Color definitions
|
|
50
|
+
* @param {Object} designTokens - Complete design tokens (optional)
|
|
51
|
+
* @returns {string} CSS with @theme directive
|
|
52
|
+
*/
|
|
53
|
+
function generateThemeCSS(colors, designTokens = null) {
|
|
54
|
+
let themeCSS = `@theme {\n`;
|
|
55
|
+
|
|
56
|
+
// Helper to flatten nested colors
|
|
57
|
+
function flattenColors(obj, prefix = '') {
|
|
58
|
+
let css = '';
|
|
59
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
60
|
+
const colorKey = prefix ? `${prefix}-${key}` : key;
|
|
61
|
+
|
|
62
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
63
|
+
// Nested color (e.g., primary.light)
|
|
64
|
+
if (value.DEFAULT) {
|
|
65
|
+
css += ` --color-${colorKey}: ${value.DEFAULT};\n`;
|
|
66
|
+
}
|
|
67
|
+
css += flattenColors(value, colorKey);
|
|
68
|
+
} else {
|
|
69
|
+
// Simple color value
|
|
70
|
+
css += ` --color-${colorKey}: ${value};\n`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return css;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
themeCSS += flattenColors(colors);
|
|
77
|
+
|
|
78
|
+
// Add spacing if provided in design tokens
|
|
79
|
+
if (designTokens?.spacing) {
|
|
80
|
+
if (typeof designTokens.spacing === 'string') {
|
|
81
|
+
themeCSS += ` --spacing: ${designTokens.spacing};\n`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
themeCSS += `}\n`;
|
|
86
|
+
|
|
87
|
+
return themeCSS;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate Tailwind CSS for specific classes (Tailwind v4)
|
|
92
|
+
* @param {Set<string>|Array<string>} classes - Tailwind classes to include
|
|
93
|
+
* @param {Object} options - Generation options
|
|
94
|
+
* @param {Object} options.designTokens - Custom design tokens (optional)
|
|
95
|
+
* @param {boolean} options.minify - Minify output CSS
|
|
96
|
+
* @returns {Promise<string>} Generated CSS
|
|
97
|
+
*/
|
|
98
|
+
export async function generateTailwindCSS(classes, options = {}) {
|
|
99
|
+
const {
|
|
100
|
+
designTokens = null,
|
|
101
|
+
minify = true
|
|
102
|
+
} = options;
|
|
103
|
+
|
|
104
|
+
const classArray = Array.isArray(classes) ? classes : Array.from(classes);
|
|
105
|
+
|
|
106
|
+
if (classArray.length === 0) {
|
|
107
|
+
return '/* No Tailwind classes found */';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Create temporary directory
|
|
111
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'tailwind-gen-'));
|
|
112
|
+
const inputPath = join(tempDir, 'input.css');
|
|
113
|
+
const htmlPath = join(tempDir, 'content.html');
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Default theme colors (complete design tokens)
|
|
117
|
+
const defaultColors = {
|
|
118
|
+
// Brand colors with variants
|
|
119
|
+
primary: {
|
|
120
|
+
DEFAULT: '#cf0ce9',
|
|
121
|
+
light: '#fb923c',
|
|
122
|
+
dark: '#c2410c'
|
|
123
|
+
},
|
|
124
|
+
secondary: {
|
|
125
|
+
DEFAULT: '#6366f1',
|
|
126
|
+
light: '#818cf8',
|
|
127
|
+
dark: '#4f46e5'
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Feedback colors with variants
|
|
131
|
+
success: {
|
|
132
|
+
DEFAULT: '#10b981',
|
|
133
|
+
light: '#34d399',
|
|
134
|
+
dark: '#059669'
|
|
135
|
+
},
|
|
136
|
+
error: {
|
|
137
|
+
DEFAULT: '#ef4444',
|
|
138
|
+
light: '#f87171',
|
|
139
|
+
dark: '#dc2626'
|
|
140
|
+
},
|
|
141
|
+
warning: {
|
|
142
|
+
DEFAULT: '#f59e0b',
|
|
143
|
+
light: '#fbbf24',
|
|
144
|
+
dark: '#d97706'
|
|
145
|
+
},
|
|
146
|
+
info: {
|
|
147
|
+
DEFAULT: '#3b82f6',
|
|
148
|
+
light: '#60a5fa',
|
|
149
|
+
dark: '#2563eb'
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Grays
|
|
153
|
+
gray: {
|
|
154
|
+
50: '#f9fafb',
|
|
155
|
+
100: '#f3f4f6',
|
|
156
|
+
200: '#e5e7eb',
|
|
157
|
+
300: '#d1d5db',
|
|
158
|
+
400: '#9ca3af',
|
|
159
|
+
500: '#6b7280',
|
|
160
|
+
600: '#4b5563',
|
|
161
|
+
700: '#374151',
|
|
162
|
+
800: '#1f2937',
|
|
163
|
+
900: '#111827'
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
// Semantic text colors
|
|
167
|
+
'text-body': '#374151',
|
|
168
|
+
'text-heading': '#111827',
|
|
169
|
+
'text-muted': '#6b7280',
|
|
170
|
+
'text-inverse': '#ffffff',
|
|
171
|
+
|
|
172
|
+
// Semantic background colors
|
|
173
|
+
'bg-default': '#ffffff',
|
|
174
|
+
'bg-subtle': '#f9fafb',
|
|
175
|
+
'bg-elevated': '#ffffff',
|
|
176
|
+
'bg-inverse': '#111827',
|
|
177
|
+
|
|
178
|
+
// Basic colors
|
|
179
|
+
black: '#000000',
|
|
180
|
+
white: '#ffffff'
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Merge with provided design tokens
|
|
184
|
+
let themeColors = defaultColors;
|
|
185
|
+
if (designTokens?.colors) {
|
|
186
|
+
const tokens = designTokens.colors;
|
|
187
|
+
themeColors = {
|
|
188
|
+
...defaultColors,
|
|
189
|
+
primary: {
|
|
190
|
+
DEFAULT: tokens.primary || defaultColors.primary.DEFAULT,
|
|
191
|
+
light: tokens.primaryLight || defaultColors.primary.light,
|
|
192
|
+
dark: tokens.primaryDark || defaultColors.primary.dark
|
|
193
|
+
},
|
|
194
|
+
secondary: {
|
|
195
|
+
DEFAULT: tokens.secondary || defaultColors.secondary.DEFAULT,
|
|
196
|
+
light: tokens.secondaryLight || defaultColors.secondary.light,
|
|
197
|
+
dark: tokens.secondaryDark || defaultColors.secondary.dark
|
|
198
|
+
},
|
|
199
|
+
success: {
|
|
200
|
+
DEFAULT: tokens.success || defaultColors.success.DEFAULT,
|
|
201
|
+
light: tokens.successLight || defaultColors.success.light,
|
|
202
|
+
dark: tokens.successDark || defaultColors.success.dark
|
|
203
|
+
},
|
|
204
|
+
error: {
|
|
205
|
+
DEFAULT: tokens.error || defaultColors.error.DEFAULT,
|
|
206
|
+
light: tokens.errorLight || defaultColors.error.light,
|
|
207
|
+
dark: tokens.errorDark || defaultColors.error.dark
|
|
208
|
+
},
|
|
209
|
+
warning: {
|
|
210
|
+
DEFAULT: tokens.warning || defaultColors.warning.DEFAULT,
|
|
211
|
+
light: tokens.warningLight || defaultColors.warning.light,
|
|
212
|
+
dark: tokens.warningDark || defaultColors.warning.dark
|
|
213
|
+
},
|
|
214
|
+
info: {
|
|
215
|
+
DEFAULT: tokens.info || defaultColors.info.DEFAULT,
|
|
216
|
+
light: tokens.infoLight || defaultColors.info.light,
|
|
217
|
+
dark: tokens.infoDark || defaultColors.info.dark
|
|
218
|
+
},
|
|
219
|
+
// Semantic colors
|
|
220
|
+
'text-body': tokens.textBody || defaultColors['text-body'],
|
|
221
|
+
'text-heading': tokens.textHeading || defaultColors['text-heading'],
|
|
222
|
+
'text-muted': tokens.textMuted || defaultColors['text-muted'],
|
|
223
|
+
'text-inverse': tokens.textInverse || defaultColors['text-inverse'],
|
|
224
|
+
'bg-default': tokens.bgDefault || defaultColors['bg-default'],
|
|
225
|
+
'bg-subtle': tokens.bgSubtle || defaultColors['bg-subtle'],
|
|
226
|
+
'bg-elevated': tokens.bgElevated || defaultColors['bg-elevated'],
|
|
227
|
+
'bg-inverse': tokens.bgInverse || defaultColors['bg-inverse'],
|
|
228
|
+
// Grays
|
|
229
|
+
gray: {
|
|
230
|
+
50: tokens.gray50 || defaultColors.gray[50],
|
|
231
|
+
100: tokens.gray100 || defaultColors.gray[100],
|
|
232
|
+
200: tokens.gray200 || defaultColors.gray[200],
|
|
233
|
+
300: tokens.gray300 || defaultColors.gray[300],
|
|
234
|
+
400: tokens.gray400 || defaultColors.gray[400],
|
|
235
|
+
500: tokens.gray500 || defaultColors.gray[500],
|
|
236
|
+
600: tokens.gray600 || defaultColors.gray[600],
|
|
237
|
+
700: tokens.gray700 || defaultColors.gray[700],
|
|
238
|
+
800: tokens.gray800 || defaultColors.gray[800],
|
|
239
|
+
900: tokens.gray900 || defaultColors.gray[900]
|
|
240
|
+
},
|
|
241
|
+
black: tokens.black || defaultColors.black,
|
|
242
|
+
white: tokens.white || defaultColors.white
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Create input CSS with Tailwind v4 syntax
|
|
247
|
+
const themeCSS = generateThemeCSS(themeColors);
|
|
248
|
+
const inputCSS = `
|
|
249
|
+
@import "tailwindcss";
|
|
250
|
+
|
|
251
|
+
${themeCSS}
|
|
252
|
+
`.trim();
|
|
253
|
+
|
|
254
|
+
writeFileSync(inputPath, inputCSS);
|
|
255
|
+
|
|
256
|
+
// Create dummy HTML file with all classes (for content scanning)
|
|
257
|
+
const dummyHTML = `<div class="${classArray.join(' ')}"></div>`;
|
|
258
|
+
writeFileSync(htmlPath, dummyHTML);
|
|
259
|
+
|
|
260
|
+
// Run Tailwind v4 CLI
|
|
261
|
+
// Tailwind v4 auto-detects HTML files in the same directory
|
|
262
|
+
const command = `node "${tailwindBinary}" -i "${inputPath}" ${minify ? '--minify' : ''}`;
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
// Set NODE_PATH so tailwindcss module can be resolved
|
|
266
|
+
const builderNodeModules = join(__dirname, "..", "node_modules");
|
|
267
|
+
|
|
268
|
+
const { stdout } = await execAsync(command, {
|
|
269
|
+
cwd: tempDir,
|
|
270
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
271
|
+
env: {
|
|
272
|
+
...process.env,
|
|
273
|
+
NODE_PATH: builderNodeModules
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return stdout.trim();
|
|
278
|
+
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error('[Tailwind Generator v4] Error:', error);
|
|
281
|
+
throw new Error(`Failed to generate Tailwind CSS: ${error.message}`);
|
|
282
|
+
} finally {
|
|
283
|
+
// Cleanup temp files
|
|
284
|
+
try {
|
|
285
|
+
unlinkSync(inputPath);
|
|
286
|
+
unlinkSync(htmlPath);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// Ignore cleanup errors
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Generate Tailwind CSS for a component HTML file
|
|
295
|
+
* @param {string} html - Component HTML content
|
|
296
|
+
* @param {Object} options - Generation options
|
|
297
|
+
* @returns {Promise<{css: string, classes: string[]}>} Generated CSS and extracted classes
|
|
298
|
+
*/
|
|
299
|
+
export async function generateComponentCSS(html, options = {}) {
|
|
300
|
+
// Extract classes from HTML
|
|
301
|
+
const classes = extractTailwindClasses(html);
|
|
302
|
+
|
|
303
|
+
if (classes.size === 0) {
|
|
304
|
+
return {
|
|
305
|
+
css: '/* No Tailwind classes found in component */\n',
|
|
306
|
+
classes: []
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Generate CSS
|
|
311
|
+
const css = await generateTailwindCSS(classes, options);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
css,
|
|
315
|
+
classes: Array.from(classes)
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind CSS Generator for Components
|
|
3
|
+
* Generates scoped Tailwind CSS for each component based on used classes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
// Get the directory where this module is located
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
|
|
19
|
+
// Find tailwindcss binary in builder package's node_modules
|
|
20
|
+
const tailwindBinary = join(__dirname, '..', 'node_modules', '.bin', 'tailwindcss');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract Tailwind classes from HTML content
|
|
24
|
+
* @param {string} html - HTML content
|
|
25
|
+
* @returns {Set<string>} Set of unique Tailwind classes
|
|
26
|
+
*/
|
|
27
|
+
export function extractTailwindClasses(html) {
|
|
28
|
+
const classes = new Set();
|
|
29
|
+
|
|
30
|
+
// Match class="..." and class='...'
|
|
31
|
+
const classRegex = /class=["']([^"']+)["']/g;
|
|
32
|
+
let match;
|
|
33
|
+
|
|
34
|
+
while ((match = classRegex.exec(html)) !== null) {
|
|
35
|
+
const classString = match[1];
|
|
36
|
+
// Split by whitespace and add each class
|
|
37
|
+
classString.split(/\s+/).forEach(cls => {
|
|
38
|
+
if (cls.trim()) {
|
|
39
|
+
classes.add(cls.trim());
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return classes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate Tailwind CSS for specific classes
|
|
49
|
+
* @param {Set<string>|Array<string>} classes - Tailwind classes to include
|
|
50
|
+
* @param {Object} options - Generation options
|
|
51
|
+
* @param {Object} options.designTokens - Custom design tokens (optional)
|
|
52
|
+
* @param {boolean} options.minify - Minify output CSS
|
|
53
|
+
* @returns {Promise<string>} Generated CSS
|
|
54
|
+
*/
|
|
55
|
+
export async function generateTailwindCSS(classes, options = {}) {
|
|
56
|
+
const {
|
|
57
|
+
designTokens = null,
|
|
58
|
+
minify = true
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
const classArray = Array.isArray(classes) ? classes : Array.from(classes);
|
|
62
|
+
|
|
63
|
+
if (classArray.length === 0) {
|
|
64
|
+
return '/* No Tailwind classes found */';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Create temporary directory
|
|
68
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'tailwind-gen-'));
|
|
69
|
+
const configPath = join(tempDir, 'tailwind.config.cjs');
|
|
70
|
+
const inputPath = join(tempDir, 'input.css');
|
|
71
|
+
const htmlPath = join(tempDir, 'content.html');
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Tailwind v4 uses CSS-based configuration
|
|
75
|
+
// Instead of config file, we generate CSS with custom properties and @theme
|
|
76
|
+
|
|
77
|
+
// Default theme colors (complete design tokens)
|
|
78
|
+
const defaultColors = {
|
|
79
|
+
// Brand colors with variants
|
|
80
|
+
primary: {
|
|
81
|
+
DEFAULT: '#cf0ce9',
|
|
82
|
+
light: '#fb923c',
|
|
83
|
+
dark: '#c2410c'
|
|
84
|
+
},
|
|
85
|
+
secondary: {
|
|
86
|
+
DEFAULT: '#6366f1',
|
|
87
|
+
light: '#818cf8',
|
|
88
|
+
dark: '#4f46e5'
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// Feedback colors with variants
|
|
92
|
+
success: {
|
|
93
|
+
DEFAULT: '#10b981',
|
|
94
|
+
light: '#34d399',
|
|
95
|
+
dark: '#059669'
|
|
96
|
+
},
|
|
97
|
+
error: {
|
|
98
|
+
DEFAULT: '#ef4444',
|
|
99
|
+
light: '#f87171',
|
|
100
|
+
dark: '#dc2626'
|
|
101
|
+
},
|
|
102
|
+
warning: {
|
|
103
|
+
DEFAULT: '#f59e0b',
|
|
104
|
+
light: '#fbbf24',
|
|
105
|
+
dark: '#d97706'
|
|
106
|
+
},
|
|
107
|
+
info: {
|
|
108
|
+
DEFAULT: '#3b82f6',
|
|
109
|
+
light: '#60a5fa',
|
|
110
|
+
dark: '#2563eb'
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Grays
|
|
114
|
+
gray: {
|
|
115
|
+
50: '#f9fafb',
|
|
116
|
+
100: '#f3f4f6',
|
|
117
|
+
200: '#e5e7eb',
|
|
118
|
+
300: '#d1d5db',
|
|
119
|
+
400: '#9ca3af',
|
|
120
|
+
500: '#6b7280',
|
|
121
|
+
600: '#4b5563',
|
|
122
|
+
700: '#374151',
|
|
123
|
+
800: '#1f2937',
|
|
124
|
+
900: '#111827'
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// Semantic text colors (direct utilities)
|
|
128
|
+
'text-body': '#374151',
|
|
129
|
+
'text-heading': '#111827',
|
|
130
|
+
'text-muted': '#6b7280',
|
|
131
|
+
'text-inverse': '#ffffff',
|
|
132
|
+
|
|
133
|
+
// Semantic background colors (direct utilities)
|
|
134
|
+
'bg-default': '#ffffff',
|
|
135
|
+
'bg-subtle': '#f9fafb',
|
|
136
|
+
'bg-elevated': '#ffffff',
|
|
137
|
+
'bg-inverse': '#111827',
|
|
138
|
+
|
|
139
|
+
// Basic colors
|
|
140
|
+
black: '#000000',
|
|
141
|
+
white: '#ffffff'
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Merge with provided design tokens
|
|
145
|
+
let themeColors = defaultColors;
|
|
146
|
+
if (designTokens?.colors) {
|
|
147
|
+
// Convert flat design tokens to nested structure
|
|
148
|
+
const tokens = designTokens.colors;
|
|
149
|
+
themeColors = {
|
|
150
|
+
...defaultColors,
|
|
151
|
+
// Override with provided tokens
|
|
152
|
+
primary: {
|
|
153
|
+
DEFAULT: tokens.primary || defaultColors.primary.DEFAULT,
|
|
154
|
+
light: tokens.primaryLight || defaultColors.primary.light,
|
|
155
|
+
dark: tokens.primaryDark || defaultColors.primary.dark
|
|
156
|
+
},
|
|
157
|
+
secondary: {
|
|
158
|
+
DEFAULT: tokens.secondary || defaultColors.secondary.DEFAULT,
|
|
159
|
+
light: tokens.secondaryLight || defaultColors.secondary.light,
|
|
160
|
+
dark: tokens.secondaryDark || defaultColors.secondary.dark
|
|
161
|
+
},
|
|
162
|
+
success: {
|
|
163
|
+
DEFAULT: tokens.success || defaultColors.success.DEFAULT,
|
|
164
|
+
light: tokens.successLight || defaultColors.success.light,
|
|
165
|
+
dark: tokens.successDark || defaultColors.success.dark
|
|
166
|
+
},
|
|
167
|
+
error: {
|
|
168
|
+
DEFAULT: tokens.error || defaultColors.error.DEFAULT,
|
|
169
|
+
light: tokens.errorLight || defaultColors.error.light,
|
|
170
|
+
dark: tokens.errorDark || defaultColors.error.dark
|
|
171
|
+
},
|
|
172
|
+
warning: {
|
|
173
|
+
DEFAULT: tokens.warning || defaultColors.warning.DEFAULT,
|
|
174
|
+
light: tokens.warningLight || defaultColors.warning.light,
|
|
175
|
+
dark: tokens.warningDark || defaultColors.warning.dark
|
|
176
|
+
},
|
|
177
|
+
info: {
|
|
178
|
+
DEFAULT: tokens.info || defaultColors.info.DEFAULT,
|
|
179
|
+
light: tokens.infoLight || defaultColors.info.light,
|
|
180
|
+
dark: tokens.infoDark || defaultColors.info.dark
|
|
181
|
+
},
|
|
182
|
+
// Semantic colors
|
|
183
|
+
'text-body': tokens.textBody || defaultColors['text-body'],
|
|
184
|
+
'text-heading': tokens.textHeading || defaultColors['text-heading'],
|
|
185
|
+
'text-muted': tokens.textMuted || defaultColors['text-muted'],
|
|
186
|
+
'text-inverse': tokens.textInverse || defaultColors['text-inverse'],
|
|
187
|
+
'bg-default': tokens.bgDefault || defaultColors['bg-default'],
|
|
188
|
+
'bg-subtle': tokens.bgSubtle || defaultColors['bg-subtle'],
|
|
189
|
+
'bg-elevated': tokens.bgElevated || defaultColors['bg-elevated'],
|
|
190
|
+
'bg-inverse': tokens.bgInverse || defaultColors['bg-inverse'],
|
|
191
|
+
// Grays
|
|
192
|
+
gray: {
|
|
193
|
+
50: tokens.gray50 || defaultColors.gray[50],
|
|
194
|
+
100: tokens.gray100 || defaultColors.gray[100],
|
|
195
|
+
200: tokens.gray200 || defaultColors.gray[200],
|
|
196
|
+
300: tokens.gray300 || defaultColors.gray[300],
|
|
197
|
+
400: tokens.gray400 || defaultColors.gray[400],
|
|
198
|
+
500: tokens.gray500 || defaultColors.gray[500],
|
|
199
|
+
600: tokens.gray600 || defaultColors.gray[600],
|
|
200
|
+
700: tokens.gray700 || defaultColors.gray[700],
|
|
201
|
+
800: tokens.gray800 || defaultColors.gray[800],
|
|
202
|
+
900: tokens.gray900 || defaultColors.gray[900]
|
|
203
|
+
},
|
|
204
|
+
black: tokens.black || defaultColors.black,
|
|
205
|
+
white: tokens.white || defaultColors.white
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Create Tailwind config with safelist
|
|
210
|
+
const config = {
|
|
211
|
+
content: [htmlPath],
|
|
212
|
+
safelist: classArray,
|
|
213
|
+
theme: {
|
|
214
|
+
extend: {
|
|
215
|
+
colors: themeColors,
|
|
216
|
+
fontFamily: designTokens?.typography?.fontFamily || {},
|
|
217
|
+
fontSize: designTokens?.typography?.fontSize || {},
|
|
218
|
+
spacing: designTokens?.spacing || {},
|
|
219
|
+
borderRadius: designTokens?.borderRadius || {},
|
|
220
|
+
boxShadow: designTokens?.boxShadow || {}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
plugins: []
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
writeFileSync(configPath, `module.exports = ${JSON.stringify(config, null, 2)};`);
|
|
227
|
+
|
|
228
|
+
// Create input CSS with Tailwind directives
|
|
229
|
+
const inputCSS = `
|
|
230
|
+
@tailwind base;
|
|
231
|
+
@tailwind components;
|
|
232
|
+
@tailwind utilities;
|
|
233
|
+
`.trim();
|
|
234
|
+
writeFileSync(inputPath, inputCSS);
|
|
235
|
+
|
|
236
|
+
// Create dummy HTML file with all classes (for content scanning)
|
|
237
|
+
const dummyHTML = `<div class="${classArray.join(' ')}"></div>`;
|
|
238
|
+
writeFileSync(htmlPath, dummyHTML);
|
|
239
|
+
|
|
240
|
+
// Run Tailwind CLI using node to execute the CLI script
|
|
241
|
+
const command = `node "${tailwindBinary}" -c "${configPath}" -i "${inputPath}" ${minify ? '--minify' : ''}`;
|
|
242
|
+
|
|
243
|
+
const { stdout } = await execAsync(command, {
|
|
244
|
+
maxBuffer: 10 * 1024 * 1024 // 10MB buffer
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return stdout.trim();
|
|
248
|
+
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error('[Tailwind Generator] Error:', error);
|
|
251
|
+
throw new Error(`Failed to generate Tailwind CSS: ${error.message}`);
|
|
252
|
+
} finally {
|
|
253
|
+
// Cleanup temp files
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(configPath);
|
|
256
|
+
unlinkSync(inputPath);
|
|
257
|
+
unlinkSync(htmlPath);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// Ignore cleanup errors
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generate Tailwind CSS for a component HTML file
|
|
266
|
+
* @param {string} html - Component HTML content
|
|
267
|
+
* @param {Object} options - Generation options
|
|
268
|
+
* @returns {Promise<{css: string, classes: string[]}>} Generated CSS and extracted classes
|
|
269
|
+
*/
|
|
270
|
+
export async function generateComponentCSS(html, options = {}) {
|
|
271
|
+
// Extract classes from HTML
|
|
272
|
+
const classes = extractTailwindClasses(html);
|
|
273
|
+
|
|
274
|
+
if (classes.size === 0) {
|
|
275
|
+
return {
|
|
276
|
+
css: '/* No Tailwind classes found in component */\n',
|
|
277
|
+
classes: []
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Generate CSS
|
|
282
|
+
const css = await generateTailwindCSS(classes, options);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
css,
|
|
286
|
+
classes: Array.from(classes)
|
|
287
|
+
};
|
|
288
|
+
}
|