@colixsystems/widget-sdk 0.117.0 → 0.119.0

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.
@@ -0,0 +1,817 @@
1
+ // sc-6086: fold a widget entry's SIBLING modules into one ESM source.
2
+ //
3
+ // A first-party bundle carries exactly one component module, but
4
+ // `appstudio-widget dev` serves the widget DIRECTORY — so a multi-file widget
5
+ // runs locally and publishes an entry importing files the tarball does not
6
+ // have. Flattening here is what makes the two loops agree (CLAUDE.md §3).
7
+ //
8
+ // Why a textual splice and not esbuild: the marketplace analyzer lints this
9
+ // exact text, and licenses several of its rules by COMMENT
10
+ // (`// appstudio-design-ok: <reason>`). esbuild drops comments, which silently
11
+ // revokes every licence — on `chart` that turns 0 `no-hardcoded-design`
12
+ // findings into 48. First-party versions are auto-published, so the analyzer
13
+ // is the only gate they pass. Splicing keeps comments and identifiers exact.
14
+ //
15
+ // WHAT to splice is decided by a real parser, not by pattern matching. Two
16
+ // rounds of review found holes a scanner cannot close — a `}` inside a regex
17
+ // character class skewing brace depth, `return /[{]/` reading as division, a
18
+ // Unicode identifier in an import clause — each of which silently published a
19
+ // bundle that was wrong rather than refused. The caller injects `parse`
20
+ // (`@babel/parser`) so this module stays dependency-free for the SDK runtime.
21
+ //
22
+ // The supported shape is narrow and anything outside it throws, naming the
23
+ // file and line: siblings may only use named declaration exports, and may only
24
+ // be imported by name.
25
+ //
26
+ // One divergence this design cannot close: modules share one scope after
27
+ // inlining, so a sibling's top-level `const URL = …` shadows an ambient global
28
+ // another module relies on. Renaming would fix it and would also destroy the
29
+ // premise — the analyzer lints THIS text, so identifiers and comments have to
30
+ // stay exactly as the author wrote them. Duplicate DECLARATIONS are refused;
31
+ // a declaration capturing another module's free reference is not detectable
32
+ // without scope analysis this deliberately does not do.
33
+
34
+ import { stripNonCode } from "./source-mask.js";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Best-effort scanner — the dev-server guard only.
38
+ //
39
+ // `appstudio-widget dev` cannot load a parser (the SDK ships this file), and a
40
+ // miss there only costs a warning, never a bad bundle. Everything the PACKER
41
+ // relies on goes through the AST below instead.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Comments + template literals blanked; quoted specifiers kept, offsets intact. */
45
+ function maskCode(source) {
46
+ return stripNonCode(source, { keepStrings: true, keepTemplates: false });
47
+ }
48
+
49
+ /** Index of a regex literal's closing `/`, or -1 when it isn't one. */
50
+ function skipRegexLiteral(mask, start) {
51
+ let inClass = false;
52
+ for (let i = start + 1; i < mask.length; i += 1) {
53
+ const ch = mask[i];
54
+ if (ch === "\\") {
55
+ i += 1;
56
+ continue;
57
+ }
58
+ if (ch === "\n") return -1;
59
+ if (ch === "[") inClass = true;
60
+ else if (ch === "]") inClass = false;
61
+ else if (ch === "/" && !inClass) return i;
62
+ }
63
+ return -1;
64
+ }
65
+
66
+ // A `/` opens a regex unless the previous token can end an expression. The
67
+ // keyword list matters: `return /[{]/` is a regex, and reading it as division
68
+ // throws the brace count off.
69
+ const REGEX_PRECEDING_KEYWORDS = new Set([
70
+ "return",
71
+ "typeof",
72
+ "instanceof",
73
+ "in",
74
+ "of",
75
+ "new",
76
+ "delete",
77
+ "void",
78
+ "do",
79
+ "else",
80
+ "case",
81
+ "yield",
82
+ "await",
83
+ "throw",
84
+ ]);
85
+
86
+ function opensRegex(mask, at) {
87
+ let i = at - 1;
88
+ while (i >= 0 && /\s/.test(mask[i])) i -= 1;
89
+ if (i < 0) return true;
90
+ const ch = mask[i];
91
+ if (/[)\]]/.test(ch)) return false;
92
+ if (!/[\w$]/.test(ch)) return true;
93
+ let end = i + 1;
94
+ while (i >= 0 && /[\w$]/.test(mask[i])) i -= 1;
95
+ const word = mask.slice(i + 1, end);
96
+ return REGEX_PRECEDING_KEYWORDS.has(word);
97
+ }
98
+
99
+ /** Brace depth at every offset, skipping regex literals. */
100
+ function braceDepths(mask) {
101
+ const depth = new Int32Array(mask.length);
102
+ let d = 0;
103
+ for (let i = 0; i < mask.length; i += 1) {
104
+ const ch = mask[i];
105
+ if (ch === "/" && opensRegex(mask, i)) {
106
+ const end = skipRegexLiteral(mask, i);
107
+ if (end > i) {
108
+ depth.fill(d, i, end + 1);
109
+ i = end;
110
+ continue;
111
+ }
112
+ }
113
+ if (ch === "}") d -= 1;
114
+ depth[i] = d;
115
+ if (ch === "{") d += 1;
116
+ }
117
+ return depth;
118
+ }
119
+
120
+ const CLAUSE_CHAR = /[A-Za-z0-9_$,{}*\s]/;
121
+
122
+ // What may appear between the keyword and `from`. Non-ASCII counts, because a
123
+ // JS identifier may be Unicode (`import { café } from "x"`) and treating one
124
+ // as a clause break skips the whole statement — losing the import silently,
125
+ // the failure sc-6670 closes. Still a whitelist, so `export const x = 1`
126
+ // drops out at the `=`.
127
+ const isClauseChar = (ch) => CLAUSE_CHAR.test(ch) || ch.charCodeAt(0) > 0x7f;
128
+
129
+ /**
130
+ * EVERY top-level static `import` / `export … from` statement, whatever its
131
+ * specifier — relative, bare, absolute, or a URL.
132
+ *
133
+ * The clause body is walked character by character rather than matched by a
134
+ * regex, so a MULTI-LINE clause (`import {\n a,\n b,\n} from "x"`) is found
135
+ * like any other. sc-6670: the dev server's own `STATIC_IMPORT_RE` used a
136
+ * `[^'";\n]` body that could not span newlines, so it served multi-line
137
+ * imports verbatim; sc-6086 had already hit the same blind spot in the
138
+ * relative-import guard. One scanner, so the guard, the packer, and the dev
139
+ * server's rewriter agree on what an import is (CLAUDE.md §3).
140
+ *
141
+ * Offsets index the ORIGINAL source (the mask is length-preserving), so a
142
+ * caller may splice on `quoteStart` / `quoteEnd` — the positions of the
143
+ * opening and closing quote — without re-finding the specifier.
144
+ *
145
+ * Best-effort by contract: it may miss an exotic clause, which costs a missing
146
+ * warning or an un-rewritten import. Never use it to decide what the packer
147
+ * emits.
148
+ *
149
+ * @param {string} source
150
+ * @returns {Array<{start:number,end:number,specifier:string,quoteStart:number,quoteEnd:number}>}
151
+ */
152
+ export function findImportStatements(source) {
153
+ const mask = maskCode(source);
154
+ const depth = braceDepths(mask);
155
+ const out = [];
156
+ // `[^\S\n]` is `\s` minus the newline that anchors the boundary — so a
157
+ // leading BOM or non-breaking space before the first `import` still scans.
158
+ const kw = /(?:^|[\n;])[^\S\n]*(import|export)\b/g;
159
+ let m;
160
+ while ((m = kw.exec(mask))) {
161
+ const keyword = m[1];
162
+ const startsAt = m.index + m[0].length - keyword.length;
163
+ if (depth[startsAt] !== 0) continue;
164
+
165
+ let i = m.index + m[0].length;
166
+ let ok = true;
167
+ while (i < mask.length) {
168
+ const ch = mask[i];
169
+ if (ch === '"' || ch === "'") break;
170
+ if (
171
+ ch === "f" &&
172
+ mask.startsWith("from", i) &&
173
+ !/[\w$]/.test(mask[i - 1] || " ") &&
174
+ !/[\w$]/.test(mask[i + 4] || " ")
175
+ ) {
176
+ i += 4;
177
+ while (i < mask.length && /\s/.test(mask[i])) i += 1;
178
+ break;
179
+ }
180
+ if (!isClauseChar(ch)) {
181
+ ok = false;
182
+ break;
183
+ }
184
+ i += 1;
185
+ }
186
+ if (!ok || i >= mask.length) continue;
187
+
188
+ const quote = mask[i];
189
+ if (quote !== '"' && quote !== "'") continue;
190
+ const close = mask.indexOf(quote, i + 1);
191
+ if (close === -1) continue;
192
+ const specifier = mask.slice(i + 1, close);
193
+ let end = close + 1;
194
+ if (mask[end] === ";") end += 1;
195
+ out.push({
196
+ start: startsAt,
197
+ end,
198
+ specifier,
199
+ quoteStart: i,
200
+ quoteEnd: close,
201
+ });
202
+ // Resume ON the closing quote: past the specifier's interior (so a `;`
203
+ // inside it opens no bogus match) but before a trailing `;`, which is
204
+ // still the boundary for a second import on the same line.
205
+ kw.lastIndex = close;
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Relative import specifiers only, for the dev server's single-file guard.
212
+ *
213
+ * @param {string} source
214
+ * @returns {Array<{start:number,end:number,specifier:string,quoteStart:number,quoteEnd:number}>}
215
+ */
216
+ export function findRelativeImportStatements(source) {
217
+ return findImportStatements(source).filter(
218
+ (s) => s.specifier.startsWith("./") || s.specifier.startsWith("../"),
219
+ );
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // AST-driven analysis — what the packer actually acts on.
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** Every binding name a destructuring pattern introduces. */
227
+ function patternNames(node, into) {
228
+ if (!node) return;
229
+ switch (node.type) {
230
+ case "Identifier":
231
+ into.push(node.name);
232
+ break;
233
+ case "ObjectPattern":
234
+ for (const p of node.properties) {
235
+ patternNames(p.type === "RestElement" ? p.argument : p.value, into);
236
+ }
237
+ break;
238
+ case "ArrayPattern":
239
+ for (const el of node.elements) patternNames(el, into);
240
+ break;
241
+ case "AssignmentPattern":
242
+ patternNames(node.left, into);
243
+ break;
244
+ case "RestElement":
245
+ patternNames(node.argument, into);
246
+ break;
247
+ default:
248
+ break;
249
+ }
250
+ }
251
+
252
+ function declarationBindings(decl, kinds) {
253
+ const names = [];
254
+ if (!decl) return names;
255
+ if (decl.type === "VariableDeclaration") {
256
+ for (const d of decl.declarations) patternNames(d.id, names);
257
+ for (const n of names) kinds?.set(n, decl.kind);
258
+ } else if (decl.id) {
259
+ names.push(decl.id.name);
260
+ kinds?.set(
261
+ decl.id.name,
262
+ decl.type === "ClassDeclaration" ? "class" : "function",
263
+ );
264
+ }
265
+ return names;
266
+ }
267
+
268
+ const FUNCTION_LIKE = new Set([
269
+ "FunctionDeclaration",
270
+ "FunctionExpression",
271
+ "ArrowFunctionExpression",
272
+ "ObjectMethod",
273
+ "ClassMethod",
274
+ "ClassPrivateMethod",
275
+ ]);
276
+
277
+ /**
278
+ * `var` bindings nested in a block are still MODULE-scoped, so two modules can
279
+ * silently share one — `if (x) { var SEEN = … }` in both an entry and a sibling
280
+ * produced a bundle that parsed and read the wrong value. Descend through
281
+ * statements but never into a function, which starts a new var scope.
282
+ */
283
+ function collectNestedVars(node, into, kinds) {
284
+ if (!node || typeof node !== "object") return;
285
+ if (Array.isArray(node)) {
286
+ for (const child of node) collectNestedVars(child, into, kinds);
287
+ return;
288
+ }
289
+ if (FUNCTION_LIKE.has(node.type)) return;
290
+ if (node.type === "VariableDeclaration" && node.kind === "var") {
291
+ for (const d of node.declarations) {
292
+ const names = [];
293
+ patternNames(d.id, names);
294
+ for (const n of names) {
295
+ into.push(n);
296
+ kinds?.set(n, "var");
297
+ }
298
+ }
299
+ }
300
+ for (const key of Object.keys(node)) {
301
+ if (
302
+ key === "loc" ||
303
+ key === "leadingComments" ||
304
+ key === "trailingComments"
305
+ )
306
+ continue;
307
+ collectNestedVars(node[key], into, kinds);
308
+ }
309
+ }
310
+
311
+ const line = (source, index) => source.slice(0, index).split("\n").length;
312
+
313
+ /** Every node in an AST, depth-first. */
314
+ function* walk(node) {
315
+ if (!node || typeof node !== "object") return;
316
+ if (Array.isArray(node)) {
317
+ for (const child of node) yield* walk(child);
318
+ return;
319
+ }
320
+ if (typeof node.type === "string") yield node;
321
+ for (const key of Object.keys(node)) {
322
+ if (
323
+ key === "loc" ||
324
+ key === "leadingComments" ||
325
+ key === "trailingComments"
326
+ )
327
+ continue;
328
+ yield* walk(node[key]);
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Everything the flattener needs about one module, straight from its AST.
334
+ *
335
+ * @param {string} source
336
+ * @param {string} rel for error messages.
337
+ * @param {Function} parse
338
+ * @param {boolean} isEntry the entry keeps its own exports; a sibling may not.
339
+ */
340
+ function analyze(source, rel, parse, isEntry) {
341
+ let ast;
342
+ try {
343
+ ast = parse(source, {
344
+ sourceType: "module",
345
+ errorRecovery: false,
346
+ plugins: ["jsx"],
347
+ });
348
+ } catch (err) {
349
+ throw new Error(`${rel}: does not parse — ${err.message}`, { cause: err });
350
+ }
351
+
352
+ const siblingImports = [];
353
+ const bareImports = [];
354
+ const cuts = [];
355
+ const names = [];
356
+ const exported = new Set();
357
+ const kinds = new Map();
358
+
359
+ for (const node of ast.program.body) {
360
+ if (node.type === "ImportDeclaration") {
361
+ const spec = node.source.value;
362
+ // EVERY import is spliced out: a sibling's because it is inlined, a bare
363
+ // one because the same package imported by two modules would otherwise
364
+ // emit two `import React from "react"` statements — a duplicate binding.
365
+ // They are merged and re-emitted once at the top of the bundle.
366
+ const record = {
367
+ start: node.start,
368
+ end: node.end,
369
+ specifier: spec,
370
+ line: line(source, node.start),
371
+ specifiers: node.specifiers,
372
+ };
373
+ if (spec.startsWith("./") || spec.startsWith("../"))
374
+ siblingImports.push(record);
375
+ else bareImports.push(record);
376
+ continue;
377
+ }
378
+
379
+ if (node.type === "ExportAllDeclaration") {
380
+ throw new Error(
381
+ `${rel}:${line(source, node.start)}: \`export * from "${node.source.value}"\` cannot ` +
382
+ `be inlined — the re-export would be silently dropped from the bundle.`,
383
+ );
384
+ }
385
+
386
+ if (node.type === "ExportNamedDeclaration") {
387
+ if (node.source) {
388
+ throw new Error(
389
+ `${rel}:${line(source, node.start)}: \`export … from "${node.source.value}"\` cannot ` +
390
+ `be inlined — the re-export would be silently dropped from the bundle. Import ` +
391
+ `the names, then export them from the entry.`,
392
+ );
393
+ }
394
+ if (isEntry) {
395
+ names.push(...declarationBindings(node.declaration, kinds));
396
+ collectNestedVars(node.declaration, names, kinds);
397
+ continue;
398
+ }
399
+ if (!node.declaration) {
400
+ throw new Error(
401
+ `${rel}:${line(source, node.start)}: a sibling module may only use named ` +
402
+ `declaration exports (export function|const|let|var|class NAME) — found ` +
403
+ `\`export { … }\`. Rewrite it as a named export, or move the code into the entry.`,
404
+ );
405
+ }
406
+ const decl = node.declaration;
407
+ if (
408
+ decl.type === "VariableDeclaration" &&
409
+ decl.declarations.some((d) => d.id.type !== "Identifier")
410
+ ) {
411
+ throw new Error(
412
+ `${rel}:${line(source, node.start)}: a sibling module may not export a destructuring ` +
413
+ `pattern — name each binding with its own \`export ${decl.kind} NAME = …\`.`,
414
+ );
415
+ }
416
+ const bound = declarationBindings(decl, kinds);
417
+ names.push(...bound);
418
+ for (const n of bound) exported.add(n);
419
+ // Cut ONLY the keyword, so a comment between it and the declaration
420
+ // survives — those comments carry the analyzer's rule licences.
421
+ cuts.push({ start: node.start, end: node.start + "export".length });
422
+ continue;
423
+ }
424
+
425
+ if (node.type === "ExportDefaultDeclaration") {
426
+ if (isEntry) continue;
427
+ throw new Error(
428
+ `${rel}:${line(source, node.start)}: a sibling module may only use named declaration ` +
429
+ `exports — found \`export default\`. Rewrite it as a named export, or move the ` +
430
+ `code into the widget entry.`,
431
+ );
432
+ }
433
+
434
+ names.push(...declarationBindings(node, kinds));
435
+ collectNestedVars(node, names, kinds);
436
+ }
437
+
438
+ for (const node of walk(ast.program)) {
439
+ const isDynamicImport =
440
+ node.type === "ImportExpression" ||
441
+ (node.type === "CallExpression" && node.callee?.type === "Import");
442
+ if (!isDynamicImport) continue;
443
+ const arg =
444
+ node.type === "ImportExpression" ? node.source : node.arguments[0];
445
+ // A non-literal argument cannot be resolved at pack time either, so a
446
+ // template-literal specifier must not slip through as "not relative".
447
+ const value = arg?.type === "StringLiteral" ? arg.value : null;
448
+ if (value === null || /^\.\.?\//.test(value)) {
449
+ throw new Error(
450
+ `${rel}:${line(source, node.start)}: dynamic import(${value ? `"${value}"` : "…"}) ` +
451
+ `cannot be inlined. The dev server rewrites it, so this works locally and breaks at ` +
452
+ `publish — use a static import.`,
453
+ );
454
+ }
455
+ }
456
+
457
+ // Names the module reassigns anywhere. Used to refuse an alias, which is a
458
+ // snapshot; a real ESM import tracks the reassignment.
459
+ const reassigned = new Set();
460
+ for (const node of walk(ast.program)) {
461
+ if (
462
+ node.type === "AssignmentExpression" &&
463
+ node.left?.type === "Identifier"
464
+ ) {
465
+ reassigned.add(node.left.name);
466
+ } else if (
467
+ node.type === "UpdateExpression" &&
468
+ node.argument?.type === "Identifier"
469
+ ) {
470
+ reassigned.add(node.argument.name);
471
+ }
472
+ }
473
+
474
+ return {
475
+ siblingImports,
476
+ bareImports,
477
+ cuts,
478
+ names,
479
+ exported,
480
+ kinds,
481
+ reassigned,
482
+ };
483
+ }
484
+
485
+ /** Remove spans from a source, leaving every other byte untouched. */
486
+ function spliceOut(source, spans) {
487
+ const ordered = [...spans].sort((a, b) => a.start - b.start);
488
+ let out = "";
489
+ let cursor = 0;
490
+ for (const { start, end } of ordered) {
491
+ if (start < cursor) continue;
492
+ out += source.slice(cursor, start);
493
+ cursor = end;
494
+ }
495
+ return out + source.slice(cursor);
496
+ }
497
+
498
+ /**
499
+ * Merge every module's bare imports into one deduplicated block.
500
+ *
501
+ * Two modules importing `React` is ordinary — every first-party entry does it —
502
+ * so the same binding from the same package collapses to one statement. One
503
+ * local name meaning two different things is a real conflict and throws.
504
+ */
505
+ const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
506
+
507
+ function mergeBareImports(records) {
508
+ const bySource = new Map();
509
+ const boundBy = new Map(); // local -> descriptor, for conflict detection
510
+
511
+ const claim = (local, descriptor, rel) => {
512
+ const prev = boundBy.get(local);
513
+ if (prev && prev.descriptor !== descriptor) {
514
+ throw new Error(
515
+ `"${local}" is imported as both ${prev.descriptor} (in ${prev.rel}) and ` +
516
+ `${descriptor} (in ${rel}) — inlining puts every module in one scope, so a local ` +
517
+ `name cannot mean two bindings.`,
518
+ );
519
+ }
520
+ boundBy.set(local, { descriptor, rel });
521
+ };
522
+
523
+ for (const { rel, record } of records) {
524
+ const src = record.specifier;
525
+ if (!bySource.has(src)) {
526
+ bySource.set(src, {
527
+ // Sets, not single slots: an entry and a sibling may import the same
528
+ // package under DIFFERENT local names. Overwriting one erased it from
529
+ // the bundle while the body still referenced it — a ReferenceError on
530
+ // load, from a bundle that parsed and linted clean.
531
+ defaults: new Set(),
532
+ namespaces: new Set(),
533
+ named: new Map(),
534
+ sideEffect: false,
535
+ });
536
+ }
537
+ const entry = bySource.get(src);
538
+ if (record.specifiers.length === 0) {
539
+ entry.sideEffect = true;
540
+ continue;
541
+ }
542
+ for (const s of record.specifiers) {
543
+ if (s.type === "ImportDefaultSpecifier") {
544
+ claim(s.local.name, `the default export of "${src}"`, rel);
545
+ entry.defaults.add(s.local.name);
546
+ } else if (s.type === "ImportNamespaceSpecifier") {
547
+ claim(s.local.name, `a namespace of "${src}"`, rel);
548
+ entry.namespaces.add(s.local.name);
549
+ } else {
550
+ const imported = s.imported.name ?? s.imported.value;
551
+ // `{ default as X }` and a plain default import name one binding.
552
+ if (imported === "default") {
553
+ claim(s.local.name, `the default export of "${src}"`, rel);
554
+ entry.defaults.add(s.local.name);
555
+ continue;
556
+ }
557
+ claim(s.local.name, `"${imported}" from "${src}"`, rel);
558
+ entry.named.set(`${imported}::${s.local.name}`, {
559
+ imported,
560
+ local: s.local.name,
561
+ });
562
+ }
563
+ }
564
+ }
565
+
566
+ const lines = [];
567
+ for (const [src, entry] of bySource) {
568
+ const [firstDefault, ...extraDefaults] = [...entry.defaults];
569
+ const clause = [];
570
+ if (firstDefault) clause.push(firstDefault);
571
+ if (entry.named.size > 0) {
572
+ const inner = [...entry.named.values()]
573
+ .map(({ imported, local }) => {
574
+ // An arbitrary module-namespace name (`import { "a-b" as ab }`) is
575
+ // not an identifier and must stay quoted when re-emitted.
576
+ const name = IDENTIFIER_RE.test(imported)
577
+ ? imported
578
+ : JSON.stringify(imported);
579
+ return name === local ? local : `${name} as ${local}`;
580
+ })
581
+ .join(", ");
582
+ clause.push(`{ ${inner} }`);
583
+ }
584
+ if (clause.length > 0)
585
+ lines.push(`import ${clause.join(", ")} from "${src}";`);
586
+ // Each additional local for the same binding gets its own statement —
587
+ // legal ESM, and the only way to keep every one of them.
588
+ for (const extra of extraDefaults) {
589
+ lines.push(`import ${extra} from "${src}";`);
590
+ }
591
+ // A namespace cannot share a statement with a default or named clause.
592
+ for (const ns of entry.namespaces) {
593
+ lines.push(`import * as ${ns} from "${src}";`);
594
+ }
595
+ if (
596
+ clause.length === 0 &&
597
+ entry.namespaces.size === 0 &&
598
+ entry.sideEffect
599
+ ) {
600
+ lines.push(`import "${src}";`);
601
+ }
602
+ }
603
+ return lines.join("\n");
604
+ }
605
+
606
+ /**
607
+ * Flatten `entryRel`'s sibling graph into a single ESM source.
608
+ *
609
+ * @param {object} opts
610
+ * @param {string} opts.entryRel POSIX rel-path of the entry inside the widget dir.
611
+ * @param {(rel: string) => string} opts.readModule reads a module by rel-path.
612
+ * @param {(fromRel: string, specifier: string) => string | null} opts.resolve
613
+ * the SAME resolver `appstudio-widget dev` uses; null when the target
614
+ * escapes the widget directory.
615
+ * @param {Function} opts.parse `@babel/parser`'s `parse`.
616
+ * @returns {{ source: string, modules: string[] }} entry first.
617
+ */
618
+ export function flattenEntry({ entryRel, readModule, resolve, parse }) {
619
+ if (typeof parse !== "function") {
620
+ throw new Error(
621
+ "flattenEntry needs a `parse` (@babel/parser) — refusing to guess.",
622
+ );
623
+ }
624
+
625
+ const sources = new Map();
626
+ const read = (rel) => {
627
+ if (!sources.has(rel)) sources.set(rel, readModule(rel));
628
+ return sources.get(rel);
629
+ };
630
+ const analyses = new Map();
631
+ const analysisOf = (rel) => {
632
+ if (!analyses.has(rel)) {
633
+ analyses.set(rel, analyze(read(rel), rel, parse, rel === entryRel));
634
+ }
635
+ return analyses.get(rel);
636
+ };
637
+
638
+ const visiting = new Set();
639
+ const emitted = new Map(); // rel -> { code, aliases: [string] }
640
+ const order = [];
641
+ const declaredBy = new Map();
642
+ // local -> { imported, target } — one alias declaration per bundle, emitted
643
+ // with the module that DECLARES the binding. Post-order guarantees a target
644
+ // precedes every module that imports from it, so the alias is always
645
+ // initialised before any module scope can read it. Attaching it to the
646
+ // importer instead put the entry's claim last, and a sibling reading it at
647
+ // module scope hit a TDZ error.
648
+ const aliasOwner = new Map();
649
+ const aliasesByTarget = new Map(); // target rel -> ["const p = pad;", …]
650
+
651
+ function visit(rel, stack) {
652
+ if (emitted.has(rel)) return;
653
+ if (visiting.has(rel)) {
654
+ throw new Error(
655
+ `circular sibling import: ${[...stack, rel].join(" → ")}. The packer inlines ` +
656
+ `siblings into one module, which a cycle cannot express — break the cycle.`,
657
+ );
658
+ }
659
+ visiting.add(rel);
660
+
661
+ const source = read(rel);
662
+ const { siblingImports, bareImports, cuts, names } = analysisOf(rel);
663
+
664
+ for (const st of siblingImports) {
665
+ const target = resolve(rel, st.specifier);
666
+ if (!target) {
667
+ throw new Error(
668
+ `${rel}:${st.line}: relative import "${st.specifier}" does not resolve to a file ` +
669
+ `inside the widget directory. A bundle may only inline its own sibling modules — ` +
670
+ `\`../\` escapes and asset imports are not packable.`,
671
+ );
672
+ }
673
+ if (/\.json$/i.test(target)) {
674
+ throw new Error(
675
+ `${rel}:${st.line}: cannot inline "${st.specifier}" — a JSON module has no ` +
676
+ `declarations to fold into the bundle. Move the data into a .js sibling.`,
677
+ );
678
+ }
679
+ visit(target, [...stack, rel]);
680
+ const targetAnalysis = analysisOf(target);
681
+
682
+ for (const s of st.specifiers) {
683
+ if (s.type === "ImportNamespaceSpecifier") {
684
+ throw new Error(
685
+ `${rel}:${st.line}: \`import * as ${s.local.name}\` from a sibling cannot be ` +
686
+ `inlined — the sibling's declarations are folded into this module's scope, so ` +
687
+ `there is no namespace object. Import the names individually.`,
688
+ );
689
+ }
690
+ if (s.type === "ImportDefaultSpecifier") {
691
+ throw new Error(
692
+ `${rel}:${st.line}: default-importing sibling "${st.specifier}" is not supported — ` +
693
+ `a sibling may only use named declaration exports.`,
694
+ );
695
+ }
696
+ const imported = s.imported.name ?? s.imported.value;
697
+ if (!targetAnalysis.exported.has(imported)) {
698
+ throw new Error(
699
+ `${rel}:${st.line}: imports "${imported}" from "${st.specifier}", which does not ` +
700
+ `export it. Inlining would leave the name undefined at runtime.`,
701
+ );
702
+ }
703
+ if (s.local.name === imported) continue;
704
+ // An alias becomes `const local = target`, which SNAPSHOTS the value.
705
+ // A real ESM import is a live binding, so aliasing anything the target
706
+ // module reassigns would silently diverge — including a `function` or
707
+ // `class` binding, which the old `let`/`var` proxy missed (the
708
+ // self-overwriting `config = () => v` memoisation idiom). Conservative
709
+ // by design: a same-named local inside a function also trips this, and
710
+ // refusing is the safe direction.
711
+ if (targetAnalysis.reassigned.has(imported)) {
712
+ throw new Error(
713
+ `${rel}:${st.line}: cannot alias "${imported}" as "${s.local.name}" — ${target} ` +
714
+ `reassigns it, and the inlined alias would snapshot its value instead of tracking ` +
715
+ `the reassignment the way a real ESM import does. Import it under its own name.`,
716
+ );
717
+ }
718
+ // One `const` per alias for the WHOLE bundle: two modules aliasing the
719
+ // same helper must not each emit one, or the bundle stops parsing.
720
+ const owner = aliasOwner.get(s.local.name);
721
+ if (owner) {
722
+ if (owner.imported !== imported) {
723
+ throw new Error(
724
+ `${rel}:${st.line}: "${s.local.name}" is aliased to "${imported}" here but to ` +
725
+ `"${owner.imported}" in ${owner.rel} — one local name cannot mean two bindings.`,
726
+ );
727
+ }
728
+ continue;
729
+ }
730
+ const collision = declaredBy.get(s.local.name);
731
+ if (collision !== undefined) {
732
+ throw new Error(
733
+ `${rel}:${st.line}: aliases "${imported}" as "${s.local.name}", but ${collision} ` +
734
+ `declares a top-level "${s.local.name}". Inlining puts them in one scope — ` +
735
+ `rename one of them.`,
736
+ );
737
+ }
738
+ aliasOwner.set(s.local.name, { imported, target, rel });
739
+ declaredBy.set(s.local.name, target);
740
+ if (!aliasesByTarget.has(target)) aliasesByTarget.set(target, []);
741
+ aliasesByTarget
742
+ .get(target)
743
+ .push(`const ${s.local.name} = ${imported};`);
744
+ }
745
+ }
746
+
747
+ for (const name of names) {
748
+ const prev = declaredBy.get(name);
749
+ if (prev !== undefined && prev !== rel) {
750
+ throw new Error(
751
+ `duplicate top-level name "${name}" — declared in both ${prev} and ${rel}. ` +
752
+ `Inlining siblings puts every module in one scope, so a top-level name must be ` +
753
+ `unique across the widget's modules.`,
754
+ );
755
+ }
756
+ declaredBy.set(name, rel);
757
+ }
758
+
759
+ emitted.set(rel, {
760
+ code: spliceOut(source, [...siblingImports, ...bareImports, ...cuts]),
761
+ });
762
+ visiting.delete(rel);
763
+ order.push(rel);
764
+ }
765
+
766
+ visit(entryRel, []);
767
+
768
+ if (order.length === 1) {
769
+ // No siblings — the entry packs byte-for-byte as it always has.
770
+ return { source: read(entryRel), modules: [entryRel] };
771
+ }
772
+
773
+ const siblings = order.filter((rel) => rel !== entryRel);
774
+ const bareRecords = order.flatMap((rel) =>
775
+ analysisOf(rel).bareImports.map((record) => ({ rel, record })),
776
+ );
777
+ // An imported local and a declared name share the bundle's one scope.
778
+ for (const { rel, record } of bareRecords) {
779
+ for (const s of record.specifiers) {
780
+ const owner = declaredBy.get(s.local.name);
781
+ if (owner !== undefined) {
782
+ throw new Error(
783
+ `${rel}: imports "${s.local.name}" from "${record.specifier}", but ${owner} declares a ` +
784
+ `top-level "${s.local.name}". Inlining puts them in one scope — rename one of them.`,
785
+ );
786
+ }
787
+ }
788
+ }
789
+ const importBlock = mergeBareImports(bareRecords);
790
+
791
+ // Each module's aliases sit with that module, so a sibling that uses an
792
+ // aliased binding at module scope reads an initialised `const`.
793
+ const render = (rel, header) => {
794
+ const { code } = emitted.get(rel);
795
+ // Aliases follow the body that declares what they name.
796
+ const aliases = aliasesByTarget.get(rel) ?? [];
797
+ return `${header}${code.trim()}\n${aliases.length ? `${aliases.join("\n")}\n` : ""}`;
798
+ };
799
+
800
+ const parts = [];
801
+ if (importBlock) parts.push(`${importBlock}\n`);
802
+ for (const rel of siblings) {
803
+ parts.push(
804
+ render(
805
+ rel,
806
+ `// ---- inlined from ${rel} (sc-6086: a first-party bundle ships as one module) ----\n`,
807
+ ),
808
+ );
809
+ }
810
+ parts.push(render(entryRel, ""));
811
+
812
+ // `;` between parts, not just a newline: a module whose last statement omits
813
+ // its semicolon would otherwise fuse with the next part's first token under
814
+ // ASI — `const f = () => x` followed by `(function(){})()` becomes a call,
815
+ // which parses cleanly and does the wrong thing.
816
+ return { source: parts.join("\n;\n"), modules: [entryRel, ...siblings] };
817
+ }