@webmate-studio/builder 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Component Build Micro-Service
4
+ * Runs a simple HTTP server that builds components (CSS + Islands)
5
+ * Extends the CSS service with full component building capabilities
6
+ */
7
+
8
+ import http from 'http';
9
+ import { generateComponentCSS } from './src/tailwind-generator.js';
10
+ import { bundleIsland } from './src/bundler.js';
11
+ import { mkdtemp, writeFile, rm, mkdir } from 'fs/promises';
12
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { tmpdir } from 'os';
15
+ import { execSync } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+
18
+ const PORT = 3031;
19
+
20
+ /**
21
+ * Install npm dependencies for a component
22
+ * @param {string} componentDir - Component directory
23
+ * @param {Object} packageJson - package.json content
24
+ */
25
+ async function installDependencies(componentDir, packageJson) {
26
+ if (!packageJson || !packageJson.dependencies || Object.keys(packageJson.dependencies).length === 0) {
27
+ return; // No dependencies
28
+ }
29
+
30
+ // Write package.json
31
+ const packageJsonPath = join(componentDir, 'package.json');
32
+ await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
33
+
34
+ // Check cache
35
+ const cacheDir = join(componentDir, '.wm-cache');
36
+ const hashFile = join(cacheDir, 'deps.hash');
37
+ const nodeModulesExists = existsSync(join(componentDir, 'node_modules'));
38
+
39
+ const currentHash = createHash('sha256').update(JSON.stringify(packageJson)).digest('hex');
40
+ const cachedHash = existsSync(hashFile) ? readFileSync(hashFile, 'utf8').trim() : null;
41
+
42
+ if (currentHash === cachedHash && nodeModulesExists) {
43
+ console.log('[Build Service] Dependencies cached');
44
+ return;
45
+ }
46
+
47
+ // Install
48
+ console.log('[Build Service] Installing dependencies...');
49
+ try {
50
+ execSync('npm install --no-save --no-audit --no-fund', {
51
+ cwd: componentDir,
52
+ stdio: 'inherit'
53
+ });
54
+
55
+ // Save cache
56
+ await mkdir(cacheDir, { recursive: true });
57
+ await writeFile(hashFile, currentHash);
58
+
59
+ console.log('[Build Service] ✓ Dependencies installed');
60
+ } catch (error) {
61
+ throw new Error(`Failed to install dependencies: ${error.message}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Build a component (islands + CSS)
67
+ * @param {Object} payload - Build request payload
68
+ * @returns {Object} Build result
69
+ */
70
+ async function buildComponent(payload) {
71
+ const { packageJson, islands, html, componentName = 'component' } = payload;
72
+
73
+ // Create temporary directory
74
+ const tmpDir = await mkdtemp(join(tmpdir(), 'wm-build-'));
75
+
76
+ try {
77
+ console.log(`[Build Service] Building ${componentName} in ${tmpDir}`);
78
+
79
+ // Install dependencies if needed
80
+ if (packageJson) {
81
+ await installDependencies(tmpDir, packageJson);
82
+ }
83
+
84
+ // Build islands
85
+ const bundledIslands = [];
86
+ if (islands && islands.length > 0) {
87
+ const islandsDir = join(tmpDir, 'islands');
88
+ await mkdir(islandsDir, { recursive: true });
89
+
90
+ for (const island of islands) {
91
+ const inputPath = join(islandsDir, island.file);
92
+ const outputPath = join(islandsDir, island.file.replace(/\.(jsx?|tsx?|svelte|vue)$/, '.js'));
93
+
94
+ // Write source file
95
+ await writeFile(inputPath, island.content);
96
+
97
+ console.log(`[Build Service] Bundling ${island.file}...`);
98
+
99
+ // Bundle with esbuild (pass tmpDir for node_modules resolution)
100
+ const result = await bundleIsland(inputPath, outputPath, {
101
+ componentDir: tmpDir,
102
+ minify: true,
103
+ sourcemap: false
104
+ });
105
+
106
+ if (!result.success) {
107
+ throw new Error(`Failed to bundle ${island.file}: ${result.error}`);
108
+ }
109
+
110
+ // Read bundled content
111
+ const bundledContent = readFileSync(outputPath, 'utf8');
112
+
113
+ bundledIslands.push({
114
+ file: island.file,
115
+ content: bundledContent,
116
+ size: result.size,
117
+ originalSize: island.content.length
118
+ });
119
+
120
+ console.log(`[Build Service] ✓ ${island.file} → ${(result.size / 1024).toFixed(2)}kb`);
121
+ }
122
+ }
123
+
124
+ // Generate CSS
125
+ let css = '';
126
+ if (html) {
127
+ console.log('[Build Service] Generating CSS...');
128
+ const cssResult = await generateComponentCSS(html, {
129
+ designTokens: null,
130
+ minify: true
131
+ });
132
+ css = cssResult.css;
133
+ console.log(`[Build Service] ✓ CSS → ${(css.length / 1024).toFixed(2)}kb`);
134
+ }
135
+
136
+ return {
137
+ success: true,
138
+ bundledIslands,
139
+ css,
140
+ stats: {
141
+ islands: bundledIslands.length,
142
+ totalSize: bundledIslands.reduce((sum, i) => sum + i.size, 0) + css.length
143
+ }
144
+ };
145
+ } finally {
146
+ // Cleanup temp directory
147
+ await rm(tmpDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ const server = http.createServer(async (req, res) => {
152
+ // CORS headers
153
+ res.setHeader('Access-Control-Allow-Origin', '*');
154
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
155
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
156
+
157
+ // Handle preflight
158
+ if (req.method === 'OPTIONS') {
159
+ res.writeHead(200);
160
+ res.end();
161
+ return;
162
+ }
163
+
164
+ // Only accept POST
165
+ if (req.method !== 'POST') {
166
+ res.writeHead(404, { 'Content-Type': 'application/json' });
167
+ res.end(JSON.stringify({ success: false, error: 'Not found' }));
168
+ return;
169
+ }
170
+
171
+ // Read body
172
+ let body = '';
173
+ req.on('data', chunk => {
174
+ body += chunk.toString();
175
+ });
176
+
177
+ req.on('end', async () => {
178
+ try {
179
+ const payload = JSON.parse(body);
180
+
181
+ // Route to appropriate handler
182
+ if (req.url === '/css/generate' || req.url === '/generate') {
183
+ // Legacy CSS generation endpoint
184
+ const { html } = payload;
185
+
186
+ if (!html) {
187
+ res.writeHead(400, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ success: false, error: 'Missing html parameter' }));
189
+ return;
190
+ }
191
+
192
+ const result = await generateComponentCSS(html, {
193
+ designTokens: null,
194
+ minify: true
195
+ });
196
+
197
+ res.writeHead(200, { 'Content-Type': 'application/json' });
198
+ res.end(JSON.stringify({
199
+ success: true,
200
+ css: result.css,
201
+ classes: result.classes
202
+ }));
203
+ } else if (req.url === '/build' || req.url === '/component/build') {
204
+ // NEW: Full component build (islands + CSS)
205
+ const result = await buildComponent(payload);
206
+
207
+ res.writeHead(200, { 'Content-Type': 'application/json' });
208
+ res.end(JSON.stringify(result));
209
+ } else {
210
+ res.writeHead(404, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ success: false, error: 'Not found' }));
212
+ }
213
+ } catch (error) {
214
+ console.error('[Build Service] Error:', error);
215
+ res.writeHead(500, { 'Content-Type': 'application/json' });
216
+ res.end(JSON.stringify({
217
+ success: false,
218
+ error: error.message,
219
+ stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
220
+ }));
221
+ }
222
+ });
223
+ });
224
+
225
+ server.listen(PORT, () => {
226
+ console.log(`[Build Service] Running on http://localhost:${PORT}`);
227
+ console.log(`[Build Service] Endpoints:`);
228
+ console.log(` POST /css/generate - Generate Tailwind CSS`);
229
+ console.log(` POST /build - Build component (islands + CSS)`);
230
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmate-studio/builder",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Webmate Studio Component Builder",
6
6
  "keywords": [
@@ -15,11 +15,18 @@
15
15
  "url": "https://github.com/webmate-studio/builder.git"
16
16
  },
17
17
  "files": [
18
- "src"
18
+ "src",
19
+ "build-service.js"
19
20
  ],
20
21
  "exports": {
21
22
  ".": "./src/index.js"
22
23
  },
24
+ "bin": {
25
+ "wm-build-service": "./build-service.js"
26
+ },
27
+ "scripts": {
28
+ "service": "node build-service.js"
29
+ },
23
30
  "publishConfig": {
24
31
  "access": "public"
25
32
  },
package/src/build.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, cpSync } from 'fs';
2
2
  import { join, dirname, relative, extname, basename } from 'path';
3
3
  import { glob } from 'glob';
4
+ import { execSync } from 'child_process';
5
+ import { createHash } from 'crypto';
4
6
  import { parseComponent } from '@webmate-studio/parser';
5
- import { loadConfig, logger } from '@webmate-studio/core';
7
+ import { loadConfig, logger, validateComponents, ComponentValidationError } from '@webmate-studio/core';
6
8
  import { cleanComponentHTML, extractStyles } from './html-cleaner.js';
7
9
  import { generateManifest } from './manifest.js';
8
10
  import { generateComponentCSS } from './tailwind-generator.js';
@@ -11,6 +13,67 @@ import { parseDocument } from 'htmlparser2';
11
13
  import { DomUtils } from 'htmlparser2';
12
14
  import render from 'dom-serializer';
13
15
 
16
+ /**
17
+ * Install component dependencies from package.json
18
+ * Uses caching to avoid unnecessary npm installs
19
+ * @param {string} componentDir - Component directory path
20
+ * @param {string} componentName - Component name for logging
21
+ */
22
+ async function installComponentDependencies(componentDir, componentName) {
23
+ const packageJsonPath = join(componentDir, 'package.json');
24
+
25
+ // No package.json? Skip.
26
+ if (!existsSync(packageJsonPath)) {
27
+ return;
28
+ }
29
+
30
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
31
+
32
+ // No dependencies? Skip.
33
+ if (!packageJson.dependencies || Object.keys(packageJson.dependencies).length === 0) {
34
+ return;
35
+ }
36
+
37
+ // Check cache: Has package.json changed since last install?
38
+ const cacheDir = join(componentDir, '.wm-cache');
39
+ const hashFile = join(cacheDir, 'deps.hash');
40
+ const nodeModulesExists = existsSync(join(componentDir, 'node_modules'));
41
+
42
+ // Calculate hash of package.json content
43
+ const packageJsonContent = readFileSync(packageJsonPath, 'utf8');
44
+ const currentHash = createHash('sha256').update(packageJsonContent).digest('hex');
45
+
46
+ // Read cached hash
47
+ const cachedHash = existsSync(hashFile) ? readFileSync(hashFile, 'utf8').trim() : null;
48
+
49
+ // Skip install if:
50
+ // 1. Hash matches (package.json unchanged)
51
+ // 2. node_modules exists
52
+ if (currentHash === cachedHash && nodeModulesExists) {
53
+ logger.debug(` ↳ Dependencies cached (${componentName})`);
54
+ return;
55
+ }
56
+
57
+ // Install dependencies
58
+ logger.info(` ↳ Installing dependencies for ${componentName}...`);
59
+
60
+ try {
61
+ execSync('npm install --no-save --no-audit --no-fund', {
62
+ cwd: componentDir,
63
+ stdio: 'inherit'
64
+ });
65
+
66
+ // Save hash to cache
67
+ mkdirSync(cacheDir, { recursive: true });
68
+ writeFileSync(hashFile, currentHash, 'utf8');
69
+
70
+ logger.success(` ↳ Dependencies installed (${componentName})`);
71
+ } catch (error) {
72
+ logger.error(` ✗ Failed to install dependencies for ${componentName}: ${error.message}`);
73
+ throw error;
74
+ }
75
+ }
76
+
14
77
  /**
15
78
  * Add scoping attribute to root element of component
16
79
  * @param {string} html - Component HTML
@@ -150,6 +213,44 @@ export async function build(options = {}) {
150
213
 
151
214
  logger.info(`Found ${allComponents.length} components`);
152
215
 
216
+ // Validate all components BEFORE building
217
+ logger.info('Validating component.json files...');
218
+ try {
219
+ const componentsToValidate = allComponents
220
+ .filter(file => file.endsWith('component.html')) // Only directory-based components need component.json
221
+ .map(file => {
222
+ const componentDir = dirname(join(componentsDir, file));
223
+ const componentJsonPath = join(componentDir, 'component.json');
224
+ const folderName = basename(componentDir);
225
+
226
+ let componentJson = null;
227
+ if (existsSync(componentJsonPath)) {
228
+ try {
229
+ componentJson = JSON.parse(readFileSync(componentJsonPath, 'utf8'));
230
+ } catch (error) {
231
+ // Will be caught by validator
232
+ }
233
+ }
234
+
235
+ return {
236
+ name: folderName,
237
+ path: relative(componentsDir, componentDir),
238
+ json: componentJson
239
+ };
240
+ });
241
+
242
+ if (componentsToValidate.length > 0) {
243
+ validateComponents(componentsToValidate);
244
+ logger.success(`✓ All ${componentsToValidate.length} components have valid UUIDs`);
245
+ }
246
+ } catch (error) {
247
+ if (error instanceof ComponentValidationError) {
248
+ logger.error(error.message);
249
+ process.exit(1);
250
+ }
251
+ throw error;
252
+ }
253
+
153
254
  const manifest = {
154
255
  version: '1.0.0',
155
256
  components: [],
@@ -165,6 +266,15 @@ export async function build(options = {}) {
165
266
  const componentPath = join(componentsDir, file);
166
267
  const html = readFileSync(componentPath, 'utf8');
167
268
 
269
+ // Determine if this is a directory-based component (before parsing)
270
+ const isDirectoryComponent = file.endsWith('component.html');
271
+ const componentDir = isDirectoryComponent ? dirname(join(componentsDir, file)) : null;
272
+
273
+ // Install component dependencies if package.json exists
274
+ if (componentDir) {
275
+ await installComponentDependencies(componentDir, basename(componentDir));
276
+ }
277
+
168
278
  try {
169
279
  // Parse component to extract schema
170
280
  const component = parseComponent(html, file);
@@ -182,18 +292,29 @@ export async function build(options = {}) {
182
292
  const isDirectoryComponent = file.endsWith('component.html');
183
293
  const componentDir = isDirectoryComponent ? dirname(join(componentsDir, file)) : null;
184
294
 
185
- // For directory-based components, load component.json to get the correct name
295
+ // For directory-based components, load component.json to get UUID, name, etc.
296
+ let componentUuid = null;
186
297
  if (isDirectoryComponent && componentDir) {
187
298
  const componentJsonPath = join(componentDir, 'component.json');
188
299
  if (existsSync(componentJsonPath)) {
189
300
  try {
190
301
  const componentJson = JSON.parse(readFileSync(componentJsonPath, 'utf8'));
191
- if (componentJson.name) {
302
+
303
+ // Extract UUID (required, validated earlier)
304
+ if (componentJson.id) {
305
+ componentUuid = componentJson.id.toLowerCase();
306
+ }
307
+
308
+ // Use displayName if available, otherwise name, otherwise fallback to parsed name
309
+ if (componentJson.displayName) {
310
+ component.name = componentJson.displayName;
311
+ } else if (componentJson.name) {
192
312
  component.name = componentJson.name;
193
- // Also merge props from component.json
194
- if (componentJson.props) {
195
- component.props = { ...component.props, ...componentJson.props };
196
- }
313
+ }
314
+
315
+ // Also merge props from component.json
316
+ if (componentJson.props) {
317
+ component.props = { ...component.props, ...componentJson.props };
197
318
  }
198
319
  } catch (error) {
199
320
  logger.warn(`Failed to parse component.json for ${file}: ${error.message}`);
@@ -272,6 +393,11 @@ export async function build(options = {}) {
272
393
  source: file
273
394
  };
274
395
 
396
+ // Add UUID for directory-based components (required for CMS sync)
397
+ if (componentUuid) {
398
+ manifestEntry.id = componentUuid;
399
+ }
400
+
275
401
  // Handle directory-based components (islands + assets)
276
402
  if (isDirectoryComponent && componentDir) {
277
403
  const componentOutputDir = dirname(outputPath);
package/src/bundler.js CHANGED
@@ -16,7 +16,8 @@ export async function bundleIsland(islandPath, outputPath, options = {}) {
16
16
  minify = true,
17
17
  sourcemap = true,
18
18
  target = 'es2020',
19
- format = 'esm'
19
+ format = 'esm',
20
+ componentDir = null // NEW: Component directory for component-specific node_modules
20
21
  } = options;
21
22
 
22
23
  try {
@@ -28,6 +29,9 @@ export async function bundleIsland(islandPath, outputPath, options = {}) {
28
29
  const cwd = process.cwd();
29
30
  const workspaceNodeModules = path.join(cwd, 'node_modules');
30
31
 
32
+ // NEW: Component-specific node_modules (highest priority)
33
+ const componentNodeModules = componentDir ? path.join(componentDir, 'node_modules') : null;
34
+
31
35
  // Determine if this file should use JSX loader
32
36
  // Only use JSX for .jsx files (React/Preact), not for .js files (Lit/Alpine/Vue/Vanilla)
33
37
  const useJsxLoader = islandPath.endsWith('.jsx');
@@ -88,8 +92,10 @@ export async function bundleIsland(islandPath, outputPath, options = {}) {
88
92
  ],
89
93
  // Don't bundle browser globals
90
94
  external: [],
91
- // Add multiple resolution paths - try both builder's node_modules and workspace node_modules
92
- nodePaths: [builderNodeModules, workspaceNodeModules],
95
+ // Add multiple resolution paths - Component node_modules has highest priority
96
+ nodePaths: componentNodeModules
97
+ ? [componentNodeModules, builderNodeModules, workspaceNodeModules]
98
+ : [builderNodeModules, workspaceNodeModules],
93
99
  // Enable package.json conditions for proper Svelte module resolution
94
100
  conditions: ['svelte', 'browser', 'import'],
95
101
  // Log level
@@ -144,7 +150,8 @@ export async function bundleComponentIslands(componentDir, outputDir) {
144
150
 
145
151
  console.log(pc.dim(` Bundling ${islandFile}...`));
146
152
 
147
- const result = await bundleIsland(inputPath, outputPath);
153
+ // Pass componentDir to bundler for component-specific node_modules resolution
154
+ const result = await bundleIsland(inputPath, outputPath, { componentDir });
148
155
 
149
156
  if (result.success) {
150
157
  const sizeKb = (result.size / 1024).toFixed(2);
@@ -27,16 +27,30 @@ export function cleanComponentHTML(html) {
27
27
 
28
28
  /**
29
29
  * Recursively remove metadata wm: attributes from DOM tree
30
- * KEEPS runtime attributes like wm:if, wm:for, wm:prop:*
30
+ * KEEPS runtime attributes: wm:if, wm:for, wm:island, wm:island-prop:*, wm:island-props, wm:class:*
31
+ * REMOVES metadata attributes: wm:component, wm:prop, wm:props, wm:schema, wm:description
32
+ *
33
+ * Note: Runtime attributes use Svelte-style syntax with curly braces:
34
+ * - wm:if={expression}
35
+ * - wm:for={item in items}
36
+ * - wm:class:active={isActive}
37
+ * - wm:island-prop:count={0}
38
+ *
31
39
  * @param {Object} node - DOM node
32
40
  */
33
41
  function removeWmAttributes(node) {
34
42
  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'];
43
+ // Metadata attributes to remove (no longer used - everything in component.json!)
44
+ const metadataAttributes = [
45
+ 'wm:component', // Removed: Use component.json instead
46
+ 'wm:prop', // Removed: Use component.json instead
47
+ 'wm:props', // Removed: Use component.json instead
48
+ 'wm:schema', // Legacy, removed
49
+ 'wm:description' // Metadata only
50
+ ];
37
51
 
38
52
  for (const attr in node.attribs) {
39
- // Remove only metadata attributes, keep wm:if, wm:for, wm:prop:*, etc.
53
+ // Remove metadata attributes
40
54
  if (metadataAttributes.includes(attr)) {
41
55
  delete node.attribs[attr];
42
56
  }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { build } from './build.js';
2
2
  import { generateComponentCSS, generateTailwindCSS, extractTailwindClasses } from './tailwind-generator.js';
3
+ import { cleanComponentHTML } from './html-cleaner.js';
3
4
 
4
- export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses };
5
+ export { build, generateComponentCSS, generateTailwindCSS, extractTailwindClasses, cleanComponentHTML };