slicejs-cli 3.5.0 → 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.
- package/.github/workflows/ci.yml +43 -0
- package/commands/createComponent/createComponent.js +6 -2
- package/commands/deleteComponent/deleteComponent.js +4 -0
- package/commands/doctor/doctor.js +9 -0
- package/commands/utils/bundling/BundleGenerator.js +271 -38
- package/package.json +4 -1
- package/playwright.config.js +51 -0
- package/tests/build-command-integration.test.js +87 -0
- package/tests/build-production-e2e.test.js +140 -0
- package/tests/builder-edge-cases.test.js +322 -0
- package/tests/bundle-generate-e2e.test.js +115 -0
- package/tests/bundling-dependency-edges.test.js +127 -0
- package/tests/bundling-imports-unit.test.js +267 -0
- package/tests/commands-component-crud.test.js +102 -0
- package/tests/commands-doctor.test.js +80 -0
- package/tests/commands-version-checker.test.js +37 -0
- package/tests/component-registry-parse.test.js +1 -1
- package/tests/e2e/bundles.spec.js +91 -0
- package/tests/e2e/dependency-scenarios.spec.js +56 -0
- package/tests/e2e/fixtures/components/Service/FetchManager/FetchManager.js +136 -0
- package/tests/e2e/fixtures/components/Service/IndexedDbManager/IndexedDbManager.js +149 -0
- package/tests/e2e/fixtures/components/Service/LocalStorageManager/LocalStorageManager.js +45 -0
- package/tests/e2e/fixtures/components/Visual/Button/Button.css +106 -0
- package/tests/e2e/fixtures/components/Visual/Button/Button.html +5 -0
- package/tests/e2e/fixtures/components/Visual/Button/Button.js +158 -0
- package/tests/e2e/fixtures/components/Visual/Link/Link.js +33 -0
- package/tests/e2e/fixtures/components/Visual/Loading/Loading.css +56 -0
- package/tests/e2e/fixtures/components/Visual/Loading/Loading.html +83 -0
- package/tests/e2e/fixtures/components/Visual/Loading/Loading.js +164 -0
- package/tests/e2e/fixtures/components/Visual/MultiRoute/MultiRoute.js +167 -0
- package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.css +116 -0
- package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.html +44 -0
- package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.js +180 -0
- package/tests/e2e/fixtures/components/Visual/NotFound/NotFound.js +20 -0
- package/tests/e2e/fixtures/components/Visual/Route/Route.js +181 -0
- package/tests/e2e/fixtures/components/registry.json +12 -0
- package/tests/e2e/fixtures/vendor-components.mjs +65 -0
- package/tests/e2e/navigation.spec.js +44 -0
- package/tests/e2e/render.spec.js +34 -0
- package/tests/e2e/serve.mjs +264 -0
- package/tests/e2e/shared-deps.spec.js +61 -0
- package/tests/e2e/unminified.spec.js +33 -0
- package/tests/e2e-serve.test.js +148 -0
- package/tests/helpers/setup.js +6 -1
- package/tests/perf-budget.test.js +86 -0
- 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
|
|
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...');
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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.5.
|
|
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
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import { withTestProject } from './helpers/setup.js';
|
|
7
|
+
import build from '../commands/build/build.js';
|
|
8
|
+
import { cleanBundles, bundleInfo } from '../commands/bundle/bundle.js';
|
|
9
|
+
|
|
10
|
+
const MODULE_URL = import.meta.url;
|
|
11
|
+
|
|
12
|
+
describe('slice build (full pipeline: buildProduction + bundle -> dist)', () => {
|
|
13
|
+
test('produces a dist/ with parseable bundles', async () => {
|
|
14
|
+
await withTestProject(async (root) => {
|
|
15
|
+
const ok = await build({ minify: false, obfuscate: false });
|
|
16
|
+
assert.equal(ok, true);
|
|
17
|
+
|
|
18
|
+
const dist = path.join(root, 'dist');
|
|
19
|
+
assert.ok(await fs.pathExists(path.join(dist, 'App', 'index.js')));
|
|
20
|
+
|
|
21
|
+
const bundlesDir = path.join(dist, 'bundles');
|
|
22
|
+
assert.ok(await fs.pathExists(bundlesDir), 'dist/bundles should exist');
|
|
23
|
+
|
|
24
|
+
const bundleFiles = (await fs.readdir(bundlesDir)).filter(
|
|
25
|
+
(f) => f.startsWith('slice-bundle.') && f.endsWith('.js')
|
|
26
|
+
);
|
|
27
|
+
assert.ok(bundleFiles.length > 0, 'at least one bundle in dist/bundles');
|
|
28
|
+
|
|
29
|
+
for (const f of bundleFiles) {
|
|
30
|
+
const code = await fs.readFile(path.join(bundlesDir, f), 'utf8');
|
|
31
|
+
assert.doesNotThrow(
|
|
32
|
+
() => parse(code, { sourceType: 'module', plugins: ['jsx'] }),
|
|
33
|
+
`dist bundle ${f} is not valid JS`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('bundle clean / info subcommands', () => {
|
|
41
|
+
test('cleanBundles removes slice-bundle.* files and the config', async () => {
|
|
42
|
+
await withTestProject(async (root) => {
|
|
43
|
+
const src = path.join(root, 'src');
|
|
44
|
+
await fs.writeFile(path.join(src, 'slice-bundle.critical.js'), '// x');
|
|
45
|
+
await fs.writeFile(path.join(src, 'slice-bundle.home.js'), '// x');
|
|
46
|
+
await fs.writeFile(path.join(src, 'bundle.config.json'), '{}');
|
|
47
|
+
|
|
48
|
+
await cleanBundles();
|
|
49
|
+
|
|
50
|
+
assert.equal(await fs.pathExists(path.join(src, 'slice-bundle.critical.js')), false);
|
|
51
|
+
assert.equal(await fs.pathExists(path.join(src, 'slice-bundle.home.js')), false);
|
|
52
|
+
assert.equal(await fs.pathExists(path.join(src, 'bundle.config.json')), false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('cleanBundles is a no-op (no throw) when there are no bundles', async () => {
|
|
57
|
+
await withTestProject(async () => {
|
|
58
|
+
await assert.doesNotReject(() => cleanBundles());
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('bundleInfo does not throw when a config exists', async () => {
|
|
63
|
+
await withTestProject(async (root) => {
|
|
64
|
+
const cfg = {
|
|
65
|
+
version: '2.0.0',
|
|
66
|
+
strategy: 'hybrid',
|
|
67
|
+
generated: '2025-01-01T00:00:00.000Z',
|
|
68
|
+
stats: {
|
|
69
|
+
totalComponents: 3,
|
|
70
|
+
totalRoutes: 2,
|
|
71
|
+
sharedComponents: 1,
|
|
72
|
+
sharedPercentage: 33,
|
|
73
|
+
totalSize: 2048,
|
|
74
|
+
},
|
|
75
|
+
bundles: { critical: { components: ['A'], size: 1024 }, routes: {} },
|
|
76
|
+
};
|
|
77
|
+
await fs.writeJson(path.join(root, 'src', 'bundle.config.json'), cfg);
|
|
78
|
+
await assert.doesNotReject(() => bundleInfo());
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('bundleInfo warns (no throw) when config is missing', async () => {
|
|
83
|
+
await withTestProject(async () => {
|
|
84
|
+
await assert.doesNotReject(() => bundleInfo());
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|