@markii/pack-build 0.0.1 → 0.17.0-next.2
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/README.md +8 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/pack-build.d.ts +113 -0
- package/dist/pack-build.js +1259 -0
- package/dist/pack-css-lint.d.ts +38 -0
- package/dist/pack-css-lint.js +231 -0
- package/dist/pack-export-archive.d.ts +45 -0
- package/dist/pack-export-archive.js +198 -0
- package/dist/pack-export.d.ts +94 -0
- package/dist/pack-export.js +335 -0
- package/package.json +41 -4
|
@@ -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
|
+
}
|