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.
- package/cli/analyze.js +21 -8
- package/cli/build.js +83 -56
- package/cli/dev.js +108 -94
- package/cli/docs-test.js +52 -33
- package/cli/index.js +81 -51
- package/cli/mobile.js +92 -40
- package/cli/release.js +64 -46
- package/cli/scaffold.js +14 -13
- package/compiler/lexer.js +55 -54
- package/compiler/parser/core.js +1 -0
- package/compiler/parser/state.js +6 -12
- package/compiler/parser/style.js +17 -20
- package/compiler/parser/view.js +1 -3
- package/compiler/preprocessor.js +124 -262
- package/compiler/sourcemap.js +10 -4
- package/compiler/transformer/expressions.js +122 -106
- package/compiler/transformer/index.js +2 -4
- package/compiler/transformer/style.js +74 -7
- package/compiler/transformer/view.js +86 -36
- package/loader/esbuild-plugin-server-components.js +209 -0
- package/loader/esbuild-plugin.js +41 -93
- package/loader/parcel-plugin.js +37 -97
- package/loader/rollup-plugin-server-components.js +30 -169
- package/loader/rollup-plugin.js +27 -78
- package/loader/shared.js +362 -0
- package/loader/swc-plugin.js +65 -82
- package/loader/vite-plugin-server-components.js +30 -171
- package/loader/vite-plugin.js +25 -10
- package/loader/webpack-loader-server-components.js +21 -134
- package/loader/webpack-loader.js +25 -80
- package/package.json +52 -12
- package/runtime/dom-selector.js +2 -1
- package/runtime/form.js +4 -3
- package/runtime/http.js +6 -1
- package/runtime/logger.js +44 -24
- package/runtime/router/utils.js +14 -7
- package/runtime/security.js +13 -1
- package/runtime/server-components/actions-server.js +23 -19
- package/runtime/server-components/error-sanitizer.js +18 -18
- package/runtime/server-components/security.js +41 -24
- package/runtime/ssr-preload.js +5 -3
- package/runtime/testing.js +759 -0
- package/runtime/utils.js +3 -2
- package/server/utils.js +15 -9
- package/sw/index.js +2 -0
- package/types/loaders.d.ts +1043 -0
- package/compiler/parser/_extract.js +0 -393
- 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
|
|
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
|
|
578
|
-
const escapedValue = attr.value
|
|
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
|
|
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}, '${
|
|
687
|
+
result = `on(${result}, '${safeEventName}', (event) => { ${handlerCode}; })`;
|
|
639
688
|
} else {
|
|
640
|
-
const modifiedHandler = generateModifiedHandler(
|
|
641
|
-
result = `on(${result}, '${
|
|
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}), '${
|
|
1099
|
+
return `${pad}on(el('div',\n${children}\n${pad}), '${safeEventName}', () => { ${handler}; })`;
|
|
1050
1100
|
}
|
|
1051
1101
|
|
|
1052
|
-
return `/* event: ${
|
|
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';
|
package/loader/esbuild-plugin.js
CHANGED
|
@@ -33,36 +33,14 @@
|
|
|
33
33
|
|
|
34
34
|
import { compile } from '../compiler/index.js';
|
|
35
35
|
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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');
|