@jxsuite/compiler 2.0.8 → 3.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "2.0.8",
3
+ "version": "3.1.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,22 +42,23 @@
42
42
  "build:site": "bun src/cli.ts build",
43
43
  "test": "bun test --isolate",
44
44
  "test:coverage": "bun test --isolate --coverage",
45
- "upgrade": "bunx npm-check-updates -u && bun install"
45
+ "upgrade": "bun update --latest && bun install"
46
46
  },
47
47
  "dependencies": {
48
- "@apidevtools/json-schema-ref-parser": "^16.0.0",
49
- "@jxsuite/create": "^1.3.7",
50
- "@jxsuite/runtime": "^2.1.0",
51
- "@jxsuite/schema": "^1.9.0",
52
- "@vue/reactivity": "3.5.41",
48
+ "@apidevtools/json-schema-ref-parser": "^16.0.1",
49
+ "@jxsuite/create": "^1.3.9",
50
+ "@jxsuite/runtime": "^3.1.0",
51
+ "@jxsuite/schema": "^2.1.0",
52
+ "@jxsuite/site": "^1.1.0",
53
+ "@vue/reactivity": "3.5.42",
53
54
  "esbuild": "^0.28.2",
54
55
  "lit-html": "^3.3.3",
55
- "sharp": "^0.35.3"
56
+ "sharp": "^0.35.4"
56
57
  },
57
58
  "devDependencies": {
58
- "@happy-dom/global-registrator": "^20.11.6",
59
- "@jxsuite/connector": "^0.5.5",
60
- "@jxsuite/parser": "^1.5.6",
59
+ "@happy-dom/global-registrator": "^20.12.0",
60
+ "@jxsuite/connector": "^0.5.7",
61
+ "@jxsuite/parser": "^1.7.0",
61
62
  "@types/mdast": "^4.0.4"
62
63
  }
63
64
  }
package/src/shared.ts CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  COLOR_SCHEME_ATTR,
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
12
  RESERVED_KEYS,
13
+ booleanAttrValue,
13
14
  camelToKebab,
15
+ isSingleExpression,
14
16
  pureSchemeOf,
15
17
  resolveAtQuery,
16
18
  schemeSelectors,
@@ -55,7 +57,9 @@ export {
55
57
  COLOR_SCHEME_ATTR,
56
58
  COLOR_SCHEME_STORAGE_KEY,
57
59
  RESERVED_KEYS,
60
+ booleanAttrValue,
58
61
  camelToKebab,
62
+ isSingleExpression,
59
63
  pureSchemeOf,
60
64
  schemeSelectors,
61
65
  toCSSText,
@@ -107,11 +111,26 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
107
111
  * @returns {string}
108
112
  */
109
113
  export function srcImportBinding(key: string, def: unknown) {
110
- const declared = (def as { $export?: unknown } | null | undefined)?.$export;
111
- const exportName = typeof declared === "string" && declared !== "" ? declared : key;
114
+ const exportName = srcExportName(key, def);
112
115
  return exportName === key ? key : `${exportName} as ${key}`;
113
116
  }
114
117
 
118
+ /**
119
+ * The export a Function-def `$src` entry names inside its module: `$export`, or the state key.
120
+ *
121
+ * Separate from {@link srcImportBinding} because a lazily loaded `$src` has no import clause to
122
+ * alias in — it reaches the export off the module namespace object a dynamic `import()` resolves
123
+ * to, where the local name is chosen by the generated code and only the export name is fixed.
124
+ *
125
+ * @param {string} key - State key
126
+ * @param {unknown} def - The Function definition
127
+ * @returns {string}
128
+ */
129
+ export function srcExportName(key: string, def: unknown): string {
130
+ const declared = (def as { $export?: unknown } | null | undefined)?.$export;
131
+ return typeof declared === "string" && declared !== "" ? declared : key;
132
+ }
133
+
115
134
  /**
116
135
  * Emit the auto-fetch `effect()` for a `$prototype: "Request"` state entry.
117
136
  *
@@ -780,37 +799,6 @@ function readsRuntimeOnlyState(str: string, scope: Record<string, unknown>) {
780
799
  return false;
781
800
  }
782
801
 
783
- /**
784
- * Whether the whole string is exactly ONE `${…}` — the interpolation that opens at index 0 also
785
- * closes at the final character.
786
- *
787
- * A greedy `/^\$\{(.+)\}$/` also matched `"${a} / ${b}"`, spliced the interior into `return (a} /
788
- * ${b)`, and the SyntaxError became a silent null: the node rendered empty, and a `$head` entry
789
- * shipped its own template text. A brace-depth scan is exact where a tightened regex is not —
790
- * `"${`${a}-x`}"` is still one expression, and must keep returning a raw value.
791
- *
792
- * @param {string} str
793
- * @returns {boolean}
794
- */
795
- export function isSingleExpression(str: string): boolean {
796
- if (!str.startsWith("${") || !str.endsWith("}")) {
797
- return false;
798
- }
799
- let depth = 0;
800
- for (let i = 1; i < str.length; i += 1) {
801
- const c = str[i];
802
- if (c === "{") {
803
- depth += 1;
804
- } else if (c === "}") {
805
- depth -= 1;
806
- if (depth === 0) {
807
- return i === str.length - 1;
808
- }
809
- }
810
- }
811
- return false;
812
- }
813
-
814
802
  /**
815
803
  * @param {string} str
816
804
  * @param {Record<string, unknown>} scope
@@ -927,10 +915,34 @@ export function buildAttrs(def: JxElement | JxMutableNode, scope: Record<string,
927
915
  if (def.attributes) {
928
916
  for (const [k, v] of Object.entries(def.attributes)) {
929
917
  const value = resolveStaticValue(v, scope);
918
+ /*
919
+ * A boolean is the attribute's PRESENCE or its text, depending on which family HTML puts the
920
+ * attribute in — `booleanAttrValue` owns that judgement, and the runtime asks it the same
921
+ * question so a prerendered page cannot change meaning as it hydrates.
922
+ *
923
+ * @docs framework/concepts/elements
924
+ *
925
+ * Stringifying a boolean emitted `open="false"`, and HTML reads a boolean attribute by
926
+ * Presence alone — so the author wrote "closed" and the page rendered open. Nor could the
927
+ * Template return nothing instead: `resolveDocTemplates` and `expandMapTemplate` both `?? v`,
928
+ * Which restores the raw `${…}` source on a null. Absence therefore has to be expressible
929
+ * HERE, and `false` is the only value that can mean it.
930
+ *
931
+ * A *string* is never reinterpreted, in either family. `"aria-current": "false"` is the docs
932
+ * Sidebar's own not-the-current-page marker, and second-guessing it would be this same defect
933
+ * Pointed the other way.
934
+ */
935
+ if (typeof value === "boolean") {
936
+ const text = booleanAttrValue(k, value);
937
+ if (text !== null) {
938
+ out += text === "" ? ` ${k}` : ` ${k}="${escapeHtml(text)}"`;
939
+ }
940
+ continue;
941
+ }
930
942
  if (
931
943
  value !== null &&
932
944
  value !== undefined &&
933
- (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
945
+ (typeof value === "string" || typeof value === "number")
934
946
  ) {
935
947
  out += ` ${k}="${escapeHtml(String(value))}"`;
936
948
  }
@@ -1233,7 +1245,20 @@ export function compileStyles(
1233
1245
  if (rules.length === 0) {
1234
1246
  return "";
1235
1247
  }
1236
- return `<style>\n${rules.join("\n")}\n</style>`;
1248
+ return `<style>\n${escapeStyleText(rules.join("\n"))}\n</style>`;
1249
+ }
1250
+
1251
+ /**
1252
+ * Make CSS safe to sit inside a `<style>` element.
1253
+ *
1254
+ * An HTML parser ends a `<style>` at the first `</style` in the text, wherever it appears — inside
1255
+ * a CSS string, inside a comment, anywhere. Since a project's style values are author data
1256
+ * (`content: "</style>"` is a legal declaration), inlining them unescaped would let content close
1257
+ * the element and the rest of the stylesheet would render as markup. `\/` is a valid CSS escape for
1258
+ * `/`, so the declaration keeps its meaning.
1259
+ */
1260
+ export function escapeStyleText(css: string): string {
1261
+ return css.replaceAll(/<\/(?=style)/gi, String.raw`<\/`);
1237
1262
  }
1238
1263
 
1239
1264
  /**
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Base-path.ts — re-rooting a build's output onto the path the site is deployed under.
3
+ *
4
+ * A site at `https://example.pages.dev/m/my-site/` is served from `/m/my-site/`, and every
5
+ * absolute-path reference the build emits — `/assets/vue-reactivity.js`,
6
+ * `/components/site-counter.js`, `/images/hero.jpg` — resolves against `example.pages.dev/`
7
+ * instead, where nothing is. The page renders blank rather than merely wrong, because the runtime
8
+ * assets 404 first (issue 235).
9
+ *
10
+ * **The base is not a new option.** `project.json`'s `url` is the base URI every emitted reference
11
+ * is resolved against (RFC 3986 §5), and the compiler was resolving against its ORIGIN and
12
+ * discarding its path. `siteBasePath` in `@jxsuite/schema/asset-paths` reads the path back; a
13
+ * second `build.base` key would be a second writer on the one fact the canonical links and the
14
+ * sitemap are already built from, free to disagree with them.
15
+ *
16
+ * **The rewrite happens on finished HTML, and that is the point.** The alternative is to thread a
17
+ * prefix through the twelve emitters that can put a URL on a page — the import map and its
18
+ * modulepreloads, three page-template tiers that each write their own map, component scripts,
19
+ * island modules, sidecar and npm bundles, image `srcset`, `$head` entries, and whatever a page
20
+ * author wrote by hand. That list is exactly the kind that is complete until the next emitter is
21
+ * added, and #227 was already one emitter's map disagreeing with another's. One pass over the bytes
22
+ * that ship cannot drift from them.
23
+ *
24
+ * Outputs that are not HTML keep their own handling, because each has a syntax and none of them can
25
+ * be found by looking at a tag: `_headers`, `_redirects`, the service worker's precache list and
26
+ * scope, `manifest.webmanifest`, and the absolute URLs in `sitemap.xml` and `robots.txt`.
27
+ *
28
+ * Everything here is idempotent (see `withBase`), so a value an emitter already prefixed survives
29
+ * this pass unchanged.
30
+ *
31
+ * @docs framework/site/deployment
32
+ */
33
+
34
+ import { formatSrcset, parseSrcset, withBase } from "@jxsuite/schema/asset-paths";
35
+
36
+ /**
37
+ * Attributes whose value is a single URL.
38
+ *
39
+ * `href` and `src` are most of the traffic; the rest are the ones a Jx page can actually emit —
40
+ * `<form action>`, `<button formaction>`, `<video poster>`, `<object data>`. Anything not listed is
41
+ * left alone, which is the safe direction: a missed URL 404s visibly on a subpath deployment, while
42
+ * a rewritten non-URL corrupts a page that was working.
43
+ */
44
+ const URL_ATTRS = new Set(["href", "src", "poster", "action", "formaction", "data"]);
45
+
46
+ /** Attributes whose value is a `srcset` candidate list rather than one URL. */
47
+ const SRCSET_ATTRS = new Set(["srcset", "imagesrcset"]);
48
+
49
+ /**
50
+ * One HTML tag: the name, then attributes with quoted values consumed whole.
51
+ *
52
+ * Consuming quotes matters — a `>` inside an attribute value would otherwise end the tag early and
53
+ * leave the rest of its attributes as text this pass never looks at.
54
+ */
55
+ const TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
56
+
57
+ /** One attribute inside a tag's attribute blob. */
58
+ const ATTR_RE = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
59
+
60
+ /** `url(...)` inside a CSS value, capturing the optional quote so it can be put back. */
61
+ const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
62
+
63
+ /** A JSON string literal holding an absolute-path reference — an import map's keys and values. */
64
+ const JSON_ABS_PATH_RE = /"(\/[^"/][^"]*)"/g;
65
+
66
+ /** `<script type="importmap">…</script>`, whose body is JSON rather than markup. */
67
+ const IMPORTMAP_RE = /(<script\b[^>]*\btype\s*=\s*["']importmap["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
68
+
69
+ /** `<style>…</style>`, whose body is CSS rather than markup. */
70
+ const STYLE_BLOCK_RE = /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi;
71
+
72
+ /**
73
+ * `<meta>` names whose `content` is a URL.
74
+ *
75
+ * `content` is the one attribute here that usually holds prose, so it is rewritten only when the
76
+ * same tag says the value is a URL. OpenGraph and Twitter image/player metas are the cases a page
77
+ * actually produces from an author-written `/hero.jpg`.
78
+ */
79
+ const URL_META_RE =
80
+ /\b(?:property|name)\s*=\s*["'](?:og:|twitter:)[\w:.-]*(?:image|url|video|audio|player)[\w:.-]*["']/i;
81
+
82
+ /**
83
+ * A quote around a `url()` target that survived attribute escaping.
84
+ *
85
+ * In a `style` ATTRIBUTE the quotes inside `url("…")` are escaped, so the target arrives wrapped in
86
+ * `&quot;` rather than in a character {@link CSS_URL_RE} would have treated as a delimiter. Left
87
+ * alone the value no longer starts with `/` and is silently skipped — a background image that 404s
88
+ * on a subpath deployment with nothing to show for it.
89
+ */
90
+ const ENTITY_QUOTE_RE = /^(&quot;|&#34;|&apos;|&#39;)([\s\S]*)\1$/;
91
+
92
+ /** Rewrite every `url(...)` in a CSS fragment, in a `<style>` block or a `style` attribute. */
93
+ function rewriteCssUrls(css: string, base: string): string {
94
+ return css.replaceAll(CSS_URL_RE, (match, quote: string, inner: string) => {
95
+ const raw = inner.trim();
96
+ const escaped = ENTITY_QUOTE_RE.exec(raw);
97
+ const target = escaped ? escaped[2]! : raw;
98
+ const out = withBase(base, target);
99
+ if (out === target) {
100
+ return match;
101
+ }
102
+ return `url(${quote}${escaped ? `${escaped[1]}${out}${escaped[1]}` : out}${quote})`;
103
+ });
104
+ }
105
+
106
+ /** Rewrite one attribute value, or return it unchanged when the attribute carries no URL. */
107
+ function rewriteAttrValue(tag: string, attrs: string, name: string, value: string, base: string) {
108
+ const lower = name.toLowerCase();
109
+ if (URL_ATTRS.has(lower)) {
110
+ return withBase(base, value);
111
+ }
112
+ if (SRCSET_ATTRS.has(lower)) {
113
+ const candidates = parseSrcset(value);
114
+ return candidates.length === 0
115
+ ? value
116
+ : formatSrcset(
117
+ candidates.map((c) => ({ descriptor: c.descriptor, url: withBase(base, c.url) })),
118
+ );
119
+ }
120
+ if (lower === "style") {
121
+ return rewriteCssUrls(value, base);
122
+ }
123
+ if (lower === "content" && tag === "meta" && URL_META_RE.test(attrs)) {
124
+ return withBase(base, value);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * Re-root every site-absolute URL in a page's HTML onto `base`.
131
+ *
132
+ * `base` of `""` is a site at an origin root, and the HTML is returned untouched — no allocation
133
+ * and no risk for the deployment Jx documents.
134
+ *
135
+ * @param {string} html - A finished page
136
+ * @param {string} base - From `siteBasePath`
137
+ * @returns {string} The page as it should ship
138
+ */
139
+ export function rewriteHtmlBase(html: string, base: string): string {
140
+ if (base === "") {
141
+ return html;
142
+ }
143
+
144
+ let out = html.replaceAll(IMPORTMAP_RE, (_match, open: string, body: string, close: string) => {
145
+ /* The import map's KEYS are addressable too: a map may name a module by URL rather than by bare
146
+ specifier, and a key left at the old root would resolve to a file the build did not write. */
147
+ const rewritten = body.replaceAll(
148
+ JSON_ABS_PATH_RE,
149
+ (_literal, path: string) => `"${withBase(base, path)}"`,
150
+ );
151
+ return open + rewritten + close;
152
+ });
153
+
154
+ out = out.replaceAll(
155
+ STYLE_BLOCK_RE,
156
+ (_m, open: string, css: string, close: string) => open + rewriteCssUrls(css, base) + close,
157
+ );
158
+
159
+ return out.replaceAll(TAG_RE, (match, tagName: string, attrs: string) => {
160
+ if (attrs === "") {
161
+ return match;
162
+ }
163
+ const tag = tagName.toLowerCase();
164
+ const rewrittenAttrs = attrs.replaceAll(
165
+ ATTR_RE,
166
+ (whole, name: string, _quoted: string, dq: string | undefined, sq: string | undefined) => {
167
+ const quote = dq === undefined ? "'" : '"';
168
+ const value = dq ?? sq ?? "";
169
+ const next = rewriteAttrValue(tag, attrs, name, value, base);
170
+ return next === value ? whole : `${name}=${quote}${next}${quote}`;
171
+ },
172
+ );
173
+ return rewrittenAttrs === attrs ? match : `<${tagName}${rewrittenAttrs}>`;
174
+ });
175
+ }
@@ -92,6 +92,14 @@ export interface BundleOptions {
92
92
  conditions?: string[];
93
93
  /** Resolution hooks, applied before the bundler's own resolution. */
94
94
  resolver?: BundleResolver;
95
+ /**
96
+ * Whether to minify the bundle.
97
+ *
98
+ * Defaults to true for `target: "browser"` and false everywhere else: browser output is parsed on
99
+ * someone else's phone, server output is read off a stack trace. Callers override it — the site
100
+ * build from `build.minify`, and `jx dev` to keep a breakpoint on a line somebody wrote.
101
+ */
102
+ minify?: boolean;
95
103
  /**
96
104
  * Called with the absolute path of every file the bundler loads.
97
105
  *
@@ -161,6 +169,21 @@ export function workerBundleOptions(adapter: string): BundleOptions {
161
169
  */
162
170
  const BROWSER_DEFINE = { "process.env.NODE_ENV": '"production"' } as const;
163
171
 
172
+ /**
173
+ * Whether this bundle is minified.
174
+ *
175
+ * Browser output defaults to minified and everything else to readable. Measured on jxsuite.com's
176
+ * own deployed bytes, the six scripts a page loads go from 155,254 B to 64,994 B — and it is the 90
177
+ * kB of source a phone no longer has to parse, not the 16 kB it no longer downloads, that moves
178
+ * Total Blocking Time.
179
+ *
180
+ * This widens the backend divergence {@link BROWSER_DEFINE} warns about: the two minifiers agree on
181
+ * semantics, not on bytes. A build that diffs `dist/` must pin one backend (`JX_BUNDLER=esbuild`).
182
+ */
183
+ function shouldMinify(opts: BundleOptions): boolean {
184
+ return opts.minify ?? opts.target === "browser";
185
+ }
186
+
164
187
  /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
165
188
  function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
166
189
  return target === "node" || target === "bun" ? "node" : "browser";
@@ -193,7 +216,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
193
216
  external,
194
217
  format: "esm",
195
218
  logLevel: "silent",
196
- minify: false,
219
+ minify: shouldMinify(opts),
197
220
  outfile: request.outfile,
198
221
  platform: esbuildPlatform(opts.target),
199
222
  plugins: plugins as unknown as NonNullable<Parameters<typeof esbuild.build>[0]["plugins"]>,
@@ -207,7 +230,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
207
230
  entrypoints: [request.entryPath],
208
231
  external,
209
232
  format: "esm",
210
- minify: false,
233
+ minify: shouldMinify(opts),
211
234
  plugins,
212
235
  target: bunTarget(opts.target),
213
236
  throw: false,
@@ -254,7 +277,8 @@ export async function resolveThroughBundler(
254
277
  await bundleSource(
255
278
  `export * from ${JSON.stringify(specifier)};\n`,
256
279
  { outfile, resolveDir },
257
- { ...opts, onFileLoaded: (path) => loaded.push(path) },
280
+ // The output is deleted in `finally` — this only wants the resolution, so never pay to minify.
281
+ { ...opts, minify: false, onFileLoaded: (path) => loaded.push(path) },
258
282
  );
259
283
  } catch {
260
284
  return undefined;
@@ -286,7 +310,7 @@ export async function bundleSource(
286
310
  external: opts.external ?? [],
287
311
  format: "esm",
288
312
  logLevel: "silent",
289
- minify: false,
313
+ minify: shouldMinify(opts),
290
314
  outfile,
291
315
  platform: esbuildPlatform(opts.target),
292
316
  stdin: { contents: source, loader: "js", resolveDir, sourcefile: "jx-entry.js" },
@@ -131,6 +131,7 @@ export async function writeClientRuntime(
131
131
  runtime: ClientRuntime,
132
132
  outDir: string,
133
133
  resolveDir: string = PACKAGE_DIR,
134
+ minify = true,
134
135
  ): Promise<{ written: number; errors: string[] }> {
135
136
  const errors: string[] = [];
136
137
  let written = 0;
@@ -150,7 +151,7 @@ export async function writeClientRuntime(
150
151
  await bundleSource(
151
152
  `export * from ${JSON.stringify(mod.specifier)};\n`,
152
153
  { outfile, resolveDir },
153
- { target: "browser" },
154
+ { minify, target: "browser" },
154
155
  );
155
156
  written += 1;
156
157
  } catch (error) {
@@ -160,6 +161,52 @@ export async function writeClientRuntime(
160
161
  return { errors, written };
161
162
  }
162
163
 
164
+ /**
165
+ * The asset paths an import map's `imports` object asks THIS build to write.
166
+ *
167
+ * Two kinds of entry are named by a map and produced by nobody: a prefix key's value is the
168
+ * DIRECTORY its subpaths are served from — `/assets/lit-html/` — rather than a file, and a CDN
169
+ * fallback belongs to a third party. What is left is the exact set `writeClientRuntime` matches
170
+ * against, so the promise the HTML makes and the bundles the build writes are read off one rule.
171
+ */
172
+ export function importMapAssets(imports: Record<string, string>): string[] {
173
+ return Object.entries(imports)
174
+ .filter(([specifier, url]) => !isPrefixSpecifier(specifier) && url.startsWith("/"))
175
+ .map(([, url]) => url);
176
+ }
177
+
178
+ /**
179
+ * The same set, read back out of a finished page.
180
+ *
181
+ * A page reaches an import map by more than one route: `injectComponentScripts` adds one, and the
182
+ * page-template tiers in `compile-static`/`compile-client`/`compile-element` emit their own. Only
183
+ * the first of those was recording what it named, so a project with interactivity but no components
184
+ * shipped a map pointing at `/assets/vue-reactivity.js` while the set that gates bundling stayed
185
+ * empty — nothing was written, the page 404'd on both modules and rendered blank, and the build
186
+ * reported success (issue #227). Scanning the emitted HTML is what makes the two agree no matter
187
+ * which path produced the map.
188
+ */
189
+ export function importMapAssetsInHtml(html: string): string[] {
190
+ const found: string[] = [];
191
+ for (const match of html.matchAll(/<script type="importmap">([\s\S]*?)<\/script>/g)) {
192
+ let imports: unknown;
193
+ try {
194
+ ({ imports } = JSON.parse(match[1] as string) as { imports?: unknown });
195
+ } catch {
196
+ // A map the build did not write and cannot parse names nothing this build must produce.
197
+ continue;
198
+ }
199
+ if (typeof imports !== "object" || imports === null) {
200
+ continue;
201
+ }
202
+ const entries = Object.entries(imports as Record<string, unknown>).filter(
203
+ (entry): entry is [string, string] => typeof entry[1] === "string",
204
+ );
205
+ found.push(...importMapAssets(Object.fromEntries(entries)));
206
+ }
207
+ return found;
208
+ }
209
+
163
210
  /** Render the `<script type="importmap">` block for a resolved runtime. */
164
211
  export function renderImportMap(imports: Record<string, string>): string {
165
212
  const entries = Object.entries(imports)
@@ -216,11 +263,13 @@ const MAX_SUBPATH_PASSES = 10;
216
263
  *
217
264
  * @param {string} outDir - Build output root
218
265
  * @param {string} [resolveDir] - Directory bare specifiers resolve from
266
+ * @param {boolean} [minify] - Minify the emitted bundles (defaults to the browser-target default)
219
267
  * @returns {Promise<{ written: number; errors: string[] }>}
220
268
  */
221
269
  export async function writeRuntimeSubpaths(
222
270
  outDir: string,
223
271
  resolveDir: string = PACKAGE_DIR,
272
+ minify = true,
224
273
  ): Promise<{ written: number; errors: string[] }> {
225
274
  const errors: string[] = [];
226
275
  const done = new Set<string>();
@@ -280,6 +329,7 @@ export async function writeRuntimeSubpaths(
280
329
  `export * from ${JSON.stringify(specifier)};\n`,
281
330
  { outfile, resolveDir },
282
331
  {
332
+ minify,
283
333
  resolver: shareRuntimeCore(mod, outfile, await coreOf(mod), mirrors),
284
334
  target: "browser",
285
335
  },