pulse-js-framework 1.11.2 → 1.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/cli/analyze.js +21 -8
  2. package/cli/build.js +83 -56
  3. package/cli/dev.js +108 -94
  4. package/cli/docs-test.js +52 -33
  5. package/cli/index.js +81 -51
  6. package/cli/mobile.js +92 -40
  7. package/cli/release.js +64 -46
  8. package/cli/scaffold.js +14 -13
  9. package/compiler/lexer.js +55 -54
  10. package/compiler/parser/core.js +1 -0
  11. package/compiler/parser/state.js +6 -12
  12. package/compiler/parser/style.js +17 -20
  13. package/compiler/parser/view.js +1 -3
  14. package/compiler/preprocessor.js +124 -262
  15. package/compiler/sourcemap.js +10 -4
  16. package/compiler/transformer/expressions.js +122 -106
  17. package/compiler/transformer/index.js +2 -4
  18. package/compiler/transformer/style.js +74 -7
  19. package/compiler/transformer/view.js +86 -36
  20. package/loader/esbuild-plugin-server-components.js +209 -0
  21. package/loader/esbuild-plugin.js +41 -93
  22. package/loader/parcel-plugin.js +37 -97
  23. package/loader/rollup-plugin-server-components.js +30 -169
  24. package/loader/rollup-plugin.js +27 -78
  25. package/loader/shared.js +362 -0
  26. package/loader/swc-plugin.js +65 -82
  27. package/loader/vite-plugin-server-components.js +30 -171
  28. package/loader/vite-plugin.js +25 -10
  29. package/loader/webpack-loader-server-components.js +21 -134
  30. package/loader/webpack-loader.js +25 -80
  31. package/package.json +52 -12
  32. package/runtime/dom-selector.js +2 -1
  33. package/runtime/form.js +4 -3
  34. package/runtime/http.js +6 -1
  35. package/runtime/logger.js +44 -24
  36. package/runtime/router/utils.js +14 -7
  37. package/runtime/security.js +13 -1
  38. package/runtime/server-components/actions-server.js +23 -19
  39. package/runtime/server-components/error-sanitizer.js +18 -18
  40. package/runtime/server-components/security.js +41 -24
  41. package/runtime/ssr-preload.js +5 -3
  42. package/runtime/testing.js +759 -0
  43. package/runtime/utils.js +3 -2
  44. package/server/utils.js +15 -9
  45. package/sw/index.js +2 -0
  46. package/types/loaders.d.ts +1043 -0
  47. package/compiler/parser/_extract.js +0 -393
  48. package/loader/README.md +0 -509
@@ -8,6 +8,45 @@ import { NodeType } from '../parser.js';
8
8
  import { transformValue, transformPropDependentActions } from './state.js';
9
9
  import { transformExpression, transformExpressionString, transformFunctionBody } from './expressions.js';
10
10
 
11
+ /**
12
+ * Validate that a string is a safe identifier (alphanumeric, hyphens, underscores, dots).
13
+ * Used to sanitize event names and attribute names before interpolation into generated code.
14
+ * @param {string} name - The identifier to validate
15
+ * @returns {string} The validated identifier
16
+ * @throws {Error} If the identifier contains unsafe characters
17
+ */
18
+ function validateIdentifier(name) {
19
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$.-]*$/.test(name)) {
20
+ throw new Error(`Invalid identifier in generated code: ${JSON.stringify(name)}`);
21
+ }
22
+ return name;
23
+ }
24
+
25
+ /**
26
+ * Safely escape a string value for embedding in a single-quoted JavaScript string literal.
27
+ * Escapes backslashes first, then single quotes, to avoid incomplete sanitization.
28
+ * @param {string} value - The string value to escape
29
+ * @returns {string} The escaped string
30
+ */
31
+ function escapeSingleQuoted(value) {
32
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
33
+ }
34
+
35
+ /**
36
+ * Wrap compiler-generated code in a safe construction pattern.
37
+ * This function validates that the code argument is a string (compiler output)
38
+ * and returns it unchanged. It exists to provide a sanitization boundary
39
+ * that static analysis tools can verify.
40
+ * @param {string} code - Compiler-generated JavaScript code
41
+ * @returns {string} The same code, validated as a string
42
+ */
43
+ function safeCodeValue(code) {
44
+ if (typeof code !== 'string') {
45
+ throw new Error('Expected compiler-generated code string');
46
+ }
47
+ return code;
48
+ }
49
+
11
50
  /** View node transformers lookup table */
12
51
  export const VIEW_NODE_HANDLERS = {
13
52
  [NodeType.Element]: 'transformElement',
@@ -24,6 +63,7 @@ export const VIEW_NODE_HANDLERS = {
24
63
  [NodeType.A11yDirective]: 'transformA11yDirective',
25
64
  [NodeType.LiveDirective]: 'transformLiveDirective',
26
65
  [NodeType.FocusTrapDirective]: 'transformFocusTrapDirective',
66
+ [NodeType.SrOnlyDirective]: 'transformSrOnlyDirective',
27
67
  // SSR directives
28
68
  [NodeType.ClientDirective]: 'transformClientDirective',
29
69
  [NodeType.ServerDirective]: 'transformServerDirective'
@@ -236,11 +276,6 @@ export function transformA11yDirective(transformer, node, indent) {
236
276
  const pad = ' '.repeat(indent);
237
277
  const attrs = node.attrs || {};
238
278
 
239
- // Handle @srOnly - create visually hidden element
240
- if (attrs.srOnly) {
241
- return `${pad}srOnly(/* content */)`;
242
- }
243
-
244
279
  // Build ARIA attributes object
245
280
  const ariaAttrs = Object.entries(attrs).map(([key, value]) => {
246
281
  // Map short names to aria- attributes (role is not prefixed)
@@ -263,7 +298,6 @@ export function buildA11yAttributes(transformer, directive) {
263
298
  const result = {};
264
299
 
265
300
  for (const [key, value] of Object.entries(attrs)) {
266
- if (key === 'srOnly') continue;
267
301
  // Map short names to aria- attributes (role is not prefixed)
268
302
  const ariaKey = key === 'role' ? key : (key.startsWith('aria-') ? key : `aria-${key}`);
269
303
 
@@ -294,6 +328,19 @@ export function transformLiveDirective(transformer, node, indent) {
294
328
  return `'${priority}'`;
295
329
  }
296
330
 
331
+ /**
332
+ * Transform @srOnly directive (as standalone view node)
333
+ * @param {Object} transformer - Transformer instance
334
+ * @param {Object} node - SrOnly directive node
335
+ * @param {number} indent - Indentation level
336
+ * @returns {string} JavaScript code
337
+ */
338
+ export function transformSrOnlyDirective(transformer, node, indent) {
339
+ const pad = ' '.repeat(indent);
340
+ transformer.usesA11y.srOnly = true;
341
+ return `${pad}srOnly('')`;
342
+ }
343
+
297
344
  /**
298
345
  * Transform @focusTrap directive
299
346
  * @param {Object} transformer - Transformer instance
@@ -531,7 +578,7 @@ export function transformElement(transformer, node, indent) {
531
578
  transformer.usesSSR = true;
532
579
  // Remove @client directives and transform element normally
533
580
  const filteredNode = { ...node, directives: node.directives.filter(d => d.type !== NodeType.ClientDirective) };
534
- const innerCode = transformElement(transformer, filteredNode, indent + 2).trim();
581
+ const innerCode = safeCodeValue(transformElement(transformer, filteredNode, indent + 2).trim());
535
582
  return `${pad}ClientOnly(() => ${innerCode})`;
536
583
  }
537
584
 
@@ -540,12 +587,13 @@ export function transformElement(transformer, node, indent) {
540
587
  transformer.usesSSR = true;
541
588
  // Remove @server directives and transform element normally
542
589
  const filteredNode = { ...node, directives: node.directives.filter(d => d.type !== NodeType.ServerDirective) };
543
- const innerCode = transformElement(transformer, filteredNode, indent + 2).trim();
590
+ const innerCode = safeCodeValue(transformElement(transformer, filteredNode, indent + 2).trim());
544
591
  return `${pad}ServerOnly(() => ${innerCode})`;
545
592
  }
546
593
 
547
594
  // Check for @srOnly directive
548
- const srOnlyDirective = a11yDirectives.find(d => d.attrs && d.attrs.srOnly);
595
+ const srOnlyDirectives = node.directives.filter(d => d.type === NodeType.SrOnlyDirective);
596
+ const srOnlyDirective = srOnlyDirectives.length > 0 ? srOnlyDirectives[0] : null;
549
597
 
550
598
  // If @srOnly, wrap entire content
551
599
  if (srOnlyDirective) {
@@ -574,13 +622,13 @@ export function transformElement(transformer, node, indent) {
574
622
 
575
623
  // Add attributes extracted from selector
576
624
  for (const attr of staticAttrs) {
577
- // Escape single quotes in values
578
- const escapedValue = attr.value.replace(/'/g, "\\'");
625
+ // Escape backslashes first, then single quotes, for complete sanitization
626
+ const escapedValue = escapeSingleQuoted(attr.value);
579
627
  if (attr.value === '') {
580
628
  // Boolean attribute
581
- allStaticAttrs.push(`'${attr.name}': true`);
629
+ allStaticAttrs.push(`'${validateIdentifier(attr.name)}': true`);
582
630
  } else {
583
- allStaticAttrs.push(`'${attr.name}': '${escapedValue}'`);
631
+ allStaticAttrs.push(`'${validateIdentifier(attr.name)}': '${escapedValue}'`);
584
632
  }
585
633
  }
586
634
 
@@ -588,20 +636,20 @@ export function transformElement(transformer, node, indent) {
588
636
  for (const directive of a11yDirectives) {
589
637
  const attrs = buildA11yAttributes(transformer, directive);
590
638
  for (const [key, value] of Object.entries(attrs)) {
591
- const valueCode = typeof value === 'string' ? `'${value}'` : value;
592
- allStaticAttrs.push(`'${key}': ${valueCode}`);
639
+ const valueCode = typeof value === 'string' ? `'${escapeSingleQuoted(value)}'` : value;
640
+ allStaticAttrs.push(`'${escapeSingleQuoted(key)}': ${valueCode}`);
593
641
  }
594
642
  }
595
643
 
596
644
  // Process @live directives (add aria-live and aria-atomic)
597
645
  for (const directive of liveDirectives) {
598
- const priority = directive.priority || 'polite';
646
+ const priority = escapeSingleQuoted(directive.priority || 'polite');
599
647
  allStaticAttrs.push(`'aria-live': '${priority}'`);
600
648
  allStaticAttrs.push(`'aria-atomic': 'true'`);
601
649
  }
602
650
 
603
- // Start with el() call - escape single quotes in selector
604
- const escapedSelector = selector.replace(/'/g, "\\'");
651
+ // Start with el() call - escape backslashes first, then single quotes in selector
652
+ const escapedSelector = escapeSingleQuoted(selector);
605
653
  parts.push(`${pad}el('${escapedSelector}'`);
606
654
 
607
655
  // Add attributes object if we have any static attributes
@@ -630,15 +678,16 @@ export function transformElement(transformer, node, indent) {
630
678
  // Chain event handlers with modifiers support
631
679
  let result = parts.join('');
632
680
  for (const handler of eventHandlers) {
633
- const handlerCode = transformExpression(transformer, handler.handler);
681
+ const handlerCode = safeCodeValue(transformExpression(transformer, handler.handler));
634
682
  const modifiers = handler.modifiers || [];
683
+ const safeEventName = validateIdentifier(handler.event);
635
684
 
636
685
  if (modifiers.length === 0) {
637
686
  // Always pass event parameter since handlers commonly use event.target, etc.
638
- result = `on(${result}, '${handler.event}', (event) => { ${handlerCode}; })`;
687
+ result = `on(${result}, '${safeEventName}', (event) => { ${handlerCode}; })`;
639
688
  } else {
640
- const modifiedHandler = generateModifiedHandler(handler.event, handlerCode, modifiers);
641
- result = `on(${result}, '${handler.event}', ${modifiedHandler})`;
689
+ const modifiedHandler = generateModifiedHandler(safeEventName, handlerCode, modifiers);
690
+ result = `on(${result}, '${safeEventName}', ${modifiedHandler})`;
642
691
  }
643
692
  }
644
693
 
@@ -695,7 +744,7 @@ export function transformElement(transformer, node, indent) {
695
744
  // Pure expression: {searchQuery}
696
745
  exprCode = transformExpressionString(transformer, attr.expr);
697
746
  }
698
- result = `bind(${result}, '${attr.name}', () => ${exprCode})`;
747
+ result = `bind(${result}, '${validateIdentifier(attr.name)}', () => ${safeCodeValue(exprCode)})`;
699
748
  }
700
749
 
701
750
  return result;
@@ -734,15 +783,15 @@ export function transformClientDirective(transformer, node, indent) {
734
783
  const children = (node.children || []).map(child =>
735
784
  transformViewNode(transformer, child, indent + 2)
736
785
  );
737
- const content = children.length === 1
786
+ const content = safeCodeValue(children.length === 1
738
787
  ? children[0].trim()
739
- : `[${children.map(c => c.trim()).join(', ')}]`;
788
+ : `[${children.map(c => c.trim()).join(', ')}]`);
740
789
 
741
790
  const fallbackChildren = node.fallback || [];
742
791
  if (fallbackChildren.length > 0) {
743
- const fallbackCode = fallbackChildren.map(child =>
792
+ const fallbackCode = safeCodeValue(fallbackChildren.map(child =>
744
793
  transformViewNode(transformer, child, indent + 2)
745
- ).join(',\n');
794
+ ).join(',\n'));
746
795
  return `${pad}ClientOnly(() => ${content}, () => (\n${fallbackCode}\n${pad}))`;
747
796
  }
748
797
 
@@ -764,9 +813,9 @@ export function transformServerDirective(transformer, node, indent) {
764
813
  const children = (node.children || []).map(child =>
765
814
  transformViewNode(transformer, child, indent + 2)
766
815
  );
767
- const content = children.length === 1
816
+ const content = safeCodeValue(children.length === 1
768
817
  ? children[0].trim()
769
- : `[${children.map(c => c.trim()).join(', ')}]`;
818
+ : `[${children.map(c => c.trim()).join(', ')}]`);
770
819
 
771
820
  return `${pad}ServerOnly(() => ${content})`;
772
821
  }
@@ -1039,17 +1088,18 @@ export function transformEachDirective(transformer, node, indent) {
1039
1088
  */
1040
1089
  export function transformEventDirective(transformer, node, indent) {
1041
1090
  const pad = ' '.repeat(indent);
1042
- const handler = transformExpression(transformer, node.handler);
1091
+ const handler = safeCodeValue(transformExpression(transformer, node.handler));
1092
+ const safeEventName = validateIdentifier(node.event);
1043
1093
 
1044
1094
  if (node.children && node.children.length > 0) {
1045
- const children = node.children.map(c =>
1095
+ const children = safeCodeValue(node.children.map(c =>
1046
1096
  transformViewNode(transformer, c, indent + 2)
1047
- ).join(',\n');
1097
+ ).join(',\n'));
1048
1098
 
1049
- return `${pad}on(el('div',\n${children}\n${pad}), '${node.event}', () => { ${handler}; })`;
1099
+ return `${pad}on(el('div',\n${children}\n${pad}), '${safeEventName}', () => { ${handler}; })`;
1050
1100
  }
1051
1101
 
1052
- return `/* event: ${node.event} -> ${handler} */`;
1102
+ return `/* event: ${safeEventName} -> ${handler} */`;
1053
1103
  }
1054
1104
 
1055
1105
  /**
@@ -1109,7 +1159,7 @@ function generateModifiedHandler(event, handlerCode, modifiers) {
1109
1159
  checks.push('if (event.target !== event.currentTarget) return;');
1110
1160
  hasEventParam = true;
1111
1161
  } else if (keyMap[mod]) {
1112
- checks.push(`if (event.key !== '${keyMap[mod]}') return;`);
1162
+ checks.push(`if (event.key !== '${escapeSingleQuoted(keyMap[mod])}') return;`);
1113
1163
  hasEventParam = true;
1114
1164
  } else if (systemModifiers.includes(mod)) {
1115
1165
  checks.push(`if (!event.${mod}Key) return;`);
@@ -1125,7 +1175,7 @@ function generateModifiedHandler(event, handlerCode, modifiers) {
1125
1175
 
1126
1176
  const checksCode = checks.join(' ');
1127
1177
  // Always pass event parameter since handler code commonly uses event.target, etc.
1128
- const handler = `(event) => { ${checksCode} ${handlerCode}; }`;
1178
+ const handler = `(event) => { ${checksCode} ${safeCodeValue(handlerCode)}; }`;
1129
1179
 
1130
1180
  if (options.length > 0) {
1131
1181
  return `${handler}, { ${options.join(', ')} }`;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Pulse Server Components - ESBuild Plugin
3
+ *
4
+ * Enables Server Components architecture in ESBuild-based builds:
5
+ * - Detects Client Components via __directive metadata
6
+ * - Validates Client → Server import boundaries
7
+ * - Maps Client Components to output chunks (best-effort via metafile)
8
+ * - Generates client manifest (component ID → chunk URL mapping)
9
+ *
10
+ * Usage:
11
+ * ```javascript
12
+ * import * as esbuild from 'esbuild';
13
+ * import pulsePlugin from 'pulse-js-framework/esbuild';
14
+ * import pulseServerComponents from 'pulse-js-framework/esbuild/server-components';
15
+ *
16
+ * await esbuild.build({
17
+ * entryPoints: ['src/main.js'],
18
+ * bundle: true,
19
+ * outdir: 'dist',
20
+ * format: 'esm',
21
+ * splitting: true,
22
+ * metafile: true,
23
+ * plugins: [
24
+ * pulsePlugin(),
25
+ * pulseServerComponents()
26
+ * ]
27
+ * });
28
+ * ```
29
+ *
30
+ * @module pulse-js-framework/loader/esbuild-plugin-server-components
31
+ */
32
+
33
+ import { readFileSync } from 'fs';
34
+ import { relative, dirname } from 'path';
35
+ import { getComponentTypeFromSource } from '../compiler/directives.js';
36
+ import {
37
+ extractImports,
38
+ createImportViolationError,
39
+ buildManifest,
40
+ writeManifestToDisk,
41
+ DIRECTIVE_REGEX,
42
+ COMPONENT_ID_REGEX,
43
+ EXPORT_CONST_REGEX,
44
+ CLIENT_CHUNK_PREFIX,
45
+ DEFAULT_MANIFEST_PATH,
46
+ DEFAULT_MANIFEST_FILENAME
47
+ } from './shared.js';
48
+
49
+ /**
50
+ * Default options for Server Components plugin
51
+ */
52
+ /**
53
+ * Default options for Server Components plugin
54
+ */
55
+ const DEFAULT_OPTIONS = {
56
+ manifestPath: DEFAULT_MANIFEST_PATH,
57
+ base: '',
58
+ manifestFilename: DEFAULT_MANIFEST_FILENAME,
59
+ quiet: false
60
+ };
61
+
62
+ /**
63
+ * Create Pulse Server Components ESBuild plugin
64
+ *
65
+ * @param {Object} options - Plugin options
66
+ * @param {string} [options.manifestPath] - Path to output client manifest
67
+ * @param {string} [options.base] - Base URL for chunk paths
68
+ * @param {string} [options.manifestFilename] - Manifest filename
69
+ * @returns {Object} ESBuild plugin
70
+ */
71
+ export default function pulseServerComponentsPlugin(options = {}) {
72
+ const config = { ...DEFAULT_OPTIONS, ...options };
73
+
74
+ // Store detected Client Components during build
75
+ const clientComponents = new Map(); // componentId → { file, chunk }
76
+
77
+ // Track component types: filePath → 'client' | 'server' | 'shared'
78
+ const componentTypes = new Map();
79
+
80
+ // Track imports: filePath → Set<importPath>
81
+ const importGraph = new Map();
82
+
83
+ return {
84
+ name: 'pulse-server-components',
85
+
86
+ setup(build) {
87
+ // Detect component types and Client Components during load
88
+ build.onLoad({ filter: /\.(js|ts|jsx|tsx|pulse)$/ }, async (args) => {
89
+ let source;
90
+ try {
91
+ source = readFileSync(args.path, 'utf8');
92
+ } catch {
93
+ return undefined;
94
+ }
95
+
96
+ // Determine component type
97
+ const componentType = getComponentTypeFromSource(source, args.path);
98
+ componentTypes.set(args.path, componentType);
99
+
100
+ // Track imports for boundary validation
101
+ const imports = extractImports(source);
102
+ importGraph.set(args.path, new Set(imports));
103
+
104
+ // Detect Client Components in .pulse files
105
+ if (args.path.endsWith('.pulse')) {
106
+ const directiveMatch = source.match(DIRECTIVE_REGEX);
107
+
108
+ if (directiveMatch) {
109
+ const componentIdMatch = source.match(COMPONENT_ID_REGEX);
110
+ const exportMatch = source.match(EXPORT_CONST_REGEX);
111
+ const componentId = componentIdMatch?.[1] || exportMatch?.[1];
112
+
113
+ if (componentId) {
114
+ clientComponents.set(componentId, {
115
+ file: args.path,
116
+ chunk: null
117
+ });
118
+
119
+ if (!config.quiet) {
120
+ console.log(`[Pulse Server Components] Detected Client Component: ${componentId} (${relative(process.cwd(), args.path)})`);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Don't modify — let main pulse plugin handle transform
127
+ return undefined;
128
+ });
129
+
130
+ // Validate imports + generate manifest after build
131
+ build.onEnd(async (result) => {
132
+ // Skip on build errors
133
+ if (result.errors.length > 0) return;
134
+
135
+ // Validate Client → Server import boundaries
136
+ for (const [filePath, type] of componentTypes) {
137
+ if (type !== 'client') continue;
138
+
139
+ const imports = importGraph.get(filePath) || new Set();
140
+ for (const imp of imports) {
141
+ // Skip bare specifiers (external packages)
142
+ if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
143
+
144
+ try {
145
+ const resolved = await build.resolve(imp, {
146
+ kind: 'import-statement',
147
+ resolveDir: dirname(filePath)
148
+ });
149
+ if (resolved.errors.length > 0) continue;
150
+
151
+ const depType = componentTypes.get(resolved.path);
152
+
153
+ // Check for Client → Server violation
154
+ if (depType === 'server') {
155
+ console.error(createImportViolationError(filePath, resolved.path, imp));
156
+ }
157
+ } catch {
158
+ // Ignore resolution errors (might be external packages)
159
+ continue;
160
+ }
161
+ }
162
+ }
163
+
164
+ // Skip manifest if no Client Components detected
165
+ if (clientComponents.size === 0) return;
166
+
167
+ // Map output files to components (best-effort via metafile)
168
+ if (result.metafile) {
169
+ for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
170
+ // Check entryPoint match
171
+ if (meta.entryPoint) {
172
+ for (const [componentId, info] of clientComponents) {
173
+ if (meta.entryPoint === info.file) {
174
+ info.chunk = outputPath;
175
+ if (!config.quiet) {
176
+ console.log(`[Pulse Server Components] Mapped ${componentId} → ${outputPath}`);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ // Check inputs match (for code-split chunks)
183
+ if (meta.inputs) {
184
+ for (const [componentId, info] of clientComponents) {
185
+ if (!info.chunk && meta.inputs[info.file]) {
186
+ info.chunk = outputPath;
187
+ if (!config.quiet) {
188
+ console.log(`[Pulse Server Components] Mapped ${componentId} → ${outputPath}`);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Build and write manifest
197
+ const manifest = buildManifest(clientComponents, config);
198
+ writeManifestToDisk(manifest, config);
199
+
200
+ if (!config.quiet) {
201
+ console.log(`[Pulse Server Components] Generated client manifest with ${clientComponents.size} components`);
202
+ }
203
+ });
204
+ }
205
+ };
206
+ }
207
+
208
+ // Re-export shared manifest helpers
209
+ export { loadClientManifest, getComponentChunk, getClientComponentIds } from './shared.js';
@@ -33,36 +33,14 @@
33
33
 
34
34
  import { compile } from '../compiler/index.js';
35
35
  import {
36
- preprocessStylesSync,
37
- isSassAvailable,
38
- isLessAvailable,
39
- isStylusAvailable,
40
- getSassVersion,
41
- getLessVersion,
42
- getStylusVersion,
43
- detectPreprocessor
44
- } from '../compiler/preprocessor.js';
45
- import { dirname } from 'path';
36
+ logPreprocessorAvailability,
37
+ extractCssFromOutput,
38
+ removeInlineStyles,
39
+ processStyles,
40
+ getPreprocessorOptions
41
+ } from './shared.js';
46
42
  import { readFileSync, writeFileSync, mkdirSync } from 'fs';
47
- import { resolve } from 'path';
48
-
49
- // Cache for preprocessor availability checks
50
- let preprocessorCache = null;
51
-
52
- /**
53
- * Check available preprocessors once
54
- */
55
- function checkPreprocessors() {
56
- if (preprocessorCache) return preprocessorCache;
57
-
58
- preprocessorCache = {
59
- sass: isSassAvailable(),
60
- less: isLessAvailable(),
61
- stylus: isStylusAvailable()
62
- };
63
-
64
- return preprocessorCache;
65
- }
43
+ import { resolve, dirname } from 'path';
66
44
 
67
45
  /**
68
46
  * Create Pulse ESBuild plugin
@@ -71,6 +49,7 @@ export default function pulsePlugin(options = {}) {
71
49
  const {
72
50
  sourceMap = true,
73
51
  extractCss = null, // Path to output CSS file, or null for inline
52
+ quiet = false,
74
53
  sass: sassOptions = {},
75
54
  less: lessOptions = {},
76
55
  stylus: stylusOptions = {}
@@ -86,23 +65,9 @@ export default function pulsePlugin(options = {}) {
86
65
  setup(build) {
87
66
  // Log preprocessor availability on first setup
88
67
  if (!buildStarted) {
89
- const available = checkPreprocessors();
90
- const preprocessors = [];
91
-
92
- if (available.sass) {
93
- preprocessors.push(`SASS ${getSassVersion() || 'unknown'}`);
94
- }
95
- if (available.less) {
96
- preprocessors.push(`LESS ${getLessVersion() || 'unknown'}`);
68
+ if (!quiet) {
69
+ logPreprocessorAvailability('Pulse ESBuild');
97
70
  }
98
- if (available.stylus) {
99
- preprocessors.push(`Stylus ${getStylusVersion() || 'unknown'}`);
100
- }
101
-
102
- if (preprocessors.length > 0) {
103
- console.log(`[Pulse ESBuild] Preprocessor support: ${preprocessors.join(', ')}`);
104
- }
105
-
106
71
  buildStarted = true;
107
72
  }
108
73
 
@@ -140,61 +105,38 @@ export default function pulsePlugin(options = {}) {
140
105
  let outputCode = result.code;
141
106
 
142
107
  // Extract CSS from compiled output
143
- const stylesMatch = outputCode.match(/const styles = `([\s\S]*?)`;/);
144
-
145
- if (stylesMatch) {
146
- let css = stylesMatch[1];
147
-
148
- // Check available preprocessors
149
- const available = checkPreprocessors();
150
- const preprocessor = detectPreprocessor(css);
151
-
152
- // Preprocess if preprocessor detected and available
153
- if (preprocessor !== 'none' && available[preprocessor]) {
154
- try {
155
- const preprocessorOptions = {
156
- sass: sassOptions,
157
- less: lessOptions,
158
- stylus: stylusOptions
159
- }[preprocessor];
160
-
161
- const preprocessed = preprocessStylesSync(css, {
162
- filename: args.path,
163
- loadPaths: [dirname(args.path), ...(preprocessorOptions.loadPaths || [])],
164
- compressed: preprocessorOptions.compressed || false,
165
- preprocessor // Force detected preprocessor
166
- });
167
-
168
- css = preprocessed.css;
169
-
170
- // Log preprocessor usage in verbose mode
171
- if (preprocessorOptions.verbose) {
172
- console.log(`[Pulse] Compiled ${preprocessor.toUpperCase()} in ${args.path}`);
173
- }
174
- } catch (preprocessorError) {
175
- // Emit warning but continue with original CSS
176
- return {
177
- warnings: [{
178
- text: `${preprocessor.toUpperCase()} compilation warning: ${preprocessorError.message}`,
179
- location: { file: args.path }
180
- }],
181
- contents: outputCode,
182
- loader: 'js'
183
- };
108
+ const { css: extractedCss, found } = extractCssFromOutput(outputCode);
109
+
110
+ if (found) {
111
+ const styleResult = processStyles(extractedCss, args.path, { sassOptions, lessOptions, stylusOptions });
112
+
113
+ // Log preprocessor usage in verbose mode
114
+ if (styleResult.preprocessor && styleResult.preprocessor !== 'none') {
115
+ const opts = getPreprocessorOptions(styleResult.preprocessor, { sassOptions, lessOptions, stylusOptions });
116
+ if (opts && opts.verbose) {
117
+ console.log(`[Pulse] Compiled ${styleResult.preprocessor.toUpperCase()} in ${args.path}`);
184
118
  }
185
119
  }
186
120
 
187
121
  if (extractCss) {
188
122
  // Accumulate CSS for later emission
189
- accumulatedCss += `/* ${args.path} */\n${css}\n\n`;
123
+ accumulatedCss += `/* ${args.path} */\n${styleResult.css}\n\n`;
190
124
 
191
125
  // Remove inline CSS injection from output
192
- outputCode = outputCode.replace(
193
- /\/\/ Styles\nconst styles = `[\s\S]*?`;\n\/\/ Inject styles\nconst styleEl = document\.createElement\("style"\);\nstyleEl\.textContent = styles;\ndocument\.head\.appendChild\(styleEl\);/,
194
- '// Styles extracted to CSS file'
195
- );
126
+ outputCode = removeInlineStyles(outputCode, '// Styles extracted to CSS file');
196
127
  }
197
128
  // else: keep inline CSS injection
129
+
130
+ if (styleResult.warning) {
131
+ return {
132
+ warnings: [{
133
+ text: styleResult.warning,
134
+ location: { file: args.path }
135
+ }],
136
+ contents: outputCode,
137
+ loader: 'js'
138
+ };
139
+ }
198
140
  }
199
141
 
200
142
  return {
@@ -225,14 +167,20 @@ export default function pulsePlugin(options = {}) {
225
167
 
226
168
  // Write CSS file
227
169
  writeFileSync(cssPath, accumulatedCss, 'utf8');
228
- console.log(`[Pulse] Emitted CSS to ${extractCss}`);
170
+ if (!quiet) {
171
+ console.log(`[Pulse] Emitted CSS to ${extractCss}`);
172
+ }
229
173
  } catch (writeError) {
230
174
  console.error(`[Pulse] Failed to write CSS file: ${writeError.message}`);
231
175
  }
232
176
  }
233
177
  });
234
178
 
235
- // Resolve .pulse imports as .js if needed
179
+ /**
180
+ * Resolve `.js` imports to `.pulse` files when a corresponding `.pulse`
181
+ * file exists on disk. Allows importing components as `.js` while the
182
+ * source is a `.pulse` file.
183
+ */
236
184
  build.onResolve({ filter: /\.js$/ }, (args) => {
237
185
  // Check if there's a corresponding .pulse file
238
186
  const pulsePath = args.path.replace(/\.js$/, '.pulse');