slicejs-cli 3.4.1 → 3.5.1

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 (47) hide show
  1. package/.github/workflows/ci.yml +43 -0
  2. package/commands/createComponent/createComponent.js +6 -2
  3. package/commands/deleteComponent/deleteComponent.js +4 -0
  4. package/commands/doctor/doctor.js +9 -0
  5. package/commands/init/init.js +53 -6
  6. package/commands/utils/bundling/BundleGenerator.js +271 -38
  7. package/package.json +5 -2
  8. package/playwright.config.js +51 -0
  9. package/tests/build-command-integration.test.js +87 -0
  10. package/tests/build-production-e2e.test.js +140 -0
  11. package/tests/builder-edge-cases.test.js +322 -0
  12. package/tests/bundle-generate-e2e.test.js +115 -0
  13. package/tests/bundling-dependency-edges.test.js +127 -0
  14. package/tests/bundling-imports-unit.test.js +267 -0
  15. package/tests/commands-component-crud.test.js +102 -0
  16. package/tests/commands-doctor.test.js +80 -0
  17. package/tests/commands-version-checker.test.js +37 -0
  18. package/tests/component-registry-parse.test.js +1 -1
  19. package/tests/e2e/bundles.spec.js +91 -0
  20. package/tests/e2e/dependency-scenarios.spec.js +56 -0
  21. package/tests/e2e/fixtures/components/Service/FetchManager/FetchManager.js +136 -0
  22. package/tests/e2e/fixtures/components/Service/IndexedDbManager/IndexedDbManager.js +149 -0
  23. package/tests/e2e/fixtures/components/Service/LocalStorageManager/LocalStorageManager.js +45 -0
  24. package/tests/e2e/fixtures/components/Visual/Button/Button.css +106 -0
  25. package/tests/e2e/fixtures/components/Visual/Button/Button.html +5 -0
  26. package/tests/e2e/fixtures/components/Visual/Button/Button.js +158 -0
  27. package/tests/e2e/fixtures/components/Visual/Link/Link.js +33 -0
  28. package/tests/e2e/fixtures/components/Visual/Loading/Loading.css +56 -0
  29. package/tests/e2e/fixtures/components/Visual/Loading/Loading.html +83 -0
  30. package/tests/e2e/fixtures/components/Visual/Loading/Loading.js +164 -0
  31. package/tests/e2e/fixtures/components/Visual/MultiRoute/MultiRoute.js +167 -0
  32. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.css +116 -0
  33. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.html +44 -0
  34. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.js +180 -0
  35. package/tests/e2e/fixtures/components/Visual/NotFound/NotFound.js +20 -0
  36. package/tests/e2e/fixtures/components/Visual/Route/Route.js +181 -0
  37. package/tests/e2e/fixtures/components/registry.json +12 -0
  38. package/tests/e2e/fixtures/vendor-components.mjs +65 -0
  39. package/tests/e2e/navigation.spec.js +44 -0
  40. package/tests/e2e/render.spec.js +34 -0
  41. package/tests/e2e/serve.mjs +264 -0
  42. package/tests/e2e/shared-deps.spec.js +61 -0
  43. package/tests/e2e/unminified.spec.js +33 -0
  44. package/tests/e2e-serve.test.js +148 -0
  45. package/tests/helpers/setup.js +6 -1
  46. package/tests/perf-budget.test.js +86 -0
  47. package/tests/types-generator.test.js +2 -0
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 20
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: pnpm/action-setup@v4
16
+ with:
17
+ version: 11
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: 22
22
+ cache: pnpm
23
+
24
+ - name: Install dependencies
25
+ run: pnpm install --frozen-lockfile
26
+
27
+ - name: Install Playwright browser
28
+ run: pnpm exec playwright install --with-deps chromium
29
+
30
+ # Serialized to avoid resource contention between the build-heavy
31
+ # integration tests under parallel runners.
32
+ - name: Unit & integration tests
33
+ run: node --test --test-concurrency=1
34
+
35
+ - name: Browser E2E tests
36
+ run: pnpm exec playwright test
37
+
38
+ - uses: actions/upload-artifact@v4
39
+ if: ${{ !cancelled() }}
40
+ with:
41
+ name: playwright-report
42
+ path: playwright-report/
43
+ retention-days: 7
@@ -23,6 +23,10 @@ function createComponent(componentName, category) {
23
23
  return false;
24
24
  }
25
25
 
26
+ // Components follow a PascalCase convention: normalize the initial to
27
+ // uppercase so the folder name, registry entry and existence checks agree.
28
+ componentName = componentName.charAt(0).toUpperCase() + componentName.slice(1);
29
+
26
30
  // Validation: Component already exists
27
31
  if(Validations.componentExists(componentName)){
28
32
  Print.error(`Component '${componentName}' already exists in your project`);
@@ -42,8 +46,8 @@ function createComponent(componentName, category) {
42
46
  }
43
47
  category = flagCategory.category;
44
48
 
45
- // Create class name and file name
46
- const className = componentName.charAt(0).toUpperCase() + componentName.slice(1);
49
+ // Create class name and file name (componentName is already PascalCase).
50
+ const className = componentName;
47
51
  const fileName = `${className}.js`;
48
52
  let template;
49
53
 
@@ -21,6 +21,10 @@ function deleteComponent(componentName, category) {
21
21
  return false;
22
22
  }
23
23
 
24
+ // Components follow a PascalCase convention: normalize the initial so the
25
+ // lookup matches the folder name created by `slice component create`.
26
+ componentName = componentName.charAt(0).toUpperCase() + componentName.slice(1);
27
+
24
28
  // Validation: Valid category
25
29
  let flagCategory = Validations.isValidCategory(category);
26
30
 
@@ -259,6 +259,15 @@ async function checkComponents() {
259
259
  /**
260
260
  * Main diagnostic command
261
261
  */
262
+ export {
263
+ checkNodeVersion,
264
+ checkDirectoryStructure,
265
+ checkConfig,
266
+ checkPort,
267
+ checkDependencies,
268
+ checkComponents
269
+ };
270
+
262
271
  export default async function runDiagnostics() {
263
272
  Print.newLine();
264
273
  Print.title('🔍 Running Slice.js Diagnostics...');
@@ -21,6 +21,15 @@ const STARTER_VISUAL_COMPONENTS = [
21
21
  'Route'
22
22
  ];
23
23
 
24
+ // Service components are now also pulled from the registry on init (instead of
25
+ // being vendored in the framework package), so Visual and Service share a single
26
+ // source of truth. Newcomers add more on demand with `slice get <Name>`.
27
+ const STARTER_SERVICE_COMPONENTS = [
28
+ 'FetchManager',
29
+ 'IndexedDbManager',
30
+ 'LocalStorageManager'
31
+ ];
32
+
24
33
  export default async function initializeProject(projectType) {
25
34
  try {
26
35
  const projectRoot = getProjectRoot(import.meta.url);
@@ -101,7 +110,7 @@ export default async function initializeProject(projectType) {
101
110
 
102
111
  if (stat.isDirectory()) {
103
112
  if (item === 'Components') {
104
- // Create Components structure but without copying Visual
113
+ // Create Components structure but without copying Visual or Service
105
114
  await fs.ensureDir(destItemPath);
106
115
 
107
116
  const componentItems = await fs.readdir(srcItemPath);
@@ -109,11 +118,11 @@ export default async function initializeProject(projectType) {
109
118
  const componentItemPath = path.join(srcItemPath, componentItem);
110
119
  const destComponentItemPath = path.join(destItemPath, componentItem);
111
120
 
112
- if (componentItem !== 'Visual') {
113
- // Copy Service and other component types
121
+ if (componentItem !== 'Visual' && componentItem !== 'Service') {
122
+ // Copy AppComponents and other template types from the framework
114
123
  await fs.copy(componentItemPath, destComponentItemPath, { recursive: true });
115
124
  } else {
116
- // Only create empty Visual directory
125
+ // Visual and Service are installed from the registry below
117
126
  await fs.ensureDir(destComponentItemPath);
118
127
  }
119
128
  }
@@ -176,6 +185,42 @@ export default async function initializeProject(projectType) {
176
185
  Print.info('You can add them later using "slice get <component-name>"');
177
186
  }
178
187
 
188
+ // 3b. DOWNLOAD STARTER SERVICE COMPONENTS FROM OFFICIAL REPOSITORY
189
+ const serviceSpinner = ora('Installing starter Service components...').start();
190
+ try {
191
+ const registry = new ComponentRegistry();
192
+ await registry.loadRegistry();
193
+
194
+ if (STARTER_SERVICE_COMPONENTS.length > 0) {
195
+ Print.info(`Installing ${STARTER_SERVICE_COMPONENTS.length} starter Service components: ${STARTER_SERVICE_COMPONENTS.join(', ')}`);
196
+ serviceSpinner.text = `Installing ${STARTER_SERVICE_COMPONENTS.length} starter Service components...`;
197
+
198
+ const results = await registry.installMultipleComponents(
199
+ STARTER_SERVICE_COMPONENTS,
200
+ 'Service',
201
+ true // force = true for initial installation
202
+ );
203
+
204
+ const successful = results.filter(r => r.success).length;
205
+ const failed = results.filter(r => !r.success).length;
206
+
207
+ if (successful > 0 && failed === 0) {
208
+ serviceSpinner.succeed(`All ${successful} Service components installed successfully`);
209
+ } else if (successful > 0) {
210
+ serviceSpinner.warn(`${successful} Service components installed, ${failed} failed`);
211
+ Print.info('You can install failed components later using "slice get <component-name>"');
212
+ } else {
213
+ serviceSpinner.fail('Failed to install Service components');
214
+ }
215
+ } else {
216
+ serviceSpinner.succeed('No starter Service components to install');
217
+ }
218
+ } catch (error) {
219
+ serviceSpinner.fail('Could not download Service components from official repository');
220
+ Print.error(`Repository error: ${error.message}`);
221
+ Print.info('You can add them later using "slice get <component-name>"');
222
+ }
223
+
179
224
  // 4. CONFIGURE SCRIPTS IN PROJECT package.json
180
225
  const pkgSpinner = ora('Configuring npm scripts...').start();
181
226
  try {
@@ -264,6 +309,8 @@ export default async function initializeProject(projectType) {
264
309
  }
265
310
  }
266
311
 
267
- // NOTE: `slice init` now installs only STARTER_VISUAL_COMPONENTS (see top of file).
312
+ // NOTE: `slice init` installs only STARTER_VISUAL_COMPONENTS and
313
+ // STARTER_SERVICE_COMPONENTS (see top of file); both Visual and Service are pulled
314
+ // from the registry rather than vendored in the framework package.
268
315
  // To install every registry component instead, iterate
269
- // `Object.keys(registry.getAvailableComponents('Visual'))`.
316
+ // `Object.keys(registry.getAvailableComponents('Visual'))` (and likewise 'Service').
@@ -1249,7 +1249,11 @@ export default class BundleGenerator {
1249
1249
  const omittedDependencies = options.omittedDependencies instanceof Set
1250
1250
  ? options.omittedDependencies
1251
1251
  : new Set(options.omittedDependencies || []);
1252
- const filteredModules = modules.filter((module) => !omittedDependencies.has(module.name));
1252
+ // Emit in topological order so a module is registered before any module
1253
+ // that depends on it (its transitive imports resolve at IIFE-eval time).
1254
+ const filteredModules = this.sortDependencyModulesTopologically(
1255
+ modules.filter((module) => !omittedDependencies.has(module.name))
1256
+ );
1253
1257
 
1254
1258
  const lines = [
1255
1259
  'const SLICE_BUNDLE_DEPENDENCIES = {};',
@@ -1260,32 +1264,88 @@ export default class BundleGenerator {
1260
1264
  }
1261
1265
  filteredModules.forEach((module, index) => {
1262
1266
  const exportVar = `__sliceDepExports${index}`;
1263
- const transformedContent = this.transformDependencyContent(module.content, exportVar, module.name);
1264
- lines.push(`const ${exportVar} = {};`);
1265
- lines.push(transformedContent.trim());
1267
+ // Evaluate each dependency inside its own IIFE so its private,
1268
+ // non-exported top-level bindings stay local and cannot collide with
1269
+ // another dependency's (or the bundle's) identifiers. Only the exports
1270
+ // object escapes the closure.
1271
+ const transformedContent = this.transformDependencyContent(module.content, '__sliceExports', module.name);
1272
+ // Bind this module's own (transitive) imports inside its IIFE — they were
1273
+ // registered by earlier modules in the topological order.
1274
+ const importBindings = this.buildDependencyBindings(
1275
+ Object.fromEntries(
1276
+ (module.moduleImports || [])
1277
+ .filter((mi) => mi.bindings && mi.bindings.length)
1278
+ .map((mi) => [mi.depName, { bindings: mi.bindings }])
1279
+ ),
1280
+ { preferShared: !!options.includeSharedResolver }
1281
+ );
1282
+ const body = transformedContent.trim();
1283
+ lines.push(`const ${exportVar} = (() => {`);
1284
+ lines.push('const __sliceExports = {};');
1285
+ if (importBindings) lines.push(importBindings);
1286
+ if (body) lines.push(body);
1287
+ lines.push('return __sliceExports;');
1288
+ lines.push('})();');
1266
1289
  lines.push(`SLICE_BUNDLE_DEPENDENCIES[${JSON.stringify(module.name)}] = ${exportVar};`);
1267
1290
  });
1268
1291
 
1269
1292
  return lines.join('\n');
1270
1293
  }
1271
1294
 
1295
+ sortDependencyModulesTopologically(modules = []) {
1296
+ const byName = new Map(modules.map((module) => [module.name, module]));
1297
+ const visited = new Set();
1298
+ const ordered = [];
1299
+ const visit = (module, stack) => {
1300
+ if (visited.has(module.name) || stack.has(module.name)) return;
1301
+ stack.add(module.name);
1302
+ for (const imp of module.moduleImports || []) {
1303
+ const dependency = byName.get(imp.depName);
1304
+ if (dependency) visit(dependency, stack);
1305
+ }
1306
+ stack.delete(module.name);
1307
+ visited.add(module.name);
1308
+ ordered.push(module);
1309
+ };
1310
+ for (const module of modules) visit(module, new Set());
1311
+ return ordered;
1312
+ }
1313
+
1272
1314
  async buildDependencyContents(jsContent, componentPath) {
1273
- const dependencies = this.analyzeDependencies(jsContent, componentPath);
1274
1315
  const dependencyContents = {};
1316
+ const visited = new Set();
1275
1317
 
1276
- for (const dep of dependencies) {
1277
- const depPath = dep.path;
1278
- try {
1279
- const depContent = await fs.readFile(depPath, 'utf-8');
1280
- const depName = path
1281
- .relative(this.srcPath, depPath)
1282
- .replace(/\\/g, '/');
1283
- dependencyContents[depName] = {
1284
- content: depContent,
1285
- bindings: dep.bindings || []
1286
- };
1287
- } catch (error) {
1288
- console.warn(`Warning: Could not read dependency ${depPath}:`, error.message);
1318
+ // Recursively resolve the relative-import graph rooted at `content`, so a
1319
+ // dependency module's OWN (transitive) imports are inlined too. Returns the
1320
+ // consumer's direct imports as [{ depName, bindings }].
1321
+ const resolveModule = async (content, basePath) => {
1322
+ const consumerImports = [];
1323
+
1324
+ for (const dep of this.analyzeDependencies(content, basePath)) {
1325
+ const depName = path.relative(this.srcPath, dep.path).replace(/\\/g, '/');
1326
+ consumerImports.push({ depName, bindings: dep.bindings || [] });
1327
+
1328
+ if (visited.has(depName)) continue;
1329
+ visited.add(depName);
1330
+
1331
+ try {
1332
+ const depContent = await fs.readFile(dep.path, 'utf-8');
1333
+ // Resolve this module's own transitive imports first.
1334
+ const moduleImports = await resolveModule(depContent, path.dirname(dep.path));
1335
+ dependencyContents[depName] = { content: depContent, bindings: [], moduleImports };
1336
+ } catch (error) {
1337
+ console.warn(`Warning: Could not read dependency ${dep.path}:`, error.message);
1338
+ }
1339
+ }
1340
+
1341
+ return consumerImports;
1342
+ };
1343
+
1344
+ const directImports = await resolveModule(jsContent, componentPath);
1345
+ // The component's direct imports drive its class-factory bindings.
1346
+ for (const { depName, bindings } of directImports) {
1347
+ if (dependencyContents[depName]) {
1348
+ dependencyContents[depName].bindings = bindings;
1289
1349
  }
1290
1350
  }
1291
1351
 
@@ -1428,10 +1488,17 @@ if (window.slice && window.slice.controller) {
1428
1488
 
1429
1489
  dependencyModules.forEach((module, index) => {
1430
1490
  const exportVar = `__sliceDepExports${index}`;
1431
- const content = this.transformDependencyContent(module.content, exportVar, module.name);
1491
+ // Each dependency lives in its own IIFE scope (see
1492
+ // buildV2DependencyModuleBlockFromModules) so private helpers cannot
1493
+ // collide across modules.
1494
+ const content = this.transformDependencyContent(module.content, '__sliceExports', module.name);
1495
+ const body = content.trim();
1432
1496
  lines.push(`// Dependency: ${module.name}`);
1433
- lines.push(`const ${exportVar} = {};`);
1434
- lines.push(content.trim());
1497
+ lines.push(`const ${exportVar} = (() => {`);
1498
+ lines.push('const __sliceExports = {};');
1499
+ if (body) lines.push(body);
1500
+ lines.push('return __sliceExports;');
1501
+ lines.push('})();');
1435
1502
  lines.push(`SLICE_BUNDLE_DEPENDENCIES[${JSON.stringify(module.name)}] = ${exportVar};`);
1436
1503
  });
1437
1504
 
@@ -1458,7 +1525,8 @@ if (window.slice && window.slice.controller) {
1458
1525
  if (modules.has(moduleName)) continue;
1459
1526
  const content = typeof entry === 'string' ? entry : entry?.content;
1460
1527
  if (!content) continue;
1461
- modules.set(moduleName, { name: moduleName, content });
1528
+ const moduleImports = (entry && typeof entry === 'object' ? entry.moduleImports : null) || [];
1529
+ modules.set(moduleName, { name: moduleName, content, moduleImports });
1462
1530
  }
1463
1531
  }
1464
1532
  return Array.from(modules.values());
@@ -1469,6 +1537,158 @@ if (window.slice && window.slice.controller) {
1469
1537
  }
1470
1538
 
1471
1539
  transformDependencyContent(content, exportVar, moduleName) {
1540
+ let ast;
1541
+ try {
1542
+ ast = parse(content, { sourceType: 'module', plugins: ['jsx'] });
1543
+ } catch (error) {
1544
+ // Unparseable content (e.g. TS syntax): fall back to the regex transform
1545
+ // so we never lose a dependency entirely.
1546
+ return this.transformDependencyContentRegexFallback(content, exportVar, moduleName);
1547
+ }
1548
+
1549
+ const fallbackKey = this.getDependencyDefaultFallbackKey(moduleName);
1550
+ const statements = ast.program.body
1551
+ .filter((node) => typeof node.start === 'number' && typeof node.end === 'number')
1552
+ .sort((a, b) => a.start - b.start);
1553
+
1554
+ let cursor = 0;
1555
+ let output = '';
1556
+ for (const node of statements) {
1557
+ output += content.slice(cursor, node.start);
1558
+ output += this.transformDependencyStatement(node, content, exportVar, moduleName, fallbackKey);
1559
+ cursor = node.end;
1560
+ }
1561
+ output += content.slice(cursor);
1562
+ return output;
1563
+ }
1564
+
1565
+ describeExportTarget(exportVar, name) {
1566
+ return /^[A-Za-z_$][\w$]*$/.test(name)
1567
+ ? `${exportVar}.${name}`
1568
+ : `${exportVar}[${JSON.stringify(name)}]`;
1569
+ }
1570
+
1571
+ collectPatternIdentifiers(node, acc = []) {
1572
+ if (!node) return acc;
1573
+ switch (node.type) {
1574
+ case 'Identifier':
1575
+ acc.push(node.name);
1576
+ break;
1577
+ case 'ObjectPattern':
1578
+ for (const prop of node.properties) {
1579
+ if (prop.type === 'RestElement') {
1580
+ this.collectPatternIdentifiers(prop.argument, acc);
1581
+ } else {
1582
+ this.collectPatternIdentifiers(prop.value, acc);
1583
+ }
1584
+ }
1585
+ break;
1586
+ case 'ArrayPattern':
1587
+ for (const element of node.elements) {
1588
+ if (element) this.collectPatternIdentifiers(element, acc);
1589
+ }
1590
+ break;
1591
+ case 'AssignmentPattern':
1592
+ this.collectPatternIdentifiers(node.left, acc);
1593
+ break;
1594
+ case 'RestElement':
1595
+ this.collectPatternIdentifiers(node.argument, acc);
1596
+ break;
1597
+ default:
1598
+ break;
1599
+ }
1600
+ return acc;
1601
+ }
1602
+
1603
+ transformExportedDeclaration(decl, content, exportVar) {
1604
+ const sourceOf = (n) => content.slice(n.start, n.end);
1605
+
1606
+ if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
1607
+ const name = decl.id?.name;
1608
+ if (!name) return sourceOf(decl);
1609
+ // Keep the declaration so other code in the module can still reference the
1610
+ // name (intra-module references), then mirror it onto the exports object.
1611
+ // Each dependency is IIFE-scoped, so this local binding can't collide.
1612
+ return `${sourceOf(decl)}\n${this.describeExportTarget(exportVar, name)} = ${name};`;
1613
+ }
1614
+
1615
+ if (decl.type === 'VariableDeclaration') {
1616
+ // Keep the declaration verbatim — preserving intra-module references and
1617
+ // initializer evaluation — then export every bound name.
1618
+ const names = [];
1619
+ for (const declarator of decl.declarations) {
1620
+ names.push(...this.collectPatternIdentifiers(declarator.id, []));
1621
+ }
1622
+ const assigns = names
1623
+ .map((n) => `${this.describeExportTarget(exportVar, n)} = ${n};`)
1624
+ .join('\n');
1625
+ return `${sourceOf(decl)}\n${assigns}`;
1626
+ }
1627
+
1628
+ return sourceOf(decl);
1629
+ }
1630
+
1631
+ transformDependencyStatement(node, content, exportVar, moduleName, fallbackKey) {
1632
+ const sourceOf = (n) => content.slice(n.start, n.end);
1633
+
1634
+ if (node.type === 'ImportDeclaration') {
1635
+ // Transitive imports of a bundled dependency cannot be resolved at
1636
+ // runtime; strip them so they never leak into the emitted bundle.
1637
+ console.warn(this.buildImportWarningMessage(
1638
+ `Warning: Stripping unsupported import inside bundled dependency: ${node.source?.value}`,
1639
+ moduleName
1640
+ ));
1641
+ return '';
1642
+ }
1643
+
1644
+ if (node.type === 'ExportAllDeclaration') {
1645
+ console.warn(this.buildImportWarningMessage(
1646
+ `Warning: Dropping unsupported 'export *' inside bundled dependency: ${node.source?.value}`,
1647
+ moduleName
1648
+ ));
1649
+ return '';
1650
+ }
1651
+
1652
+ if (node.type === 'ExportDefaultDeclaration') {
1653
+ const declSource = sourceOf(node.declaration);
1654
+ const lines = [`${exportVar}.default = (${declSource});`];
1655
+ if (fallbackKey && fallbackKey !== 'default') {
1656
+ // Preserve the historical `<basename>Data` key so existing default
1657
+ // bindings (which pass it as the preferred key) keep resolving.
1658
+ lines.push(`${this.describeExportTarget(exportVar, fallbackKey)} = ${exportVar}.default;`);
1659
+ }
1660
+ return lines.join('\n');
1661
+ }
1662
+
1663
+ if (node.type === 'ExportNamedDeclaration') {
1664
+ if (node.source) {
1665
+ console.warn(this.buildImportWarningMessage(
1666
+ `Warning: Dropping unsupported re-export inside bundled dependency: ${node.source.value}`,
1667
+ moduleName
1668
+ ));
1669
+ return '';
1670
+ }
1671
+
1672
+ if (node.declaration) {
1673
+ return this.transformExportedDeclaration(node.declaration, content, exportVar);
1674
+ }
1675
+
1676
+ // `export { local as exported, ... }` — key the exports object by the
1677
+ // PUBLIC (exported) name, mapped to the local binding's value.
1678
+ return node.specifiers
1679
+ .map((spec) => {
1680
+ const localName = spec.local.name;
1681
+ const exportedName = spec.exported.name ?? spec.exported.value;
1682
+ return `${this.describeExportTarget(exportVar, exportedName)} = ${localName};`;
1683
+ })
1684
+ .join('\n');
1685
+ }
1686
+
1687
+ // Any other top-level statement is kept verbatim.
1688
+ return sourceOf(node);
1689
+ }
1690
+
1691
+ transformDependencyContentRegexFallback(content, exportVar, moduleName) {
1472
1692
  const dataName = this.getDependencyDefaultFallbackKey(moduleName);
1473
1693
  const exportPrefix = dataName ? `${exportVar}.${dataName} = ` : `${exportVar}.default = `;
1474
1694
 
@@ -1594,11 +1814,18 @@ if (window.slice && window.slice.controller) {
1594
1814
  }
1595
1815
 
1596
1816
  toSafeIdentifier(name) {
1597
- const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '_');
1598
- if (/^\d/.test(cleaned)) {
1599
- return `SliceComponent_${cleaned}`;
1600
- }
1601
- return `SliceComponent_${cleaned}`;
1817
+ // Injective encoding: every character outside [A-Za-z0-9] (including '_')
1818
+ // is escaped to `_<hex>_`. This guarantees that two distinct component
1819
+ // names can never collapse to the same identifier (e.g. "my-btn" and
1820
+ // "my_btn" used to both yield "SliceComponent_my_btn", emitting duplicate
1821
+ // `const` declarations and producing invalid bundle JS). The leading
1822
+ // `SliceComponent_` prefix keeps the result a valid identifier even when
1823
+ // the name starts with a digit.
1824
+ const encoded = String(name).replace(
1825
+ /[^a-zA-Z0-9]/g,
1826
+ (char) => `_${char.charCodeAt(0).toString(16)}_`
1827
+ );
1828
+ return `SliceComponent_${encoded}`;
1602
1829
  }
1603
1830
 
1604
1831
  /**
@@ -1633,17 +1860,23 @@ if (window.slice && window.slice.controller) {
1633
1860
  integrity: null,
1634
1861
  components: []
1635
1862
  },
1636
- vendorShared: {
1637
- bundleKey: 'vendor-shared',
1638
- type: 'vendor-shared',
1639
- file: this.vendorShared.file,
1640
- size: this.vendorShared.bundle?.size || 0,
1641
- hash: this.vendorShared.bundle?.hash || null,
1642
- integrity: this.vendorShared.bundle?.integrity || null,
1643
- dependencies: Array.from(this.vendorShared.sharedDependencySet).sort((a, b) => a.localeCompare(b)),
1644
- dependencyCount: this.vendorShared.sharedDependencySet.size,
1645
- routes: Array.from(this.vendorShared.bundleKeysUsingSharedDependencies).sort((a, b) => a.localeCompare(b))
1646
- },
1863
+ // Only advertise the vendor-shared bundle when it was actually emitted
1864
+ // (i.e. there were shared dependencies). Otherwise the config would
1865
+ // reference a file that does not exist on disk -> 404 for any runtime
1866
+ // that resolves it.
1867
+ vendorShared: this.vendorShared.bundle
1868
+ ? {
1869
+ bundleKey: 'vendor-shared',
1870
+ type: 'vendor-shared',
1871
+ file: this.vendorShared.file,
1872
+ size: this.vendorShared.bundle?.size || 0,
1873
+ hash: this.vendorShared.bundle?.hash || null,
1874
+ integrity: this.vendorShared.bundle?.integrity || null,
1875
+ dependencies: Array.from(this.vendorShared.sharedDependencySet).sort((a, b) => a.localeCompare(b)),
1876
+ dependencyCount: this.vendorShared.sharedDependencySet.size,
1877
+ routes: Array.from(this.vendorShared.bundleKeysUsingSharedDependencies).sort((a, b) => a.localeCompare(b))
1878
+ }
1879
+ : null,
1647
1880
  critical: {
1648
1881
  file: this.bundles.critical.file,
1649
1882
  size: this.bundles.critical.size,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-cli",
3
- "version": "3.4.1",
3
+ "version": "3.5.1",
4
4
  "description": "Command client for developing web applications with Slice.js framework",
5
5
  "main": "client.js",
6
6
  "bin": {
@@ -54,5 +54,8 @@
54
54
  "ora": "^8.2.0",
55
55
  "slicejs-web-framework": "latest",
56
56
  "terser": "^5.43.1"
57
+ },
58
+ "devDependencies": {
59
+ "@playwright/test": "^1.60.0"
57
60
  }
58
- }
61
+ }
@@ -0,0 +1,51 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ const PORT = process.env.E2E_PORT ? Number(process.env.E2E_PORT) : 3210;
4
+ const UNMIN_PORT = PORT + 4;
5
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
6
+ const UNMIN_URL = `http://127.0.0.1:${UNMIN_PORT}`;
7
+
8
+ export default defineConfig({
9
+ testDir: './tests/e2e',
10
+ testMatch: '**/*.spec.js',
11
+ fullyParallel: false,
12
+ workers: 1,
13
+ timeout: 30_000,
14
+ expect: { timeout: 10_000 },
15
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
16
+ use: {
17
+ trace: 'retain-on-failure',
18
+ },
19
+ projects: [
20
+ {
21
+ name: 'chromium',
22
+ testIgnore: '**/unminified.spec.js',
23
+ use: { ...devices['Desktop Chrome'], baseURL: BASE_URL },
24
+ },
25
+ {
26
+ name: 'chromium-unminified',
27
+ testMatch: '**/unminified.spec.js',
28
+ use: { ...devices['Desktop Chrome'], baseURL: UNMIN_URL },
29
+ },
30
+ ],
31
+ webServer: [
32
+ {
33
+ command: 'node tests/e2e/serve.mjs',
34
+ url: `${BASE_URL}/slice-env.json`,
35
+ env: { E2E_PORT: String(PORT) },
36
+ timeout: 120_000,
37
+ reuseExistingServer: !process.env.CI,
38
+ stdout: 'pipe',
39
+ stderr: 'pipe',
40
+ },
41
+ {
42
+ command: 'node tests/e2e/serve.mjs',
43
+ url: `${UNMIN_URL}/slice-env.json`,
44
+ env: { E2E_PORT: String(UNMIN_PORT), E2E_MINIFY: 'false' },
45
+ timeout: 120_000,
46
+ reuseExistingServer: !process.env.CI,
47
+ stdout: 'pipe',
48
+ stderr: 'pipe',
49
+ },
50
+ ],
51
+ });