@markii/pack-build 0.0.1 → 0.17.0-next.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.
@@ -0,0 +1,1259 @@
1
+ /**
2
+ * Compiles a pack's `.tsx` component sources into the SAME shape as a
3
+ * hand-written `webview.js` registration script (see
4
+ * `apps/vscode/test-fixtures/packs/demo/webview.js`'s own doc comment for
5
+ * the contract this must produce: a classic IIFE that reads
6
+ * `window.__markiiReact` LAZILY and calls
7
+ * `window.__markiiRegisterPack(manifestJson, componentModules)`).
8
+ *
9
+ * Why this exists: `docs/packs.md` describes a pack as source plus a
10
+ * manifest — nothing in this repository ever produces a prebuilt
11
+ * `webview.js` from that source. Without this module, a real pack shipped
12
+ * as `.tsx` sources (the ordinary case) discovers cleanly but contributes
13
+ * ZERO components to a preview, so every directive under its namespace
14
+ * silently falls through to the unknown-component fallback with no
15
+ * explanation.
16
+ *
17
+ * Lives in `@markii/pack-build` (not `apps/vscode`) because it is shared:
18
+ * any Markii host that wants to compile a pack from source needs the exact
19
+ * same builder, not a second drifting copy of it. `@markii/pack-build` is
20
+ * the only package that declares `esbuild-wasm`, so no neutral package
21
+ * carries a build toolchain (AGENTS.md's Stack section). This module knows nothing about a host's discovery mechanism
22
+ * (`markii.packs`, an Obsidian settings tab, …) — it only needs
23
+ * `PackBuildSource`: a pack's folder, manifest, and component paths, which
24
+ * a host's own discovery module can hand it directly (structurally
25
+ * compatible with `apps/vscode`'s `DiscoveredPack`, which is a superset).
26
+ *
27
+ * ## The in-process WebAssembly path (not the Node child-process path)
28
+ *
29
+ * esbuild-wasm ships two Node-reachable entry points with very different
30
+ * runtime behavior:
31
+ *
32
+ * - `lib/main.js` (the package's `main`): spawns `node bin/esbuild` as a
33
+ * CHILD PROCESS and talks to it over a pipe. This is the ordinary
34
+ * `esbuild`/`esbuild-wasm` Node experience, and is what this module used
35
+ * before this file moved here.
36
+ * - `lib/browser.js` (the package's `browser` field, meant for bundlers
37
+ * targeting a browser environment): runs the SAME Go-compiled esbuild
38
+ * core, but entirely IN PROCESS via `WebAssembly.compile`/`instantiate` —
39
+ * no child process, no `node` binary required at all.
40
+ *
41
+ * This module uses `lib/browser.js`, and that is a REQUIREMENT, not a
42
+ * preference: an Obsidian host runs inside Electron's renderer process,
43
+ * which ships no `node` binary on its `PATH` at all, so `lib/main.js`'s
44
+ * `child_process.spawn('node', ['bin/esbuild', ...])` fails immediately with
45
+ * `Error: The service was stopped: spawn node ENOENT` — confirmed
46
+ * empirically in a real Obsidian vault (Electron 43 / Node 24). The
47
+ * `lib/browser.js` path, initialized once with a compiled
48
+ * `WebAssembly.Module` (`loadEsbuildWasm` below), was confirmed working in
49
+ * that same vault: roughly 221ms to initialize and 809ms for a cold build,
50
+ * 69ms warm. It is also the FASTER option in plain Node — roughly 918ms
51
+ * total cold against 1554ms for the child-process path, measured against
52
+ * this exact builder — so both hosts use the SAME path; there is no
53
+ * per-host divergence to maintain.
54
+ *
55
+ * The consequence: `lib/browser.js` has no filesystem access whatsoever —
56
+ * esbuild will not open a single file on its own when run this way. Every
57
+ * source byte this builder's build needs — the synthetic entry, every
58
+ * declared component, every file ANY of those transitively imports (a
59
+ * relative helper module, a helper's own further helper, a directory
60
+ * `index`, CSS pulled in from a non-entry module) — is therefore supplied
61
+ * directly through an esbuild plugin (`virtualSourcePlugin` below) with
62
+ * `onResolve`/`onLoad` handlers over a private namespace.
63
+ *
64
+ * `virtualSourcePlugin` seeds itself from the SAME `sources`/`cssSources`
65
+ * maps `buildPackRegistrationScript` already reads via `node:fs` to compute
66
+ * its (necessarily incomplete, pre-build) cache key, but it does not stop
67
+ * there: `onResolve` performs REAL module resolution — exact path, then
68
+ * each of `.tsx`/`.ts`/`.jsx`/`.js`/`.mjs`/`.cjs`/`.css`, then the same list
69
+ * under a directory's own `index` — and reads a newly-discovered file
70
+ * itself via `node:fs` (fine: only *esbuild* has no filesystem here, the
71
+ * plugin is ordinary host code in both hosts this module ships in). A
72
+ * resolved path is rejected unless it stays inside the pack's own folder
73
+ * (`fs.realpath`-compared, so `..` segments and symlinks cannot walk out —
74
+ * defense in depth, packs are self-contained by contract already); a bare
75
+ * specifier other than `react`/`react-dom` is rejected with a clear
76
+ * "pack ... cannot resolve" message rather than falling through to
77
+ * esbuild's own resolver — which has no filesystem either and previously
78
+ * surfaced as an opaque `Cannot read directory ".": not implemented on js`
79
+ * once a lookup missed the pre-seeded maps. `onResolve` therefore always
80
+ * returns a definitive result (a resolved path or an explicit `errors`
81
+ * entry), never `undefined`, so esbuild's built-in resolver is never
82
+ * reached at all; `absWorkingDir` is also pinned to the pack's folder as a
83
+ * second guard against the same class of failure.
84
+ *
85
+ * Because the full file set a build touches can only be known AFTER the
86
+ * build runs, the cache key computed up front (`computeCacheKey`, over the
87
+ * manifest plus the declared components plus a best-effort static CSS
88
+ * scan) decides only the cache FILE NAME, not by itself whether a cached
89
+ * entry is still valid. Validity is a sidecar file
90
+ * (`<cacheBaseName>.sources.json`) written next to the cached script on
91
+ * every successful build, recording the path and content hash of every
92
+ * file `virtualSourcePlugin` actually loaded (the full transitive set,
93
+ * including helpers the static scan never saw). A later call with an
94
+ * existing cache file re-hashes exactly those recorded files; any missing
95
+ * or changed file is a miss, same as no sidecar at all. This is what makes
96
+ * editing a shared helper module — invisible to the pre-build key — still
97
+ * invalidate the cache.
98
+ *
99
+ * `react`/`react-dom` stay external (`lazyGlobalModulePlugin`, unchanged by
100
+ * any of this) — the OUTPUT this module produces is unchanged in shape:
101
+ * same IIFE, same `window.__markiiRegisterPack` call, same lazy
102
+ * `window.__markiiReact` access, same sibling stylesheet, same
103
+ * content-hash cache naming. Only how the build is INVOKED changed.
104
+ *
105
+ * ## The lazy-React contract
106
+ *
107
+ * A pack script loads BEFORE a host's main webview/renderer bundle sets
108
+ * `window.__markiiReact`. Every reference this module's compiled output
109
+ * makes to React — both a component's own JSX and any
110
+ * `import { useX } from 'react'` it writes — must therefore read
111
+ * `window.__markiiReact` only from INSIDE a function that runs at render
112
+ * time, never at the module's top level. Two building blocks make that true
113
+ * for arbitrary component source without needing to understand it:
114
+ *
115
+ * - JSX itself never imports `react` or `react/jsx-runtime` at all: a
116
+ * custom `jsxFactory`/`jsxFragment` pair (`__markiiJSX.createElement` /
117
+ * `__markiiJSX.Fragment`, defined by `JSX_SHIM_SOURCE` below as plain
118
+ * getters) replaces the automatic runtime, and a getter's body only
119
+ * runs when the property is actually read — i.e. at the JSX call site
120
+ * inside a component's render, not when the script loads. `JSX_SHIM_SOURCE`
121
+ * is compiled as an ordinary module of the build (seeded into
122
+ * `fileContents` under a synthetic path inside the pack's own folder) and
123
+ * wired into every module that references `__markiiJSX` as a free
124
+ * variable through esbuild's `inject` option — never through `banner`,
125
+ * which writes its text OUTSIDE the `format: 'iife'` wrapper and leaves
126
+ * `__markiiJSX` as a global on `window` (docs/packs.md's "must not leave
127
+ * globals behind beyond the one call it makes"). `inject` is what puts
128
+ * the shim in scope for every module that reads it, not only the entry:
129
+ * a `var` declared in the entry module alone is scoped to that module by
130
+ * the bundler, so a component reached only through a non-entry helper
131
+ * module never sees it and throws `__markiiJSX is not defined` at render
132
+ * time instead.
133
+ * - An explicit `import ... from 'react'` (hooks, `forwardRef`, etc.) is
134
+ * redirected by `lazyGlobalModulePlugin` to a virtual CommonJS module
135
+ * whose `module.exports` is a `Proxy` over `window.__markiiReact`. A
136
+ * `Proxy` get trap only fires when a property is actually read, so the
137
+ * `require()`/`__toESM()` machinery esbuild's own bundler generates for
138
+ * this import (unavoidably called at the module's top level) never
139
+ * itself touches `window.__markiiReact` — only a later, real property
140
+ * access (again, from inside a component body) does. This is verified
141
+ * empirically, not just argued, in `pack-build.test.ts` and
142
+ * `pack-build.fixture.test.ts`: a built script is loaded into a window
143
+ * with `window.__markiiReact` left `undefined`, and loading it alone
144
+ * must not throw.
145
+ */
146
+ import { createHash } from 'node:crypto';
147
+ import { createRequire } from 'node:module';
148
+ import * as path from 'node:path';
149
+ import { mkdir, readFile as nodeReadFile, realpath, rename, stat, unlink, writeFile, } from 'node:fs/promises';
150
+ import { existsSync } from 'node:fs';
151
+ import { packComponents } from '@markii/pack';
152
+ import { lintPackCss } from './pack-css-lint.js';
153
+ /** Bumped whenever this module's OUTPUT for the same pack sources would change (a shim/plugin tweak, an esbuild option change) — folded into the cache key (`computeCacheKey`) so a stale cached script from a previous version of this builder is never reused after an upgrade. Bumped again for the `inject`-based JSX shim (replacing `banner`): a pack built before this version leaves `__markiiJSX` as a global on `window`, so every pack must rebuild once to pick up the fix. */
154
+ const BUILDER_VERSION = 5;
155
+ /**
156
+ * `esbuild-wasm`'s browser entry unconditionally reaches for the `self`
157
+ * global while assembling its Go/WebAssembly runtime bridge — confirmed
158
+ * empirically against `esbuild-wasm@0.28.2`: both the worker-mode code path
159
+ * AND the in-process code path this module actually uses
160
+ * (`initialize({ worker: false })`) share the exact same generated source,
161
+ * which contains `for (let o = self; o; o = Object.getPrototypeOf(o)) ...`
162
+ * with no guard. A browser (or an Electron renderer, where an Obsidian host
163
+ * runs) always has `self`; a plain Node process — this repo's Vitest run,
164
+ * and the VS Code extension host — does not, and fails outright with
165
+ * `ReferenceError: self is not defined` without this shim. Applied
166
+ * defensively, once, and only when `self` is not already defined, so it is
167
+ * a no-op wherever a real `self` already exists.
168
+ */
169
+ function ensureSelfGlobal() {
170
+ const g = globalThis;
171
+ if (typeof g.self === 'undefined') {
172
+ g.self = globalThis;
173
+ }
174
+ }
175
+ /**
176
+ * `resolveRequire` picks HOW to get a runtime `require`, because this
177
+ * source is authored as ESM but ships in two different module formats: a
178
+ * packaged VS Code extension bundles it into `dist/extension.js` as CJS,
179
+ * where a real ambient `require` already exists and must be used directly
180
+ * — `import.meta.url` is EMPTY in a CJS bundle (esbuild warns exactly this
181
+ * at build time), so `createRequire(import.meta.url)` would resolve
182
+ * against nothing and fail. In dev/tsx and under Vitest, this file runs as
183
+ * genuine ESM, where `require` is not ambient and
184
+ * `createRequire(import.meta.url)` is the correct (and only) way to get
185
+ * one. Checking `typeof require` at runtime picks the right one in either
186
+ * format without needing two builds of this module.
187
+ *
188
+ * A runtime `require()` (never a static `import`) for `esbuild-wasm`
189
+ * itself is deliberate for the same reason it always was: a static
190
+ * `import * as esbuildWasm from 'esbuild-wasm/lib/browser.js'` at this
191
+ * module's top level would be a bundlable reference, which a host's own
192
+ * bundler (`apps/vscode/esbuild.config.mjs`'s `extensionBuild`, for
193
+ * instance) must be able to keep external — see that file's own doc
194
+ * comment for the other half of this contract.
195
+ */
196
+ function resolveRequire() {
197
+ if (typeof require === 'function') {
198
+ return require;
199
+ }
200
+ return createRequire(import.meta.url);
201
+ }
202
+ let cachedBuildFn;
203
+ let cachedInitPromise;
204
+ /**
205
+ * Loads and initializes esbuild-wasm's in-process, WebAssembly `build`
206
+ * function, lazily and once per process (a second `initialize()` call
207
+ * throws — esbuild-wasm's own restriction — so this is cached across every
208
+ * `buildPackRegistrationScript` call the process ever makes, cache hits
209
+ * included).
210
+ *
211
+ * `browserModulePath`, when given, is an absolute path to a REAL,
212
+ * unbundled copy of `esbuild-wasm`'s `lib/browser.js` — what a packaged
213
+ * host supplies (`apps/vscode/esbuild.config.mjs` copies it next to
214
+ * `dist/extension.js`; that file's own doc comment has the other half of
215
+ * this contract). `undefined` (the default, and what every test in this
216
+ * file uses) lets plain Node module resolution find the ordinary
217
+ * `esbuild-wasm` package under `node_modules`. `wasmBinaryPath` is the
218
+ * matching absolute path to the `esbuild.wasm` binary this function
219
+ * compiles via `WebAssembly.compile` before initializing; `undefined`
220
+ * resolves the ordinary package's copy the same way.
221
+ */
222
+ function loadEsbuildWasm(browserModulePath, wasmBinaryPath) {
223
+ if (cachedBuildFn) {
224
+ return Promise.resolve(cachedBuildFn);
225
+ }
226
+ if (!cachedInitPromise) {
227
+ cachedInitPromise = (async () => {
228
+ ensureSelfGlobal();
229
+ const req = resolveRequire();
230
+ const moduleSpecifier = browserModulePath ?? 'esbuild-wasm/lib/browser.js';
231
+ const mod = req(moduleSpecifier);
232
+ const wasmPath = wasmBinaryPath ?? req.resolve('esbuild-wasm/esbuild.wasm');
233
+ const wasmBytes = await nodeReadFile(wasmPath);
234
+ const wasmModule = await WebAssembly.compile(wasmBytes);
235
+ try {
236
+ await mod.initialize({ wasmModule, worker: false });
237
+ }
238
+ catch (error) {
239
+ // This module-level cache does not survive a HOST reload, but the
240
+ // esbuild-wasm module can: Obsidian re-evaluates a plugin's
241
+ // main.js on disable/enable while the renderer's require cache
242
+ // keeps the already-initialized esbuild-wasm instance, so the
243
+ // fresh plugin's first initialize() hits its once-per-instance
244
+ // guard. That module IS initialized and its build() works — reuse
245
+ // it. Any other failure is a real one and still propagates.
246
+ const message = error instanceof Error ? error.message : String(error);
247
+ if (!message.includes('more than once'))
248
+ throw error;
249
+ }
250
+ cachedBuildFn = mod.build;
251
+ return mod.build;
252
+ })();
253
+ }
254
+ return cachedInitPromise;
255
+ }
256
+ /** Every named export the installed `react`/`react-dom` (18.3.x) actually ship, confirmed by inspecting the real packages under `node_modules` — the fixed key list `lazyGlobalModulePlugin`'s virtual modules report via their `Proxy`'s `ownKeys` trap (a `Proxy` cannot report "every possible key"; it must report a concrete list for `Object.getOwnPropertyNames`/esbuild's `__copyProps` helper to iterate). Deliberately excludes the `__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED` internals export — no legitimate pack component reaches for it. */
257
+ const REACT_KEYS = [
258
+ 'Children',
259
+ 'Component',
260
+ 'Fragment',
261
+ 'Profiler',
262
+ 'PureComponent',
263
+ 'StrictMode',
264
+ 'Suspense',
265
+ 'act',
266
+ 'cloneElement',
267
+ 'createContext',
268
+ 'createElement',
269
+ 'createFactory',
270
+ 'createRef',
271
+ 'forwardRef',
272
+ 'isValidElement',
273
+ 'lazy',
274
+ 'memo',
275
+ 'startTransition',
276
+ 'unstable_act',
277
+ 'useCallback',
278
+ 'useContext',
279
+ 'useDebugValue',
280
+ 'useDeferredValue',
281
+ 'useEffect',
282
+ 'useId',
283
+ 'useImperativeHandle',
284
+ 'useInsertionEffect',
285
+ 'useLayoutEffect',
286
+ 'useMemo',
287
+ 'useReducer',
288
+ 'useRef',
289
+ 'useState',
290
+ 'useSyncExternalStore',
291
+ 'useTransition',
292
+ 'version',
293
+ ];
294
+ const REACT_DOM_KEYS = [
295
+ 'createPortal',
296
+ 'findDOMNode',
297
+ 'flushSync',
298
+ 'hydrate',
299
+ 'render',
300
+ 'unmountComponentAtNode',
301
+ 'unstable_batchedUpdates',
302
+ 'unstable_renderSubtreeIntoContainer',
303
+ 'version',
304
+ ];
305
+ const REACT_DOM_CLIENT_KEYS = ['createRoot', 'hydrateRoot'];
306
+ /** Module specifier -> (global property name on `window`, exported key list) for every import `lazyGlobalModulePlugin` redirects to a lazy `Proxy`. A host's own webview/renderer bundle is the ONE place that ever sets `window.__markiiReact`/`window.__markiiReactDom`; a pack component reads the SAME instance, never its own copy. */
307
+ const LAZY_GLOBAL_MODULES = new Map([
308
+ ['react', { globalName: '__markiiReact', keys: REACT_KEYS }],
309
+ ['react-dom', { globalName: '__markiiReactDom', keys: REACT_DOM_KEYS }],
310
+ [
311
+ 'react-dom/client',
312
+ { globalName: '__markiiReactDom', keys: REACT_DOM_CLIENT_KEYS },
313
+ ],
314
+ ]);
315
+ /**
316
+ * Source text for one virtual lazy-global module: a `Proxy` whose every
317
+ * trap reads `window[globalName]` at call time, never at load time — see
318
+ * this file's top doc comment. `ownKeys`/`getOwnPropertyDescriptor` report
319
+ * the fixed `keys` list so esbuild's `__copyProps` helper (generated for
320
+ * `import { x } from '<specifier>'`) can enumerate and re-export them as
321
+ * lazy getters, without ever eagerly reading a value itself (`__copyProps`
322
+ * defines `{ get: () => from[key] }` descriptors — defining a getter never
323
+ * invokes it).
324
+ *
325
+ * `get`/`has` are guarded to the SAME `keys` allowlist as
326
+ * `getOwnPropertyDescriptor`, on purpose: esbuild's own `__toESM` helper
327
+ * (generated for every `import ... from '<specifier>'`, including a bare
328
+ * namespace import) probes `mod.__esModule` at the TOP of the compiled
329
+ * bundle to decide CJS/ESM interop — an unconditional `get` trap would
330
+ * forward that probe straight to `window[globalName]`, defeating the
331
+ * whole point of this module (confirmed empirically: without this guard,
332
+ * loading a script that merely imports from `react` reads
333
+ * `window.__markiiReact` once before any component ever renders). Denying
334
+ * every key outside `keys` up front means only a genuine, intentional
335
+ * property read of something this module actually re-exports ever reaches
336
+ * `window[globalName]`.
337
+ */
338
+ function lazyGlobalModuleSource(globalName, keys, globalRoot = 'window') {
339
+ const rootRead = globalRoot === 'window'
340
+ ? `(typeof window !== 'undefined' && window[${JSON.stringify(globalName)}])`
341
+ : `globalThis[${JSON.stringify(globalName)}]`;
342
+ return [
343
+ `function __markiiTarget() { return ${rootRead} || {}; }`,
344
+ `var __markiiKeys = ${JSON.stringify(keys)};`,
345
+ 'module.exports = new Proxy({}, {',
346
+ ' get(_t, prop) {',
347
+ ' if (__markiiKeys.indexOf(prop) === -1) return undefined;',
348
+ ' return __markiiTarget()[prop];',
349
+ ' },',
350
+ ' has(_t, prop) { return __markiiKeys.indexOf(prop) !== -1; },',
351
+ ' ownKeys() { return __markiiKeys; },',
352
+ ' getOwnPropertyDescriptor(_t, prop) {',
353
+ ' if (__markiiKeys.indexOf(prop) === -1) return undefined;',
354
+ ' return { enumerable: true, configurable: true };',
355
+ ' },',
356
+ '});',
357
+ ].join('\n');
358
+ }
359
+ /** Every named export `ink` ships (the version this repo pins), the fixed key list the terminal build's lazy `ink` module reports. */
360
+ const INK_KEYS = [
361
+ 'Box',
362
+ 'Newline',
363
+ 'Spacer',
364
+ 'Static',
365
+ 'Text',
366
+ 'Transform',
367
+ 'measureElement',
368
+ 'render',
369
+ 'renderToString',
370
+ 'useAnimation',
371
+ 'useApp',
372
+ 'useBoxMetrics',
373
+ 'useCursor',
374
+ 'useFocus',
375
+ 'useFocusManager',
376
+ 'useInput',
377
+ 'useIsScreenReaderEnabled',
378
+ 'usePaste',
379
+ 'useStderr',
380
+ 'useStdin',
381
+ 'useStdout',
382
+ 'useWindowSize',
383
+ ];
384
+ /** The terminal build's redirected imports: `react` and `ink` read the instances the CLI's engine publishes on `globalThis` (`@markii/ansi`'s `installTerminalPackGlobals`). */
385
+ const TERMINAL_LAZY_GLOBAL_MODULES = new Map([
386
+ ['react', { globalName: '__markiiReactInk', keys: REACT_KEYS }],
387
+ ['ink', { globalName: '__markiiInk', keys: INK_KEYS }],
388
+ ]);
389
+ const LAZY_GLOBAL_NAMESPACE = 'markii-lazy-global';
390
+ /** An esbuild plugin that intercepts `import ... from 'react'` (and the other specifiers in `modules`) and serves `lazyGlobalModuleSource` instead — so the compiled script never bundles its own copy of React, and never reads the global outside a getter/proxy trap. */
391
+ function lazyGlobalModulePlugin(modules = LAZY_GLOBAL_MODULES, globalRoot = 'window') {
392
+ const specifiers = new Set(modules.keys());
393
+ return {
394
+ name: 'markii-lazy-global',
395
+ setup(build) {
396
+ build.onResolve({ filter: /^(react|react-dom|ink)(\/.*)?$/ }, (args) => {
397
+ if (!specifiers.has(args.path))
398
+ return undefined;
399
+ return { path: args.path, namespace: LAZY_GLOBAL_NAMESPACE };
400
+ });
401
+ build.onLoad({ filter: /.*/, namespace: LAZY_GLOBAL_NAMESPACE }, (args) => {
402
+ const entry = modules.get(args.path);
403
+ const resolved = entry ?? {
404
+ globalName: '__markiiReact',
405
+ keys: REACT_KEYS,
406
+ };
407
+ return {
408
+ contents: lazyGlobalModuleSource(resolved.globalName, resolved.keys, globalRoot),
409
+ loader: 'js',
410
+ };
411
+ });
412
+ },
413
+ };
414
+ }
415
+ const VIRTUAL_SOURCE_NAMESPACE = 'markii-pack-source';
416
+ /** `path.extname` -> esbuild `Loader`, for every extension this builder's own virtual sources ever carry. Anything else loads as plain `text` — never `js`/`ts`, since silently parsing an unrecognized file as a script would be worse than a clear "could not resolve" build failure. */
417
+ function loaderForPath(absolutePath) {
418
+ switch (path.extname(absolutePath)) {
419
+ case '.tsx':
420
+ return 'tsx';
421
+ case '.ts':
422
+ return 'ts';
423
+ case '.jsx':
424
+ return 'jsx';
425
+ case '.css':
426
+ return 'css';
427
+ case '.js':
428
+ return 'js';
429
+ default:
430
+ return 'text';
431
+ }
432
+ }
433
+ /** Extensions this builder's real module resolution (`resolveImportCandidate`) tries, in order, after the exact specifier itself — both bare (`./guard` -> `./guard.ts`) and as a directory `index` (`./util` -> `./util/index.ts`). Mirrors ordinary Node/bundler resolution closely enough for a pack's own self-contained sources; a pack never resolves against `node_modules`. */
434
+ const RESOLUTION_EXTENSIONS = [
435
+ '.tsx',
436
+ '.ts',
437
+ '.jsx',
438
+ '.js',
439
+ '.mjs',
440
+ '.cjs',
441
+ '.css',
442
+ ];
443
+ /** True when `candidate` exists and is a regular file (never a directory: a directory only ever matches through the `index.*` arm of `resolveImportCandidate`, so a bare directory hit there must fall through to the next extension rather than "resolving" to the directory itself). */
444
+ async function isResolvableFile(candidate) {
445
+ try {
446
+ return (await stat(candidate)).isFile();
447
+ }
448
+ catch {
449
+ return false;
450
+ }
451
+ }
452
+ /**
453
+ * Real module resolution for one already-joined base path (an absolute
454
+ * specifier as-is, or a relative specifier already resolved against its
455
+ * importer's directory): tries `base` itself, then `base` plus each of
456
+ * `RESOLUTION_EXTENSIONS`, then `base` as a directory with `index` plus
457
+ * each extension — the order this file's top doc comment and AGENTS.md's
458
+ * task both specify. Resolves to `undefined` (never throws) when nothing
459
+ * on disk matches any candidate.
460
+ */
461
+ async function resolveImportCandidate(base) {
462
+ if (await isResolvableFile(base))
463
+ return base;
464
+ for (const ext of RESOLUTION_EXTENSIONS) {
465
+ const withExt = `${base}${ext}`;
466
+ if (await isResolvableFile(withExt))
467
+ return withExt;
468
+ }
469
+ for (const ext of RESOLUTION_EXTENSIONS) {
470
+ const indexPath = path.join(base, `index${ext}`);
471
+ if (await isResolvableFile(indexPath))
472
+ return indexPath;
473
+ }
474
+ return undefined;
475
+ }
476
+ /** One `onResolve`/`onLoad` failure, shaped as esbuild's own `OnResolveResult.errors` — used for every rejection `virtualSourcePlugin` produces (unresolvable bare specifier, resolution miss, jail violation, unreadable file) so esbuild treats it as a normal build error (surfaced through `result.errors` exactly like a real syntax error) rather than falling through to its own filesystem-less default resolver. */
477
+ function resolveFailure(text) {
478
+ return { errors: [{ text }] };
479
+ }
480
+ /**
481
+ * The plugin that stands in for filesystem access entirely: `lib/browser.js`
482
+ * (see this file's top doc comment) never reads a file on its own, so every
483
+ * source byte this build touches must be supplied here. `fileContents` is
484
+ * the SAME mutable map `buildPackRegistrationScript` seeds from its
485
+ * pre-build `sources`/`cssSources` scan — this plugin both reads from it
486
+ * (a component's own absolute path, a statically-found CSS file) and
487
+ * WRITES to it as resolution discovers further files (a relative helper
488
+ * import, a helper's own helper, a directory `index`, CSS imported from a
489
+ * non-entry module), so that after a successful build `fileContents` holds
490
+ * the complete, real transitive file set — what the sidecar cache records.
491
+ *
492
+ * `onResolve` handles three shapes: an ALREADY-ABSOLUTE specifier (what
493
+ * `entrySource` writes for each declared component, and what esbuild's
494
+ * `inject` option resolves the JSX shim's path as) is looked up as its own
495
+ * base path; a RELATIVE specifier (`./guard`, `./util`, `./x.css`, …) is
496
+ * resolved against `path.dirname(args.importer)` first; anything else is a
497
+ * BARE specifier, which — `react`/`react-dom` having already been claimed
498
+ * by `lazyGlobalModulePlugin` earlier in the `plugins` array — this pack
499
+ * cannot supply (a pack ships its own sources only, never a `node_modules`
500
+ * dependency) and is rejected outright with a clear reason naming the pack
501
+ * and the specifier. A resolved candidate that falls outside
502
+ * `jailRealRoot` (the pack's own folder, real-path compared so `..` and
503
+ * symlinks cannot walk out) is rejected the same way — EXCEPT a path in
504
+ * `virtualOnlyPaths`, which never exists on disk (the JSX shim, seeded
505
+ * directly into `fileContents` under a synthetic in-pack path — see
506
+ * `buildPackRegistrationScript`) and so cannot be `realpath`-checked at
507
+ * all; it is trusted outright, since this builder wrote its content itself
508
+ * rather than reading it from anywhere a pack author controls. Every other
509
+ * rejection is an explicit `errors` result, never `undefined` — so
510
+ * esbuild's own filesystem-less default resolver is never reached.
511
+ */
512
+ function virtualSourcePlugin(packName, fileContents, jailRealRoot, virtualOnlyPaths) {
513
+ return {
514
+ name: 'markii-pack-source',
515
+ setup(build) {
516
+ build.onResolve({ filter: /.*/ }, async (args) => {
517
+ let base;
518
+ if (path.isAbsolute(args.path)) {
519
+ base = args.path;
520
+ }
521
+ else if (args.path.startsWith('./') ||
522
+ args.path.startsWith('../')) {
523
+ base = path.resolve(path.dirname(args.importer), args.path);
524
+ }
525
+ else {
526
+ return resolveFailure(`pack "${packName}": cannot resolve "${args.path}" — a pack import must be a relative path within the pack, or "react"/"react-dom"; other bare package specifiers are not supported (packs are self-contained).`);
527
+ }
528
+ const resolved = fileContents.has(base)
529
+ ? base
530
+ : await resolveImportCandidate(base);
531
+ if (resolved === undefined) {
532
+ return resolveFailure(`pack "${packName}": could not resolve "${args.path}" imported from "${args.importer}"`);
533
+ }
534
+ if (virtualOnlyPaths.has(resolved)) {
535
+ // Never exists on disk (the JSX shim) — realpath would throw
536
+ // ENOENT. Its content was written by this builder, not read
537
+ // from the pack, so the jail check has nothing to verify.
538
+ return { path: resolved, namespace: VIRTUAL_SOURCE_NAMESPACE };
539
+ }
540
+ let real;
541
+ try {
542
+ real = await realpath(resolved);
543
+ }
544
+ catch (err) {
545
+ return resolveFailure(`pack "${packName}": could not read "${resolved}" resolving "${args.path}": ${describeThrown(err)}`);
546
+ }
547
+ if (real !== jailRealRoot &&
548
+ !real.startsWith(jailRealRoot + path.sep)) {
549
+ return resolveFailure(`pack "${packName}": import "${args.path}" resolves to "${resolved}", which is outside the pack's own folder — a pack must be self-contained.`);
550
+ }
551
+ if (!fileContents.has(resolved)) {
552
+ let text;
553
+ try {
554
+ text = await nodeReadFile(resolved, 'utf8');
555
+ }
556
+ catch (err) {
557
+ return resolveFailure(`pack "${packName}": could not read "${resolved}" resolving "${args.path}": ${describeThrown(err)}`);
558
+ }
559
+ fileContents.set(resolved, text);
560
+ }
561
+ return { path: resolved, namespace: VIRTUAL_SOURCE_NAMESPACE };
562
+ });
563
+ build.onLoad({ filter: /.*/, namespace: VIRTUAL_SOURCE_NAMESPACE }, (args) => {
564
+ return {
565
+ contents: fileContents.get(args.path) ?? '',
566
+ loader: loaderForPath(args.path),
567
+ resolveDir: path.dirname(args.path),
568
+ };
569
+ });
570
+ },
571
+ };
572
+ }
573
+ /**
574
+ * The synthetic file name the JSX shim module is seeded into `fileContents`
575
+ * under, joined to the pack's own folder so `virtualSourcePlugin`'s jail
576
+ * check has an absolute in-pack path to reason about (see
577
+ * `buildPackRegistrationScript`'s use of `JSX_SHIM_FILENAME` for the actual
578
+ * jail bypass). Deliberately distinctive and double-underscore-prefixed so
579
+ * an ordinary component source is exceedingly unlikely to collide with it;
580
+ * `buildPackRegistrationScript` still checks for a real file at that path
581
+ * before building and fails cleanly (never silently shadows it) if one
582
+ * exists.
583
+ */
584
+ const JSX_SHIM_FILENAME = '__markii-jsx-shim.js';
585
+ /**
586
+ * Custom JSX compiler options: JSX never imports `react`/`react/jsx-runtime`
587
+ * at all. `__markiiJSX` (this module's source, below) exposes
588
+ * `createElement`/`Fragment` as GETTERS over `window.__markiiReact` — read
589
+ * only at the point a JSX expression actually evaluates (inside a
590
+ * component's render), never at the top of the compiled script.
591
+ *
592
+ * Compiled as an ordinary ESM module (not a `banner`) and wired in through
593
+ * esbuild's `inject` option, so it stays inside the `format: 'iife'`
594
+ * wrapper and reaches every module that references `__markiiJSX`, not only
595
+ * the entry — see this file's top doc comment ("The lazy-React contract")
596
+ * for why a `banner` or an entry-only `var` cannot do this.
597
+ */
598
+ const JSX_SHIM_SOURCE = [
599
+ 'export var __markiiJSX = {',
600
+ ' get createElement() {',
601
+ " return ((typeof window !== 'undefined' && window.__markiiReact) || {}).createElement;",
602
+ ' },',
603
+ ' get Fragment() {',
604
+ " return ((typeof window !== 'undefined' && window.__markiiReact) || {}).Fragment;",
605
+ ' },',
606
+ '};',
607
+ '',
608
+ ].join('\n');
609
+ /** The engine id whose sources the webview build compiles (`@markii/react`'s `REACT_ENGINE_ID`, kept local so this module imports no renderer). */
610
+ const WEBVIEW_ENGINE = 'react';
611
+ /** The engine id whose sources the terminal build compiles (`@markii/ansi`'s `ANSI_ENGINE_ID`). */
612
+ const TERMINAL_ENGINE = 'ansi';
613
+ /** One component's declared local name and the absolute path to its source, in `manifest.components` declaration order. Reads the manifest through `@markii/pack`'s `packComponents()` (the one accessor that normalizes both the string shorthand and the object form) rather than walking `pack.manifest.components` directly. */
614
+ function orderedComponents(pack, engine = WEBVIEW_ENGINE) {
615
+ const paths = engine === WEBVIEW_ENGINE
616
+ ? pack.componentPaths
617
+ : (pack.componentPathsByEngine?.[engine] ?? {});
618
+ const result = [];
619
+ for (const listing of packComponents(pack.manifest)) {
620
+ const sourcePath = Object.hasOwn(paths, listing.localName)
621
+ ? paths[listing.localName]
622
+ : undefined;
623
+ if (sourcePath === undefined)
624
+ continue;
625
+ result.push({ localName: listing.localName, sourcePath });
626
+ }
627
+ return result;
628
+ }
629
+ /**
630
+ * The synthetic entry module esbuild compiles (via its `stdin` option, so
631
+ * nothing needs to exist on disk for it): imports every declared
632
+ * component's source, picks the exported component FUNCTION from each
633
+ * module (`__markiiPick` below — the pack author's own export style,
634
+ * named or `default`, is a host concern, taking the first function-typed
635
+ * export covers both without requiring a convention this repo has never
636
+ * documented), and calls `window.__markiiRegisterPack` exactly like
637
+ * `apps/vscode/test-fixtures/packs/demo/webview.js` does by hand.
638
+ */
639
+ function entrySource(pack, components) {
640
+ const imports = components
641
+ .map((c, i) => `import * as __markiiMod${i} from ${JSON.stringify(c.sourcePath)};`)
642
+ .join('\n');
643
+ const picks = components
644
+ .map((c, i) => ` ${JSON.stringify(c.localName)}: __markiiPick(__markiiMod${i}),`)
645
+ .join('\n');
646
+ const manifestJsonLiteral = JSON.stringify(JSON.stringify(pack.manifest));
647
+ // Which local names the manifest declares `kind: "inline"` for, baked
648
+ // into the artifact so a standalone consumer of the registration
649
+ // convention sees a truthful `inline` flag (loadPack re-derives it from
650
+ // the manifest anyway; this keeps the generated script honest on its
651
+ // own). Every other kind, and an absent kind, is a block form.
652
+ const inlineNames = packComponents(pack.manifest)
653
+ .filter((listing) => listing.kind === 'inline')
654
+ .map((listing) => listing.localName);
655
+ const inlineMapLiteral = JSON.stringify(Object.fromEntries(inlineNames.map((name) => [name, true])));
656
+ return [
657
+ imports,
658
+ '',
659
+ 'function __markiiPick(mod) {',
660
+ " if (mod && typeof mod['default'] === 'function') return mod['default'];",
661
+ ' for (var key in mod) {',
662
+ " if (Object.prototype.hasOwnProperty.call(mod, key) && typeof mod[key] === 'function') return mod[key];",
663
+ ' }',
664
+ ' return undefined;',
665
+ '}',
666
+ '',
667
+ 'var __markiiComponents = {',
668
+ picks,
669
+ '};',
670
+ '',
671
+ "if (typeof window !== 'undefined' && typeof window.__markiiRegisterPack === 'function') {",
672
+ ' var __markiiEntries = {};',
673
+ ` var __markiiInline = ${inlineMapLiteral};`,
674
+ ' for (var __markiiLocalName in __markiiComponents) {',
675
+ ' if (!Object.prototype.hasOwnProperty.call(__markiiComponents, __markiiLocalName)) continue;',
676
+ " if (typeof __markiiComponents[__markiiLocalName] !== 'function') continue;",
677
+ ' __markiiEntries[__markiiLocalName] = { component: __markiiComponents[__markiiLocalName], inline: __markiiInline[__markiiLocalName] === true };',
678
+ ' }',
679
+ ` window.__markiiRegisterPack(${manifestJsonLiteral}, __markiiEntries);`,
680
+ '}',
681
+ '',
682
+ ].join('\n');
683
+ }
684
+ /**
685
+ * The terminal build's JSX shim: the same lazy-getter shape as
686
+ * `JSX_SHIM_SOURCE`, over `globalThis.__markiiReactInk` (the React instance
687
+ * Ink uses). Read only when a JSX expression evaluates inside a render-time
688
+ * function, so evaluating `terminal.js` with the global undefined never throws.
689
+ */
690
+ const TERMINAL_JSX_SHIM_SOURCE = [
691
+ 'export var __markiiJSX = {',
692
+ ' get createElement() {',
693
+ ' return (globalThis.__markiiReactInk || {}).createElement;',
694
+ ' },',
695
+ ' get Fragment() {',
696
+ ' return (globalThis.__markiiReactInk || {}).Fragment;',
697
+ ' },',
698
+ '};',
699
+ '',
700
+ ].join('\n');
701
+ /**
702
+ * The synthetic entry of a terminal build: the same component picking as
703
+ * `entrySource`, ending in `globalThis.__markiiRegisterTerminalPack(
704
+ * manifestJson, modules)` instead of the webview's `window` call. Calls the
705
+ * hook when it is a function; a bare evaluation outside a host does nothing.
706
+ */
707
+ function terminalEntrySource(pack, components) {
708
+ const imports = components
709
+ .map((c, i) => `import * as __markiiMod${i} from ${JSON.stringify(c.sourcePath)};`)
710
+ .join('\n');
711
+ const picks = components
712
+ .map((c, i) => ` ${JSON.stringify(c.localName)}: __markiiPick(__markiiMod${i}),`)
713
+ .join('\n');
714
+ const manifestJsonLiteral = JSON.stringify(JSON.stringify(pack.manifest));
715
+ const inlineNames = packComponents(pack.manifest)
716
+ .filter((listing) => listing.kind === 'inline')
717
+ .map((listing) => listing.localName);
718
+ const inlineMapLiteral = JSON.stringify(Object.fromEntries(inlineNames.map((name) => [name, true])));
719
+ return [
720
+ imports,
721
+ '',
722
+ 'function __markiiPick(mod) {',
723
+ " if (mod && typeof mod['default'] === 'function') return mod['default'];",
724
+ ' for (var key in mod) {',
725
+ " if (Object.prototype.hasOwnProperty.call(mod, key) && typeof mod[key] === 'function') return mod[key];",
726
+ ' }',
727
+ ' return undefined;',
728
+ '}',
729
+ '',
730
+ 'var __markiiComponents = {',
731
+ picks,
732
+ '};',
733
+ '',
734
+ "if (typeof globalThis.__markiiRegisterTerminalPack === 'function') {",
735
+ ' var __markiiEntries = {};',
736
+ ` var __markiiInline = ${inlineMapLiteral};`,
737
+ ' for (var __markiiLocalName in __markiiComponents) {',
738
+ ' if (!Object.prototype.hasOwnProperty.call(__markiiComponents, __markiiLocalName)) continue;',
739
+ " if (typeof __markiiComponents[__markiiLocalName] !== 'function') continue;",
740
+ ' __markiiEntries[__markiiLocalName] = { component: __markiiComponents[__markiiLocalName], inline: __markiiInline[__markiiLocalName] === true };',
741
+ ' }',
742
+ ` globalThis.__markiiRegisterTerminalPack(${manifestJsonLiteral}, __markiiEntries);`,
743
+ '}',
744
+ '',
745
+ ].join('\n');
746
+ }
747
+ /**
748
+ * A best-effort, static scan of one component source file for side-effect
749
+ * `import '...css'` specifiers (`import './hn-list.css';`, and the `import
750
+ * styles from './x.css'` shape too, though a pack CSS component is expected
751
+ * to use the plain side-effect form per the pack CSS design). Resolves only
752
+ * RELATIVE specifiers (`./...`/`../...`) against `sourceDir` — a bare
753
+ * specifier (`import 'some-package/x.css'`) is out of scope for a pack,
754
+ * which ships its own sources and never a `node_modules` dependency of its
755
+ * own. This is intentionally a regex, not a real JS/TS parser: a specifier
756
+ * this misses is not a build failure any more: virtualSourcePlugin resolves
757
+ * the real file itself, on demand, during the build. This scan only gives the
758
+ * PRE-build cache key (computeCacheKey, which decides the cache file's name)
759
+ * a head start on catching a component's own direct CSS edit without a
760
+ * rebuild's help; a specifier it misses (a helper module's own .css import,
761
+ * say) is still caught correctly on the NEXT call by the sidecar cache (this
762
+ * file's top doc comment) once the file has actually been loaded once.
763
+ */
764
+ const CSS_IMPORT_RE = /\bimport\s+(?:[^'";]+\s+from\s+)?["']([^"']+\.css)["']/g;
765
+ function staticCssImportPaths(source, sourceDir) {
766
+ const specifiers = [];
767
+ CSS_IMPORT_RE.lastIndex = 0;
768
+ let match;
769
+ while ((match = CSS_IMPORT_RE.exec(source)) !== null) {
770
+ const specifier = match[1];
771
+ if (specifier === undefined)
772
+ continue;
773
+ if (!specifier.startsWith('./') && !specifier.startsWith('../'))
774
+ continue;
775
+ specifiers.push(path.resolve(sourceDir, specifier));
776
+ }
777
+ return specifiers;
778
+ }
779
+ /**
780
+ * The PRE-build cache key for one pack: a SHA-256 of `BUILDER_VERSION`, the
781
+ * manifest (stable-stringified: `Object.keys` order for a plain
782
+ * `Record<string,string>` from `parsePackManifest`'s own construction is
783
+ * already insertion order, and there is exactly one manifest shape), every
784
+ * declared component's absolute path plus its own source bytes, and every
785
+ * CSS file the component sources statically import (`cssSources`, keyed the
786
+ * same way) — so touching any one component file, any CSS file it imports,
787
+ * adding/removing a component, or shipping a new builder version each
788
+ * independently invalidates the cache FILE NAME. Entries are sorted by path
789
+ * first so the digest does not depend on either map's insertion order.
790
+ *
791
+ * This key alone cannot know about a file only a transitive import
792
+ * discovers (this file's top doc comment: the sidecar cache is what
793
+ * catches that). It still decides the cache file's NAME because it is
794
+ * cheap, deterministic, and known before any build attempt — an edit to a
795
+ * declared component or its statically-visible CSS gets a fresh name
796
+ * immediately, and a genuinely stale entry under an unchanged name is
797
+ * still caught by the sidecar hash check before it is ever reused.
798
+ */
799
+ export function computeCacheKey(pack, componentSources, cssSources = new Map()) {
800
+ const hash = createHash('sha256');
801
+ hash.update(`builder:${BUILDER_VERSION}\n`);
802
+ hash.update(`manifest:${JSON.stringify(pack.manifest)}\n`);
803
+ const entries = [...componentSources.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
804
+ for (const [sourcePath, source] of entries) {
805
+ hash.update(`component:${sourcePath}\n`);
806
+ hash.update(source);
807
+ hash.update('\n');
808
+ }
809
+ const cssEntries = [...cssSources.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
810
+ for (const [cssPath, source] of cssEntries) {
811
+ hash.update(`css:${cssPath}\n`);
812
+ hash.update(source);
813
+ hash.update('\n');
814
+ }
815
+ return hash.digest('hex');
816
+ }
817
+ /** A cache-safe file-name fragment: the pack's namespace, already restricted to lowercase-kebab by `@markii/pack`'s `validatePackName` (a host's own discovery never constructs a `PackBuildSource` from a manifest that failed validation), so no further sanitizing is needed here — kept as a small local guard anyway, cheap insurance against a future relaxed namespace rule. */
818
+ function safeCacheBaseName(pack) {
819
+ return pack.manifest.name.replace(/[^a-z0-9-]/g, '-') || 'pack';
820
+ }
821
+ /** SHA-256 hex digest of one file's text — the sidecar's per-file fingerprint. */
822
+ function hashFileContent(text) {
823
+ return createHash('sha256').update(text).digest('hex');
824
+ }
825
+ /**
826
+ * Builds the sidecar record for a completed build's full, real file set
827
+ * (`fileContents`, mutated in place by `virtualSourcePlugin` as it
828
+ * resolved) — EXCLUDING `virtualOnlyPaths` (the JSX shim). The shim has no
829
+ * file on disk to re-read and re-hash on a later `sidecarStillValid` check
830
+ * (`readComponentSource` would always report it missing), so recording it
831
+ * would turn every cache hit into a permanent miss; it needs no entry
832
+ * anyway, since its content is fixed and any change to it is a change to
833
+ * this builder itself, already covered by `BUILDER_VERSION` in the
834
+ * pre-build cache key. Sorted by path so the serialized JSON is stable
835
+ * across runs with the same file set.
836
+ */
837
+ function buildSidecar(fileContents, virtualOnlyPaths) {
838
+ const files = [...fileContents.entries()]
839
+ .filter(([filePath]) => !virtualOnlyPaths.has(filePath))
840
+ .map(([filePath, text]) => ({
841
+ path: filePath,
842
+ hash: hashFileContent(text),
843
+ }))
844
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
845
+ return { files };
846
+ }
847
+ /**
848
+ * Reads and validates one sidecar file's shape. Returns `undefined` — never
849
+ * throws — for a missing file, unparsable JSON, or any shape that is not
850
+ * exactly `{ files: Array<{ path: string, hash: string }> }`: per this
851
+ * file's top doc comment, "no sidecar" and "a sidecar that cannot be
852
+ * trusted" are the same outcome, a cache miss.
853
+ */
854
+ async function loadSidecar(sidecarPath) {
855
+ let raw;
856
+ try {
857
+ raw = await nodeReadFile(sidecarPath, 'utf8');
858
+ }
859
+ catch {
860
+ return undefined;
861
+ }
862
+ let parsed;
863
+ try {
864
+ parsed = JSON.parse(raw);
865
+ }
866
+ catch {
867
+ return undefined;
868
+ }
869
+ if (typeof parsed !== 'object' || parsed === null)
870
+ return undefined;
871
+ const rawFiles = parsed.files;
872
+ if (!Array.isArray(rawFiles))
873
+ return undefined;
874
+ const files = [];
875
+ for (const entry of rawFiles) {
876
+ if (typeof entry !== 'object' ||
877
+ entry === null ||
878
+ typeof entry.path !== 'string' ||
879
+ typeof entry.hash !== 'string') {
880
+ return undefined;
881
+ }
882
+ files.push({
883
+ path: entry.path,
884
+ hash: entry.hash,
885
+ });
886
+ }
887
+ return { files };
888
+ }
889
+ /**
890
+ * Whether every file a sidecar recorded still reads back with the same
891
+ * content hash — a cache HIT only when this is true for every entry.
892
+ * `readComponentSource` (the same injected/real reader
893
+ * `buildPackRegistrationScript` already uses) rather than raw `node:fs`,
894
+ * so this stays testable with a fake reader exactly like the rest of this
895
+ * module's cache-key logic; a file that is missing, or throws while being
896
+ * read, is treated as changed (a miss), never as a crash.
897
+ */
898
+ async function sidecarStillValid(sidecar, readComponentSource) {
899
+ for (const file of sidecar.files) {
900
+ let text;
901
+ try {
902
+ text = await readComponentSource(file.path);
903
+ }
904
+ catch {
905
+ text = undefined;
906
+ }
907
+ if (text === undefined || hashFileContent(text) !== file.hash) {
908
+ return false;
909
+ }
910
+ }
911
+ return true;
912
+ }
913
+ const defaultReadComponentSource = async (absolutePath) => {
914
+ try {
915
+ return await nodeReadFile(absolutePath, 'utf8');
916
+ }
917
+ catch {
918
+ return undefined;
919
+ }
920
+ };
921
+ /**
922
+ * Builds (or reuses a cached build of) one pack's webview registration
923
+ * script. NEVER throws: every failure mode (a missing component source, a
924
+ * broken `.tsx`, an esbuild-wasm error) comes back as
925
+ * `{ kind: 'failed', reason }` — a host records that reason next to the
926
+ * pack and simply excludes it from its own webview/renderer registry, the
927
+ * same quiet degradation as every other pack failure (AGENTS.md's
928
+ * cleanliness rule: never a compiler error dump as page content).
929
+ *
930
+ * `cacheDir` is a HOST-OWNED directory — never the pack's own folder, per
931
+ * AGENTS.md's cleanliness rule that the user's file tree stays clean. A
932
+ * cache hit (the built script for this exact pack content already exists
933
+ * under `cacheDir`) skips esbuild-wasm entirely: esbuild-wasm
934
+ * initialization is not free (see this file's top doc comment for measured
935
+ * figures), and a warm preview open must not pay that cost on every panel
936
+ * (re)creation.
937
+ */
938
+ export function buildPackRegistrationScript(pack, cacheDir, options = {}) {
939
+ return buildPackScript(pack, cacheDir, options, 'webview');
940
+ }
941
+ /**
942
+ * Builds (or reuses a cached build of) one pack's TERMINAL script
943
+ * (`terminal.js`): the `ansi` sources of every component that names one,
944
+ * compiled through the same esbuild-wasm in-process path, as one
945
+ * self-contained ESM module (no imports: the loader evaluates it from a
946
+ * `data:` URL). `react` and `ink` imports and JSX read the instances the
947
+ * CLI publishes on `globalThis` (`__markiiReactInk`, `__markiiInk`), only
948
+ * inside render-time functions. The output ends by calling
949
+ * `globalThis.__markiiRegisterTerminalPack(manifestJson, components)`.
950
+ * Returns `'skipped'` when no component names an `ansi` source. A CSS
951
+ * import in a terminal source is compiled away and ignored. Never throws.
952
+ */
953
+ export function buildPackTerminalScript(pack, cacheDir, options = {}) {
954
+ return buildPackScript(pack, cacheDir, options, 'terminal');
955
+ }
956
+ async function buildPackScript(pack, cacheDir, options, kind) {
957
+ const terminal = kind === 'terminal';
958
+ const readComponentSource = options.readComponentSource ?? defaultReadComponentSource;
959
+ const components = orderedComponents(pack, terminal ? TERMINAL_ENGINE : WEBVIEW_ENGINE);
960
+ // A pack whose manifest declares no components at all (`"components":
961
+ // {}`) is a shared-Lua-only pack module: it has nothing for esbuild-wasm
962
+ // to compile and contributes zero directives. Rather than pay
963
+ // esbuild-wasm's initialization cost to produce a registration script
964
+ // that would call `window.__markiiRegisterPack` with an empty entry map,
965
+ // this returns `'skipped'` straight away — the same "no attempt was
966
+ // made" outcome a host already treats as "nothing to add to the
967
+ // registry," so such a pack never appears as an empty entry anywhere a
968
+ // caller renders build outcomes.
969
+ if (components.length === 0) {
970
+ return { kind: 'skipped' };
971
+ }
972
+ const jsxShimPath = path.join(pack.folder, JSX_SHIM_FILENAME);
973
+ if (existsSync(jsxShimPath)) {
974
+ return {
975
+ kind: 'failed',
976
+ reason: `pack "${pack.manifest.name}": "${JSX_SHIM_FILENAME}" is a reserved file name used internally by the pack builder and must not exist in the pack's own folder`,
977
+ };
978
+ }
979
+ const sources = new Map();
980
+ for (const component of components) {
981
+ let text;
982
+ try {
983
+ text = await readComponentSource(component.sourcePath);
984
+ }
985
+ catch {
986
+ text = undefined;
987
+ }
988
+ if (text === undefined) {
989
+ return {
990
+ kind: 'failed',
991
+ reason: `missing component source "${component.localName}" (${component.sourcePath})`,
992
+ };
993
+ }
994
+ sources.set(component.sourcePath, text);
995
+ }
996
+ // Best-effort CSS-import discovery (`staticCssImportPaths`) so a CSS-only
997
+ // edit is a cache miss same as a `.tsx` edit — see that function's doc
998
+ // comment for why this is a static scan rather than asking esbuild (which
999
+ // would mean building first, defeating the cache-hit fast path below).
1000
+ // An unreadable candidate is silently skipped here: if the pack's real
1001
+ // source genuinely needs it, `virtualSourcePlugin` below will fail the
1002
+ // build on it with a proper reason.
1003
+ const cssSources = new Map();
1004
+ for (const [sourcePath, source] of terminal ? [] : sources) {
1005
+ for (const cssPath of staticCssImportPaths(source, path.dirname(sourcePath))) {
1006
+ if (cssSources.has(cssPath))
1007
+ continue;
1008
+ let cssText;
1009
+ try {
1010
+ cssText = await readComponentSource(cssPath);
1011
+ }
1012
+ catch {
1013
+ cssText = undefined;
1014
+ }
1015
+ if (cssText !== undefined)
1016
+ cssSources.set(cssPath, cssText);
1017
+ }
1018
+ }
1019
+ let cacheKey;
1020
+ try {
1021
+ cacheKey = computeCacheKey(pack, sources, cssSources);
1022
+ }
1023
+ catch (err) {
1024
+ return { kind: 'failed', reason: describeThrown(err) };
1025
+ }
1026
+ const cacheBaseName = `${safeCacheBaseName(pack)}${terminal ? '-terminal' : ''}-${cacheKey}`;
1027
+ const cachePath = path.join(cacheDir, `${cacheBaseName}.js`);
1028
+ const cssCachePath = path.join(cacheDir, `${cacheBaseName}.css`);
1029
+ const sidecarPath = path.join(cacheDir, `${cacheBaseName}.sources.json`);
1030
+ if (existsSync(cachePath)) {
1031
+ const sidecar = await loadSidecar(sidecarPath);
1032
+ if (sidecar && (await sidecarStillValid(sidecar, readComponentSource))) {
1033
+ return await builtOutcomeFromCache(pack, cachePath, cssCachePath);
1034
+ }
1035
+ // No sidecar, or one of its recorded files is missing/changed (most
1036
+ // often a transitively-imported helper the pre-build key above never
1037
+ // saw) — a miss, same as no cache file at all. Falls through to a real
1038
+ // rebuild, which overwrites this exact `cachePath`/`sidecarPath` pair.
1039
+ }
1040
+ let build;
1041
+ try {
1042
+ build =
1043
+ options.build ??
1044
+ (await loadEsbuildWasm(options.esbuildBrowserModulePath, options.esbuildWasmBinaryPath));
1045
+ }
1046
+ catch (err) {
1047
+ return {
1048
+ kind: 'failed',
1049
+ reason: `could not load esbuild-wasm: ${describeThrown(err)}`,
1050
+ };
1051
+ }
1052
+ // Every source byte esbuild might touch for this build, in one map —
1053
+ // `virtualSourcePlugin` is the ONLY way any of it reaches esbuild: the
1054
+ // in-process WebAssembly build (this file's top doc comment) has no
1055
+ // filesystem of its own. Seeded with what the pre-build scan already
1056
+ // knows, plus the JSX shim's own source under its synthetic path (never
1057
+ // written to disk); `virtualSourcePlugin` mutates this in place as it
1058
+ // resolves further files, so after a successful build it holds the
1059
+ // complete, real transitive set — what `buildSidecar` records below,
1060
+ // minus the shim itself (`virtualOnlyPaths`).
1061
+ const fileContents = new Map([
1062
+ ...sources,
1063
+ ...cssSources,
1064
+ [jsxShimPath, terminal ? TERMINAL_JSX_SHIM_SOURCE : JSX_SHIM_SOURCE],
1065
+ ]);
1066
+ const virtualOnlyPaths = new Set([jsxShimPath]);
1067
+ // The jail boundary every dynamically-resolved import must stay inside
1068
+ // (task #2 in this file's top doc comment). A pack folder that cannot be
1069
+ // real-path'd (does not exist) falls back to its own resolved form: in
1070
+ // that case the build below fails for other, more fundamental reasons
1071
+ // (an unreadable `stdin.resolveDir`), and this fallback only matters so
1072
+ // a caller that already validated `pack.folder` some other way (a test
1073
+ // double, say) is never blocked on a jail check that has nothing real to
1074
+ // compare against.
1075
+ let jailRealRoot;
1076
+ try {
1077
+ jailRealRoot = await realpath(pack.folder);
1078
+ }
1079
+ catch {
1080
+ jailRealRoot = path.resolve(pack.folder);
1081
+ }
1082
+ let scriptText;
1083
+ let stylesheetText;
1084
+ try {
1085
+ const result = await build({
1086
+ stdin: {
1087
+ contents: terminal
1088
+ ? terminalEntrySource(pack, components)
1089
+ : entrySource(pack, components),
1090
+ resolveDir: pack.folder,
1091
+ sourcefile: `${pack.manifest.name}-pack-entry.js`,
1092
+ loader: 'js',
1093
+ },
1094
+ bundle: true,
1095
+ write: false,
1096
+ // `outdir` + `entryNames` (rather than a bare `write:false` with no
1097
+ // output-path option at all) is what makes a CSS import legal in the
1098
+ // first place — esbuild refuses to bundle a CSS import "without an
1099
+ // output path configured" otherwise (confirmed empirically against
1100
+ // esbuild-wasm@0.28.2) — and gives any emitted stylesheet the SAME
1101
+ // content-hash base name as the script, one directory entry apart by
1102
+ // extension only: `${cacheBaseName}.js` next to `${cacheBaseName}.css`.
1103
+ // A pack with no CSS import simply never gets a `.css` entry in
1104
+ // `result.outputFiles` — confirmed empirically, no special-casing
1105
+ // needed. Nothing is actually written to `cacheDir` by esbuild itself
1106
+ // (`write: false`): this repo's own atomic temp+rename dance below is
1107
+ // what lands the file(s), exactly as it already did for the script.
1108
+ outdir: cacheDir,
1109
+ entryNames: cacheBaseName,
1110
+ // Pins esbuild's own idea of "the current directory" to the pack's
1111
+ // folder. Without this, `lib/browser.js` falls back to its default
1112
+ // (effectively `.`), and a resolution path that reaches esbuild's
1113
+ // OWN internal directory-walking logic — even briefly, before a
1114
+ // plugin's `onResolve` result is applied — tries to read that
1115
+ // literal `.` and fails with `Cannot read directory ".": not
1116
+ // implemented on js` (there is no real filesystem in this mode).
1117
+ // `virtualSourcePlugin`'s `onResolve` now always returns a
1118
+ // definitive result (never `undefined`) so esbuild's own resolver
1119
+ // is never reached at all; this option is kept anyway as a second,
1120
+ // independent guard against the same failure mode.
1121
+ absWorkingDir: pack.folder,
1122
+ format: terminal ? 'esm' : 'iife',
1123
+ platform: terminal ? 'neutral' : 'browser',
1124
+ // VS Code 1.90 ships Electron 29 / Chromium 122 (matches
1125
+ // esbuild.config.mjs's webviewBuild target); the same target is a
1126
+ // safe floor for any other browser-embedded host too.
1127
+ target: terminal ? 'node22' : 'chrome122',
1128
+ jsx: 'transform',
1129
+ jsxFactory: '__markiiJSX.createElement',
1130
+ jsxFragment: '__markiiJSX.Fragment',
1131
+ // Bypasses esbuild's own tsconfig.json AUTO-DISCOVERY (it otherwise
1132
+ // walks up from `resolveDir` — the pack's own folder — looking for
1133
+ // one, and could find a HOST'S OWN `tsconfig.json`, whose `jsx`
1134
+ // compiler option would silently override the
1135
+ // `jsx`/`jsxFactory`/`jsxFragment` options above, re-introducing the
1136
+ // automatic runtime and its `react/jsx-runtime` import this builder
1137
+ // exists to avoid — confirmed empirically: without this, the output
1138
+ // ships a full bundled copy of React's development JSX runtime). An
1139
+ // empty inline config is deliberate: a pack's own source has no
1140
+ // business being compiled against ITS host's unrelated TypeScript
1141
+ // settings anyway.
1142
+ tsconfigRaw: '{}',
1143
+ // The JSX shim as an INJECTED module, not a `banner`: a `banner`
1144
+ // writes its text outside the `format: 'iife'` wrapper and leaves
1145
+ // `__markiiJSX` as a global on `window` — this file's top doc
1146
+ // comment ("The lazy-React contract") and `JSX_SHIM_SOURCE`'s own
1147
+ // comment have the full reasoning. `inject` compiles the shim as an
1148
+ // ordinary module of the bundle (resolved through
1149
+ // `virtualSourcePlugin` like any other pack file) and imports its
1150
+ // `__markiiJSX` export into every module — component or helper,
1151
+ // entry or not — that references it as a free variable.
1152
+ inject: [jsxShimPath],
1153
+ plugins: [
1154
+ terminal
1155
+ ? lazyGlobalModulePlugin(TERMINAL_LAZY_GLOBAL_MODULES, 'globalThis')
1156
+ : lazyGlobalModulePlugin(),
1157
+ virtualSourcePlugin(pack.manifest.name, fileContents, jailRealRoot, virtualOnlyPaths),
1158
+ ],
1159
+ logLevel: 'silent',
1160
+ });
1161
+ const outputFiles = result.outputFiles ?? [];
1162
+ // The main script is always the FIRST output file esbuild produces for
1163
+ // a single-entry build, script before any co-emitted asset (confirmed
1164
+ // empirically, and unaffected by whether a CSS file also came out) —
1165
+ // kept as an index lookup (not a `.js`-suffix `find`) so the fast,
1166
+ // fake-`build`-backed unit tests (`pack-build.test.ts`, which use an
1167
+ // opaque `path: '<stdout>'` and never touch real esbuild-wasm) keep
1168
+ // working unchanged.
1169
+ const output = outputFiles[0];
1170
+ if (!output) {
1171
+ const messages = (result.errors ?? []).map((e) => e.text).join('; ');
1172
+ return {
1173
+ kind: 'failed',
1174
+ reason: messages || 'esbuild-wasm produced no output',
1175
+ };
1176
+ }
1177
+ scriptText = output.text;
1178
+ stylesheetText = terminal
1179
+ ? undefined
1180
+ : outputFiles.slice(1).find((file) => file.path.endsWith('.css'))?.text;
1181
+ }
1182
+ catch (err) {
1183
+ return { kind: 'failed', reason: describeThrown(err) };
1184
+ }
1185
+ try {
1186
+ await mkdir(cacheDir, { recursive: true });
1187
+ await writeCacheFileAtomic(cachePath, scriptText);
1188
+ if (stylesheetText !== undefined) {
1189
+ await writeCacheFileAtomic(cssCachePath, stylesheetText);
1190
+ }
1191
+ // The sidecar records the FULL, real file set this build actually
1192
+ // touched (`fileContents`, mutated in place by `virtualSourcePlugin` as
1193
+ // it resolved) — written last, after the cache file(s) it protects
1194
+ // already exist, so a crash between the two never leaves a sidecar
1195
+ // pointing at a script that was never written.
1196
+ await writeCacheFileAtomic(sidecarPath, JSON.stringify(buildSidecar(fileContents, virtualOnlyPaths)));
1197
+ }
1198
+ catch (err) {
1199
+ return {
1200
+ kind: 'failed',
1201
+ reason: `could not write cache file: ${describeThrown(err)}`,
1202
+ };
1203
+ }
1204
+ return {
1205
+ kind: 'built',
1206
+ scriptPath: cachePath,
1207
+ ...(stylesheetText !== undefined
1208
+ ? {
1209
+ stylesheetPath: cssCachePath,
1210
+ warnings: lintPackCss(pack.manifest.name, stylesheetText),
1211
+ }
1212
+ : { warnings: [] }),
1213
+ };
1214
+ }
1215
+ /** Writes `text` to `targetPath` via the same temp-file-then-rename dance for every cache file this module writes (script or stylesheet): a partially-written file is never observable at `targetPath`, and a concurrent build that already won the race (another process/window building the same content-addressed path first) is treated as success rather than an error. */
1216
+ async function writeCacheFileAtomic(targetPath, text) {
1217
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
1218
+ await writeFile(tempPath, text, 'utf8');
1219
+ try {
1220
+ await rename(tempPath, targetPath);
1221
+ }
1222
+ catch {
1223
+ await unlink(tempPath).catch(() => undefined);
1224
+ }
1225
+ }
1226
+ /**
1227
+ * The `'built'` outcome for a cache HIT: `cachePath` (the script) already
1228
+ * exists, so no esbuild-wasm invocation happens at all. `cssCachePath` is
1229
+ * checked the same way — its presence or absence reflects whether THIS
1230
+ * exact cache key's build produced a stylesheet, since both files share one
1231
+ * content-hash base name. When present, its text is read back and re-linted
1232
+ * (`lintPackCss`) — cheap string analysis, so re-running it on every cache
1233
+ * hit keeps a developer informed on every panel (re)open, not only the
1234
+ * first time a pack was ever compiled in this environment.
1235
+ */
1236
+ async function builtOutcomeFromCache(pack, cachePath, cssCachePath) {
1237
+ if (!existsSync(cssCachePath)) {
1238
+ return { kind: 'built', scriptPath: cachePath, warnings: [] };
1239
+ }
1240
+ let cssText;
1241
+ try {
1242
+ cssText = await nodeReadFile(cssCachePath, 'utf8');
1243
+ }
1244
+ catch {
1245
+ cssText = undefined;
1246
+ }
1247
+ if (cssText === undefined) {
1248
+ return { kind: 'built', scriptPath: cachePath, warnings: [] };
1249
+ }
1250
+ return {
1251
+ kind: 'built',
1252
+ scriptPath: cachePath,
1253
+ stylesheetPath: cssCachePath,
1254
+ warnings: lintPackCss(pack.manifest.name, cssText),
1255
+ };
1256
+ }
1257
+ function describeThrown(err) {
1258
+ return err instanceof Error ? err.message : String(err);
1259
+ }