@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 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
@@ -0,0 +1,3 @@
1
+ import { build } from './build.js';
2
+
3
+ export { build };
@@ -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
+ }