slicejs-cli 3.0.2 → 3.0.3

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.
@@ -953,11 +953,14 @@ export default class BundleGenerator {
953
953
  cssContent = await fs.readFile(cssPath, 'utf-8');
954
954
  }
955
955
 
956
+ const cleanedJavaScript = this.cleanJavaScript(jsContent, comp.name, jsPath);
957
+
956
958
  bundleComponents.push({
957
959
  name: comp.name,
958
960
  category: comp.category,
959
961
  categoryType: comp.categoryType,
960
- js: this.cleanJavaScript(jsContent, comp.name),
962
+ js: cleanedJavaScript.code,
963
+ hoistedImports: cleanedJavaScript.hoistedImports,
961
964
  html: htmlContent,
962
965
  css: cssContent,
963
966
  externalDependencies: await this.buildDependencyContents(jsContent, comp.path),
@@ -988,7 +991,22 @@ export default class BundleGenerator {
988
991
  ? 'framework'
989
992
  : this.routeToFileName(routePath || fileName.replace('slice-bundle.', '').replace('.js', ''));
990
993
 
994
+ const dependencyModules = this.collectDependencyModulesFromComponents(uniqueComponents);
991
995
  const dependencyModuleBlock = this.buildV2DependencyModuleBlock(uniqueComponents);
996
+ const rawHoistedImports = uniqueComponents
997
+ .flatMap((component) => component.hoistedImports || [])
998
+ .map((statement) => String(statement).trim())
999
+ .filter(Boolean);
1000
+ const reservedIdentifiers = new Set([
1001
+ 'SLICE_BUNDLE_META',
1002
+ 'SLICE_BUNDLE_DEPENDENCIES',
1003
+ ...uniqueComponents.map((component) => this.classFactoryName(component.name)),
1004
+ ...uniqueComponents.map((component) => `__templateElement_${this.toSafeIdentifier(component.name)}`),
1005
+ ...this.getDependencyExportVariableNames(dependencyModules)
1006
+ ]);
1007
+ this.validateHoistedImportCollisions(rawHoistedImports, reservedIdentifiers);
1008
+ const hoistedImports = Array.from(new Set(rawHoistedImports));
1009
+ const hoistedImportBlock = hoistedImports.join('\n');
992
1010
 
993
1011
  const classFactoryDefinitions = uniqueComponents
994
1012
  .map((component) => {
@@ -1052,23 +1070,14 @@ export default class BundleGenerator {
1052
1070
  componentCount: uniqueComponents.length
1053
1071
  };
1054
1072
 
1055
- return `export const SLICE_BUNDLE_META = ${JSON.stringify(metadata, null, 2)};\n\n${dependencyModuleBlock}\n\n${classFactoryDefinitions}\n\n${templateDeclarations}\n\nexport async function registerAll(controller, stylesManager) {\n${classRegistrations}\n${templateRegistrations}\n${cssRegistrationInit}${cssRegistrationInit ? '\n' : ''}${cssRegistrations}\n${categoryRegistrations}\n}\n`;
1073
+ return `${hoistedImportBlock}${hoistedImportBlock ? '\n\n' : ''}export const SLICE_BUNDLE_META = ${JSON.stringify(metadata, null, 2)};\n\n${dependencyModuleBlock}\n\n${classFactoryDefinitions}\n\n${templateDeclarations}\n\nexport async function registerAll(controller, stylesManager) {\n${classRegistrations}\n${templateRegistrations}\n${cssRegistrationInit}${cssRegistrationInit ? '\n' : ''}${cssRegistrations}\n${categoryRegistrations}\n}\n`;
1056
1074
  }
1057
1075
 
1058
1076
  buildV2DependencyModuleBlock(components) {
1059
- const modules = new Map();
1060
- for (const component of components || []) {
1061
- const externalDependencies = component.externalDependencies || {};
1062
- for (const [moduleName, entry] of Object.entries(externalDependencies)) {
1063
- if (modules.has(moduleName)) continue;
1064
- const content = typeof entry === 'string' ? entry : entry?.content;
1065
- if (!content) continue;
1066
- modules.set(moduleName, { name: moduleName, content });
1067
- }
1068
- }
1077
+ const modules = this.collectDependencyModulesFromComponents(components);
1069
1078
 
1070
1079
  const lines = ['const SLICE_BUNDLE_DEPENDENCIES = {};'];
1071
- Array.from(modules.values()).forEach((module, index) => {
1080
+ modules.forEach((module, index) => {
1072
1081
  const exportVar = `__sliceDepExports${index}`;
1073
1082
  const transformedContent = this.transformDependencyContent(module.content, exportVar, module.name);
1074
1083
  lines.push(`const ${exportVar} = {};`);
@@ -1105,12 +1114,17 @@ export default class BundleGenerator {
1105
1114
  /**
1106
1115
  * Cleans JavaScript code by removing imports/exports and ensuring class is available globally
1107
1116
  */
1108
- cleanJavaScript(code, componentName) {
1117
+ cleanJavaScript(code, componentName, sourceContext = componentName) {
1109
1118
  // Remove export default
1110
1119
  code = code.replace(/export\s+default\s+/g, '');
1111
1120
 
1112
- // Remove imports (components will already be available)
1113
- code = code.replace(/import\s+.*?from\s+['"].*?['"];?\s*/g, '');
1121
+ // Remove only unsupported imports (relative always removed, allowed absolute kept)
1122
+ const stripped = this.stripImports(code, {
1123
+ sourceContext,
1124
+ collectHoistedImports: true
1125
+ });
1126
+ const hoistedImports = stripped.hoistedImports || [];
1127
+ code = stripped.code;
1114
1128
 
1115
1129
  // Guard customElements.define to avoid duplicate registrations
1116
1130
  code = code.replace(
@@ -1144,7 +1158,10 @@ export default class BundleGenerator {
1144
1158
  // Add return statement for bundle evaluation compatibility
1145
1159
  code += `\nreturn ${componentName};`;
1146
1160
 
1147
- return code;
1161
+ return {
1162
+ code,
1163
+ hoistedImports
1164
+ };
1148
1165
  }
1149
1166
 
1150
1167
  /**
@@ -1173,10 +1190,28 @@ export default class BundleGenerator {
1173
1190
  .update(JSON.stringify(integrityPayload))
1174
1191
  .digest('hex')}`;
1175
1192
 
1193
+ const dependencyModules = this.collectDependencyModules(componentsData);
1194
+ const frameworkComponentKeys = Object.keys(componentsData || {});
1195
+ const frameworkClassIdentifiers = frameworkComponentKeys.map((key) => this.toSafeIdentifier(key));
1196
+ const frameworkReservedIdentifiers = new Set([
1197
+ 'SLICE_BUNDLE',
1198
+ 'SLICE_BUNDLE_COMPONENTS',
1199
+ 'SLICE_BUNDLE_DEPENDENCIES',
1200
+ 'SLICE_FRAMEWORK_CLASSES',
1201
+ ...frameworkClassIdentifiers,
1202
+ ...this.getDependencyExportVariableNames(dependencyModules)
1203
+ ]);
1204
+ const rawHoistedImports = Object.values(componentsData || {})
1205
+ .flatMap((component) => component?.hoistedImports || [])
1206
+ .map((statement) => String(statement).trim())
1207
+ .filter(Boolean);
1208
+ this.validateHoistedImportCollisions(rawHoistedImports, frameworkReservedIdentifiers);
1209
+ const hoistedImportBlock = Array.from(new Set(rawHoistedImports)).join('\n');
1210
+
1176
1211
  const dependencyBlock = this.buildDependencyModuleBlock(componentsData);
1177
1212
  const componentBlock = this.buildComponentBundleBlock(componentsData);
1178
1213
 
1179
- return `/**
1214
+ return `${hoistedImportBlock}${hoistedImportBlock ? '\n\n' : ''}/**
1180
1215
  * Slice.js Bundle
1181
1216
  * Type: ${metadata.type}
1182
1217
  * Generated: ${metadata.generated}
@@ -1231,6 +1266,24 @@ if (window.slice && window.slice.controller) {
1231
1266
  return Array.from(modules.values());
1232
1267
  }
1233
1268
 
1269
+ collectDependencyModulesFromComponents(components = []) {
1270
+ const modules = new Map();
1271
+ for (const component of components || []) {
1272
+ const externalDependencies = component.externalDependencies || {};
1273
+ for (const [moduleName, entry] of Object.entries(externalDependencies)) {
1274
+ if (modules.has(moduleName)) continue;
1275
+ const content = typeof entry === 'string' ? entry : entry?.content;
1276
+ if (!content) continue;
1277
+ modules.set(moduleName, { name: moduleName, content });
1278
+ }
1279
+ }
1280
+ return Array.from(modules.values());
1281
+ }
1282
+
1283
+ getDependencyExportVariableNames(dependencyModules = []) {
1284
+ return (dependencyModules || []).map((_, index) => `__sliceDepExports${index}`);
1285
+ }
1286
+
1234
1287
  transformDependencyContent(content, exportVar, moduleName) {
1235
1288
  const baseName = moduleName.split('/').pop().replace(/\.[^.]+$/, '');
1236
1289
  const dataName = baseName ? `${baseName}Data` : null;
@@ -1450,12 +1503,15 @@ if (window.slice && window.slice.controller) {
1450
1503
  const jsPath = path.join(comp.path, `${fileBaseName}.js`);
1451
1504
  const jsContent = fs.readFileSync(jsPath, 'utf-8');
1452
1505
  const dependencyContents = this.buildDependencyContentsSync(jsContent, comp.path);
1506
+ const cleanedJavaScript = this.cleanJavaScript(jsContent, comp.name, jsPath);
1507
+
1453
1508
  componentsData[componentKey] = {
1454
1509
  name: comp.name,
1455
1510
  category: comp.category,
1456
1511
  categoryType: comp.categoryType,
1457
1512
  isFramework: true,
1458
- js: this.cleanJavaScript(jsContent, comp.name),
1513
+ js: cleanedJavaScript.code,
1514
+ hoistedImports: cleanedJavaScript.hoistedImports,
1459
1515
  externalDependencies: dependencyContents,
1460
1516
  componentDependencies: Array.from(comp.dependencies),
1461
1517
  html: fs.existsSync(path.join(comp.path, `${fileBaseName}.html`))
@@ -1511,8 +1567,293 @@ if (window.slice && window.slice.controller) {
1511
1567
  return dependencyContents;
1512
1568
  }
1513
1569
 
1514
- stripImports(code) {
1515
- return code.replace(/import\s+.*?from\s+['"].*?['"];?\s*/g, '');
1570
+ getConfiguredPublicFolders() {
1571
+ const publicFolders = Array.isArray(this.sliceConfig?.publicFolders)
1572
+ ? this.sliceConfig.publicFolders
1573
+ : [];
1574
+
1575
+ return publicFolders
1576
+ .map((folder) => this.normalizePublicFolder(folder))
1577
+ .filter(Boolean);
1578
+ }
1579
+
1580
+ normalizePublicFolder(folder) {
1581
+ if (typeof folder !== 'string') return null;
1582
+ let normalized = folder.trim();
1583
+ if (!normalized) return null;
1584
+
1585
+ if (!normalized.startsWith('/')) {
1586
+ normalized = `/${normalized}`;
1587
+ }
1588
+
1589
+ normalized = normalized.replace(/\\+/g, '/').replace(/\/+/g, '/');
1590
+ if (normalized.length > 1 && normalized.endsWith('/')) {
1591
+ normalized = normalized.slice(0, -1);
1592
+ }
1593
+
1594
+ return normalized;
1595
+ }
1596
+
1597
+ normalizeImportPath(importPath) {
1598
+ if (typeof importPath !== 'string') return '';
1599
+ const cleanPath = importPath.split(/[?#]/)[0];
1600
+ return cleanPath.replace(/\\+/g, '/').replace(/\/+/g, '/');
1601
+ }
1602
+
1603
+ isRelativeImport(importPath) {
1604
+ return importPath.startsWith('./') || importPath.startsWith('../');
1605
+ }
1606
+
1607
+ isAbsoluteImport(importPath) {
1608
+ return importPath.startsWith('/');
1609
+ }
1610
+
1611
+ isImportInPublicFolders(importPath, publicFolders) {
1612
+ const normalizedImport = this.normalizeImportPath(importPath);
1613
+ return publicFolders.some((folder) => normalizedImport === folder || normalizedImport.startsWith(`${folder}/`));
1614
+ }
1615
+
1616
+ classifyImport(importPath, publicFolders) {
1617
+ if (typeof importPath !== 'string' || !importPath) {
1618
+ return { keep: false, warning: 'Warning: Removing bare import: <unknown>' };
1619
+ }
1620
+
1621
+ if (this.isRelativeImport(importPath)) {
1622
+ return { keep: false, warning: null };
1623
+ }
1624
+
1625
+ if (this.isAbsoluteImport(importPath)) {
1626
+ if (this.isImportInPublicFolders(importPath, publicFolders)) {
1627
+ return { keep: true, warning: null };
1628
+ }
1629
+
1630
+ return {
1631
+ keep: false,
1632
+ warning: `Warning: Removing absolute import outside publicFolders: ${importPath}`
1633
+ };
1634
+ }
1635
+
1636
+ return {
1637
+ keep: false,
1638
+ warning: `Warning: Removing bare import: ${importPath}`
1639
+ };
1640
+ }
1641
+
1642
+ buildImportWarningMessage(baseMessage, sourceContext) {
1643
+ if (!sourceContext) return baseMessage;
1644
+ return `${baseMessage} [${sourceContext}]`;
1645
+ }
1646
+
1647
+ extractLocalBindingsFromImportStatement(statement) {
1648
+ const source = String(statement || '').trim();
1649
+ if (!source.startsWith('import ')) return [];
1650
+ if (/^import\s+['"][^'"]+['"]\s*;?$/.test(source)) return [];
1651
+
1652
+ const bindings = [];
1653
+
1654
+ const defaultMatch = source.match(/^import\s+([A-Za-z_$][\w$]*)\s*(,|\s+from\s+)/);
1655
+ if (defaultMatch && defaultMatch[1] !== '*') {
1656
+ bindings.push(defaultMatch[1]);
1657
+ }
1658
+
1659
+ const namespaceMatch = source.match(/,?\s*\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]/);
1660
+ if (namespaceMatch) {
1661
+ bindings.push(namespaceMatch[1]);
1662
+ }
1663
+
1664
+ const namedMatch = source.match(/\{([\s\S]*?)\}\s*from\s*['"]/);
1665
+ if (namedMatch) {
1666
+ const namedSection = namedMatch[1];
1667
+ for (const part of namedSection.split(',')) {
1668
+ const cleanPart = part.trim();
1669
+ if (!cleanPart) continue;
1670
+ const aliasParts = cleanPart.split(/\s+as\s+/i).map((v) => v.trim()).filter(Boolean);
1671
+ const localName = aliasParts.length > 1 ? aliasParts[1] : aliasParts[0];
1672
+ if (/^[A-Za-z_$][\w$]*$/.test(localName)) {
1673
+ bindings.push(localName);
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ return Array.from(new Set(bindings));
1679
+ }
1680
+
1681
+ validateHoistedImportCollisions(importStatements, reservedIdentifiers = new Set()) {
1682
+ const reserved = reservedIdentifiers instanceof Set
1683
+ ? reservedIdentifiers
1684
+ : new Set(reservedIdentifiers || []);
1685
+ const bindingToStatement = new Map();
1686
+
1687
+ for (const statement of importStatements || []) {
1688
+ const normalizedStatement = String(statement || '').trim();
1689
+ if (!normalizedStatement) continue;
1690
+ const localBindings = this.extractLocalBindingsFromImportStatement(normalizedStatement);
1691
+
1692
+ for (const localBinding of localBindings) {
1693
+ if (reserved.has(localBinding)) {
1694
+ throw new Error(`Hoisted import reserved identifier collision: ${localBinding}`);
1695
+ }
1696
+ const previousStatement = bindingToStatement.get(localBinding);
1697
+ if (previousStatement && previousStatement !== normalizedStatement) {
1698
+ throw new Error(`Hoisted import binding collision: ${localBinding}`);
1699
+ }
1700
+ bindingToStatement.set(localBinding, normalizedStatement);
1701
+ }
1702
+ }
1703
+ }
1704
+
1705
+ parseImportsFromCode(code) {
1706
+ const ast = parse(code, {
1707
+ sourceType: 'module',
1708
+ plugins: ['jsx']
1709
+ });
1710
+
1711
+ const importNodes = [];
1712
+ traverse.default(ast, {
1713
+ ImportDeclaration(pathNode) {
1714
+ importNodes.push(pathNode.node);
1715
+ }
1716
+ });
1717
+
1718
+ return importNodes
1719
+ .filter((node) => typeof node.start === 'number' && typeof node.end === 'number')
1720
+ .sort((a, b) => a.start - b.start);
1721
+ }
1722
+
1723
+ parseImportsWithFallbackScanner(code) {
1724
+ const entries = [];
1725
+ const importRegex = /\bimport\b/g;
1726
+ let match = null;
1727
+
1728
+ while ((match = importRegex.exec(code)) !== null) {
1729
+ const start = match.index;
1730
+ const nextChar = code[start + 'import'.length];
1731
+ if (nextChar === '(') {
1732
+ continue;
1733
+ }
1734
+
1735
+ let index = start + 'import'.length;
1736
+ let quote = null;
1737
+ let escaped = false;
1738
+
1739
+ while (index < code.length) {
1740
+ const char = code[index];
1741
+
1742
+ if (quote) {
1743
+ if (escaped) {
1744
+ escaped = false;
1745
+ } else if (char === '\\') {
1746
+ escaped = true;
1747
+ } else if (char === quote) {
1748
+ quote = null;
1749
+ }
1750
+ index += 1;
1751
+ continue;
1752
+ }
1753
+
1754
+ if (char === '\'' || char === '"' || char === '`') {
1755
+ quote = char;
1756
+ index += 1;
1757
+ continue;
1758
+ }
1759
+
1760
+ if (char === ';') {
1761
+ index += 1;
1762
+ break;
1763
+ }
1764
+
1765
+ index += 1;
1766
+ }
1767
+
1768
+ const end = index;
1769
+ const statement = code.slice(start, end);
1770
+ const fromMatch = statement.match(/\bfrom\s+['"]([^'"]+)['"]/);
1771
+ const sideEffectMatch = statement.match(/\bimport\s+['"]([^'"]+)['"]/);
1772
+ const importPath = fromMatch?.[1] || sideEffectMatch?.[1] || null;
1773
+
1774
+ if (!importPath) {
1775
+ continue;
1776
+ }
1777
+
1778
+ entries.push({ start, end, statement, importPath });
1779
+ importRegex.lastIndex = end;
1780
+ }
1781
+
1782
+ return entries;
1783
+ }
1784
+
1785
+ stripImportsWithFallbackRegex(code, publicFolders, sourceContext, collectHoistedImports) {
1786
+ const hoistedImports = [];
1787
+ const importEntries = this.parseImportsWithFallbackScanner(code);
1788
+ if (importEntries.length === 0) {
1789
+ return { code, hoistedImports };
1790
+ }
1791
+
1792
+ let cleanedCode = '';
1793
+ let cursor = 0;
1794
+ for (const entry of importEntries) {
1795
+ const { start, end, statement, importPath } = entry;
1796
+ const classification = this.classifyImport(importPath, publicFolders);
1797
+ cleanedCode += code.slice(cursor, start);
1798
+ if (classification.keep) {
1799
+ if (collectHoistedImports) {
1800
+ hoistedImports.push(statement.trim());
1801
+ } else {
1802
+ cleanedCode += statement;
1803
+ }
1804
+ } else if (classification.warning) {
1805
+ console.warn(this.buildImportWarningMessage(classification.warning, sourceContext));
1806
+ }
1807
+ cursor = end;
1808
+ }
1809
+
1810
+ cleanedCode += code.slice(cursor);
1811
+
1812
+ return { code: cleanedCode, hoistedImports };
1813
+ }
1814
+
1815
+ stripImports(code, options = {}) {
1816
+ const { sourceContext = null, collectHoistedImports = false } = options;
1817
+ const publicFolders = this.getConfiguredPublicFolders();
1818
+ const hoistedImports = [];
1819
+
1820
+ try {
1821
+ const importNodes = this.parseImportsFromCode(code);
1822
+
1823
+ if (importNodes.length === 0) {
1824
+ return collectHoistedImports ? { code, hoistedImports } : code;
1825
+ }
1826
+
1827
+ let cleaned = '';
1828
+ let cursor = 0;
1829
+
1830
+ for (const node of importNodes) {
1831
+ const importPath = node.source?.value;
1832
+ const classification = this.classifyImport(importPath, publicFolders);
1833
+ const statement = code.slice(node.start, node.end);
1834
+
1835
+ cleaned += code.slice(cursor, node.start);
1836
+ if (classification.keep) {
1837
+ if (collectHoistedImports) {
1838
+ hoistedImports.push(statement.trim());
1839
+ } else {
1840
+ cleaned += statement;
1841
+ }
1842
+ } else if (classification.warning) {
1843
+ console.warn(this.buildImportWarningMessage(classification.warning, sourceContext));
1844
+ }
1845
+
1846
+ cursor = node.end;
1847
+ }
1848
+
1849
+ cleaned += code.slice(cursor);
1850
+ return collectHoistedImports
1851
+ ? { code: cleaned, hoistedImports }
1852
+ : cleaned;
1853
+ } catch (error) {
1854
+ const fallback = this.stripImportsWithFallbackRegex(code, publicFolders, sourceContext, collectHoistedImports);
1855
+ return collectHoistedImports ? fallback : fallback.code;
1856
+ }
1516
1857
  }
1517
1858
 
1518
1859
  async loadComponentsMap() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-cli",
3
- "version": "3.0.2",
3
+ "version": "3.0.3",
4
4
  "description": "Command client for developing web applications with Slice.js framework",
5
5
  "main": "client.js",
6
6
  "bin": {
@@ -204,3 +204,185 @@ test('rebalance merge preserves and merges route path metadata deterministically
204
204
  assert.equal(Object.keys(bundles).length, 2);
205
205
  assert.deepEqual(bundles.beta.paths, ['/beta', '/beta-alt', '/gamma']);
206
206
  });
207
+
208
+ test('stripImports preserves absolute imports from configured publicFolders', () => {
209
+ const generator = new BundleGenerator(import.meta.url, {
210
+ components: [],
211
+ routes: [],
212
+ metrics: {},
213
+ sliceConfig: {
214
+ publicFolders: ['/public', '/assets']
215
+ }
216
+ });
217
+
218
+ const source = "import logo from '/public/logo.js';\nimport hero from '/assets/hero.js';\nclass Demo {}\n";
219
+ const cleaned = generator.stripImports(source);
220
+
221
+ assert.match(cleaned, /import\s+logo\s+from\s+'\/public\/logo\.js';/);
222
+ assert.match(cleaned, /import\s+hero\s+from\s+'\/assets\/hero\.js';/);
223
+ });
224
+
225
+ test('stripImports removes relative imports', () => {
226
+ const generator = new BundleGenerator(import.meta.url, {
227
+ components: [],
228
+ routes: [],
229
+ metrics: {},
230
+ sliceConfig: {
231
+ publicFolders: ['/public']
232
+ }
233
+ });
234
+
235
+ const source = "import localDep from './local.js';\nimport parentDep from '../parent.js';\nclass Demo {}\n";
236
+ const cleaned = generator.stripImports(source);
237
+
238
+ assert.doesNotMatch(cleaned, /\.\/local\.js/);
239
+ assert.doesNotMatch(cleaned, /\.\.\/parent\.js/);
240
+ assert.match(cleaned, /class Demo \{\}/);
241
+ });
242
+
243
+ test('stripImports warns on bare imports', () => {
244
+ const generator = new BundleGenerator(import.meta.url, {
245
+ components: [],
246
+ routes: [],
247
+ metrics: {},
248
+ sliceConfig: {
249
+ publicFolders: ['/public']
250
+ }
251
+ });
252
+
253
+ const warnings = [];
254
+ const originalWarn = console.warn;
255
+ console.warn = (...args) => warnings.push(args.join(' '));
256
+
257
+ try {
258
+ const source = "import { html } from 'lit';\nclass Demo {}\n";
259
+ const cleaned = generator.stripImports(source);
260
+
261
+ assert.doesNotMatch(cleaned, /from\s+'lit'/);
262
+ assert.ok(warnings.some((msg) => msg.includes('bare import') && msg.includes('lit')));
263
+ } finally {
264
+ console.warn = originalWarn;
265
+ }
266
+ });
267
+
268
+ test('stripImports warns on absolute imports outside publicFolders', () => {
269
+ const generator = new BundleGenerator(import.meta.url, {
270
+ components: [],
271
+ routes: [],
272
+ metrics: {},
273
+ sliceConfig: {
274
+ publicFolders: ['/public']
275
+ }
276
+ });
277
+
278
+ const warnings = [];
279
+ const originalWarn = console.warn;
280
+ console.warn = (...args) => warnings.push(args.join(' '));
281
+
282
+ try {
283
+ const source = "import secret from '/private/secret.js';\nclass Demo {}\n";
284
+ const cleaned = generator.stripImports(source);
285
+
286
+ assert.doesNotMatch(cleaned, /\/private\/secret\.js/);
287
+ assert.ok(warnings.some((msg) => msg.includes('outside publicFolders') && msg.includes('/private/secret.js')));
288
+ } finally {
289
+ console.warn = originalWarn;
290
+ }
291
+ });
292
+
293
+ test('stripImports supports side-effect and multiline imports in fallback mode', () => {
294
+ const generator = new BundleGenerator(import.meta.url, {
295
+ components: [],
296
+ routes: [],
297
+ metrics: {},
298
+ sliceConfig: {
299
+ publicFolders: ['/public']
300
+ }
301
+ });
302
+
303
+ const warnings = [];
304
+ const originalWarn = console.warn;
305
+ console.warn = (...args) => warnings.push(args.join(' '));
306
+
307
+ const originalParse = generator.parseImportsFromCode;
308
+ generator.parseImportsFromCode = () => {
309
+ throw new Error('forced parser failure');
310
+ };
311
+
312
+ try {
313
+ const source = [
314
+ "import '/public/effects.js';",
315
+ "import '/private/effects.js';",
316
+ "import {",
317
+ ' html,',
318
+ ' css',
319
+ "} from 'lit';",
320
+ 'class Demo {}'
321
+ ].join('\n');
322
+ const cleaned = generator.stripImports(source, { sourceContext: 'DemoComponent' });
323
+
324
+ assert.match(cleaned, /import '\/public\/effects\.js';/);
325
+ assert.doesNotMatch(cleaned, /\/private\/effects\.js/);
326
+ assert.doesNotMatch(cleaned, /from 'lit'/);
327
+ assert.ok(warnings.some((msg) => msg.includes('outside publicFolders') && msg.includes('/private/effects.js') && msg.includes('[DemoComponent]')));
328
+ assert.ok(warnings.some((msg) => msg.includes('bare import') && msg.includes('lit') && msg.includes('[DemoComponent]')));
329
+ } finally {
330
+ generator.parseImportsFromCode = originalParse;
331
+ console.warn = originalWarn;
332
+ }
333
+ });
334
+
335
+ test('cleanJavaScript hoists allowed absolute imports and removes them from component code', () => {
336
+ const generator = new BundleGenerator(import.meta.url, {
337
+ components: [],
338
+ routes: [],
339
+ metrics: {},
340
+ sliceConfig: {
341
+ publicFolders: ['/public']
342
+ }
343
+ });
344
+
345
+ const source = [
346
+ "import hero from '/public/hero.js';",
347
+ 'class Demo extends HTMLElement {}',
348
+ 'customElements.define("x-demo", Demo);'
349
+ ].join('\n');
350
+
351
+ const result = generator.cleanJavaScript(source, 'Demo', 'DemoPath.js');
352
+
353
+ assert.doesNotMatch(result.code, /import hero from '\/public\/hero\.js';/);
354
+ assert.ok(result.hoistedImports.includes("import hero from '/public/hero.js';"));
355
+ });
356
+
357
+ test('formatBundleFile emits hoisted imports for framework-compatible output', () => {
358
+ const generator = new BundleGenerator(import.meta.url, {
359
+ components: [],
360
+ routes: [],
361
+ metrics: {}
362
+ });
363
+
364
+ const source = generator.formatBundleFile({
365
+ 'Framework/Structural/Bootstrap': {
366
+ name: 'Bootstrap',
367
+ category: 'Framework',
368
+ categoryType: 'Structural',
369
+ componentDependencies: [],
370
+ externalDependencies: {},
371
+ hoistedImports: ["import boot from '/public/bootstrap.js';"],
372
+ js: 'class Bootstrap extends HTMLElement {}\nreturn Bootstrap;',
373
+ html: '',
374
+ css: '',
375
+ size: 10,
376
+ isFramework: true
377
+ }
378
+ }, {
379
+ type: 'framework',
380
+ generated: new Date().toISOString(),
381
+ strategy: 'hybrid',
382
+ componentCount: 1,
383
+ totalSize: 10
384
+ });
385
+
386
+ assert.match(source, /import boot from '\/public\/bootstrap\.js';/);
387
+ assert.match(source, /const SLICE_BUNDLE_DEPENDENCIES = \{\};/);
388
+ });
@@ -139,3 +139,113 @@ test('bundle output inlines dependency modules and binds imported symbols in cla
139
139
  assert.match(source, /SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\] = __sliceDepExports0;/);
140
140
  assert.match(source, /const documentationRoutes = SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\]\.documentationRoutes;/);
141
141
  });
142
+
143
+ test('bundle output hoists allowed absolute imports to module top-level', () => {
144
+ const generator = new BundleGenerator(import.meta.url, {
145
+ components: [],
146
+ routes: [],
147
+ metrics: {
148
+ totalComponents: 0,
149
+ totalRoutes: 0,
150
+ sharedPercentage: 0,
151
+ totalSize: 0
152
+ }
153
+ }, { output: 'src' });
154
+
155
+ const source = generator.generateBundleFileContent(
156
+ 'slice-bundle.test.js',
157
+ 'route',
158
+ [{
159
+ name: 'HeroCard',
160
+ category: 'Visual',
161
+ categoryType: 'Visual',
162
+ dependencies: new Set(),
163
+ size: 100,
164
+ js: 'class HeroCard extends HTMLElement {}\nwindow.HeroCard = HeroCard;\nreturn HeroCard;',
165
+ html: '',
166
+ css: '',
167
+ hoistedImports: ["import hero from '/public/hero.js';"]
168
+ }],
169
+ '/test'
170
+ );
171
+
172
+ assert.match(source, /import hero from '\/public\/hero\.js';/);
173
+ assert.doesNotMatch(source, /SLICE_CLASS_FACTORY_SliceComponent_HeroCard = \(\) => \{[\s\S]*import hero from '\/public\/hero\.js';/);
174
+ });
175
+
176
+ test('generateBundleFileContent throws on hoisted import local binding collisions', () => {
177
+ const generator = new BundleGenerator(import.meta.url, {
178
+ components: [],
179
+ routes: [],
180
+ metrics: {
181
+ totalComponents: 0,
182
+ totalRoutes: 0,
183
+ sharedPercentage: 0,
184
+ totalSize: 0
185
+ }
186
+ }, { output: 'src' });
187
+
188
+ assert.throws(() => {
189
+ generator.generateBundleFileContent(
190
+ 'slice-bundle.test.js',
191
+ 'route',
192
+ [
193
+ {
194
+ name: 'CompA',
195
+ category: 'Visual',
196
+ categoryType: 'Visual',
197
+ dependencies: new Set(),
198
+ size: 100,
199
+ js: 'class CompA extends HTMLElement {}\nwindow.CompA = CompA;\nreturn CompA;',
200
+ html: '',
201
+ css: '',
202
+ hoistedImports: ["import foo from '/public/a.js';"]
203
+ },
204
+ {
205
+ name: 'CompB',
206
+ category: 'Visual',
207
+ categoryType: 'Visual',
208
+ dependencies: new Set(),
209
+ size: 100,
210
+ js: 'class CompB extends HTMLElement {}\nwindow.CompB = CompB;\nreturn CompB;',
211
+ html: '',
212
+ css: '',
213
+ hoistedImports: ["import foo from '/public/b.js';"]
214
+ }
215
+ ],
216
+ '/test'
217
+ );
218
+ }, /Hoisted import binding collision: foo/);
219
+ });
220
+
221
+ test('generateBundleFileContent throws on reserved identifier collision', () => {
222
+ const generator = new BundleGenerator(import.meta.url, {
223
+ components: [],
224
+ routes: [],
225
+ metrics: {
226
+ totalComponents: 0,
227
+ totalRoutes: 0,
228
+ sharedPercentage: 0,
229
+ totalSize: 0
230
+ }
231
+ }, { output: 'src' });
232
+
233
+ assert.throws(() => {
234
+ generator.generateBundleFileContent(
235
+ 'slice-bundle.test.js',
236
+ 'route',
237
+ [{
238
+ name: 'CompMeta',
239
+ category: 'Visual',
240
+ categoryType: 'Visual',
241
+ dependencies: new Set(),
242
+ size: 100,
243
+ js: 'class CompMeta extends HTMLElement {}\nwindow.CompMeta = CompMeta;\nreturn CompMeta;',
244
+ html: '',
245
+ css: '',
246
+ hoistedImports: ["import SLICE_BUNDLE_META from '/public/meta.js';"]
247
+ }],
248
+ '/test'
249
+ );
250
+ }, /reserved identifier collision: SLICE_BUNDLE_META/);
251
+ });