@uniflowed/vite 0.0.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,459 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // `import { Switch } from "@uniflowed/ui"`, as the bundler has to see it: an
6
+ // import from `switch.js` (ubugeeei-prod/uf#1118).
7
+ //
8
+ // # Why a barrel import is rewritten at all
9
+ //
10
+ // `@uniflowed/ui` is imported through its barrel, which re-exports every one of
11
+ // its modules, and `sideEffects: false` lets a bundler drop the modules a page
12
+ // does not use. But that happens when a build tree-shakes, and two things
13
+ // happen before it:
14
+ //
15
+ // * **The rsc pass records client modules as it transforms them.**
16
+ // `clientReferencePlugin` in `./flight.js` makes every `"use client"` module
17
+ // the rsc graph *loads* an entry of the client build. A page that imported
18
+ // `{ Switch }` loaded the barrel, the barrel loaded all forty-one modules,
19
+ // and the client build of one switch was 57 files and 146 KB gzipped where
20
+ // an import of `switch.js` alone is 6 files and 88 KB.
21
+ // * **A development server evaluates what is imported.** Nothing is
22
+ // tree-shaken under `uf dev`, so a page using one part ran all of them.
23
+ //
24
+ // So a named import from the barrel becomes an import from the file that
25
+ // defines the name, before either happens: the idea behind Next.js's
26
+ // `optimizePackageImports`. `uf_rsc` draws the client boundary of such an
27
+ // import at that module too (`uf_lib::client_modules_exporting`), so the
28
+ // analysis and the bundle agree about what a name reaches.
29
+ //
30
+ // # Read off the barrel the project resolves
31
+ //
32
+ // Which file defines a name is read from the barrel itself: its static
33
+ // `import { A } from "./a.js"` and `export { A } from "./a.js"` statements, and
34
+ // the object literals it builds its namespaces from. Not a table kept here,
35
+ // because the barrel a project installed is the one whose names count.
36
+ //
37
+ // A name that reading cannot place stays an import from the barrel, and so does
38
+ // every form that binds no name — `import * as ui`, `export * from` and
39
+ // `import()` — which is the rule `uf_rsc` applies to the same forms. A barrel
40
+ // import left alone costs size, never correctness, for the next reason.
41
+ //
42
+ // # One module, however it is reached
43
+ //
44
+ // A target is the file's absolute path, not a package subpath, so the package
45
+ // can export `.` alone. It is the path the barrel's own `./switch.js` resolves
46
+ // to, and Vite gives an absolute, a relative and a bare import of one file the
47
+ // same id in every environment — the `?v=` a development server adds to a file
48
+ // in `node_modules` included. So a module still loading the barrel and a module
49
+ // rewritten past it share one `switch.js`, and one React context between their
50
+ // parts.
51
+ //
52
+ // # Namespaces
53
+ //
54
+ // `Dialog` is not an export of `dialog.js`. It is an object the barrel builds
55
+ // from `dialog.js`'s parts, and `ContextMenu`'s is built from two modules. An
56
+ // import of one is served from a view of the barrel, `index.js?uf-namespace=Dialog`:
57
+ // a module generated here that imports those parts and builds the same object.
58
+ // It is the barrel's own path with a query, so its relative imports resolve the
59
+ // way the barrel's do.
60
+
61
+ import { readFileSync, statSync } from "node:fs";
62
+ import path from "node:path";
63
+
64
+ import { normalizePath, parseAst } from "vite";
65
+
66
+ /** The packages whose barrel imports are rewritten. */
67
+ export const BARREL_PACKAGES = Object.freeze(["@uniflowed/ui"]);
68
+
69
+ /** The query that makes a barrel's path a view of one of its namespaces. */
70
+ export const NAMESPACE_QUERY = "uf-namespace";
71
+
72
+ /** The module kinds whose imports are read, once earlier plugins made them JavaScript. */
73
+ const SCRIPT = /\.(?:[cm]?[jt]sx?|mdx)$/;
74
+
75
+ /** A name the barrel exports that no rewrite can place. */
76
+ const OPAQUE = Object.freeze({ kind: "opaque" });
77
+
78
+ /**
79
+ * The plugin that rewrites named imports from a barrel to the files defining
80
+ * them, in every environment.
81
+ *
82
+ * A normal plugin, so it runs after `uf:flow` and `uf:mdx` and after Vite's own
83
+ * transforms: whatever the module was written in, it is JavaScript by the time
84
+ * its imports are read.
85
+ */
86
+ export function barrelImportsPlugin() {
87
+ /** Each barrel's reading, keyed by its file and kept while the file is unchanged. */
88
+ const readings = new Map();
89
+ /** The barrels already reported as unreadable, so each is reported once. */
90
+ const reported = new Set();
91
+
92
+ return {
93
+ name: "uf:barrel-imports",
94
+
95
+ async transform(code, id) {
96
+ if (id.startsWith("\0") || namespaceViewOf(id) != null) return null;
97
+ if (!BARREL_PACKAGES.some((name) => code.includes(name))) return null;
98
+ if (!SCRIPT.test(cleanId(id))) return null;
99
+ let program;
100
+ try {
101
+ program = parseAst(code);
102
+ } catch {
103
+ return null;
104
+ }
105
+ const statements = program.body.filter(importsFromBarrel);
106
+ if (statements.length === 0) return null;
107
+
108
+ const edits = [];
109
+ for (const name of BARREL_PACKAGES) {
110
+ const own = statements.filter((node) => node.source.value === name);
111
+ if (own.length === 0) continue;
112
+ const resolved = await this.resolve(name, id, { skipSelf: true });
113
+ // Left to whatever loads an external module, which reads the barrel
114
+ // as a whole; nothing here can change what that loads.
115
+ if (resolved == null || resolved.external) continue;
116
+ const file = cleanId(resolved.id);
117
+ const barrel = readBarrel(readings, file);
118
+ if (barrel.problem != null) {
119
+ if (!reported.has(file)) {
120
+ reported.add(file);
121
+ this.warn(
122
+ `uf: could not read which file defines each export of ${name} (${barrel.problem}), ` +
123
+ "so a module importing from it loads every module it re-exports",
124
+ );
125
+ }
126
+ continue;
127
+ }
128
+ this.addWatchFile?.(file);
129
+ for (const node of own) {
130
+ const replacement = rewriteStatement(node, barrel.exports, file);
131
+ if (replacement != null) edits.push({ node, replacement });
132
+ }
133
+ }
134
+ if (edits.length === 0) return null;
135
+ return { code: applyEdits(code, edits), map: null };
136
+ },
137
+
138
+ load(id) {
139
+ const view = namespaceViewOf(id);
140
+ if (view == null) return null;
141
+ return namespaceViewSource(readBarrel(readings, view.file).exports, view.file, view.name);
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The barrel and the namespace a view module stands for, or `null` for any
148
+ * other id.
149
+ *
150
+ * `uf:flow` asks too: a view has the barrel's path and extension, and is not
151
+ * the barrel's Flow source.
152
+ */
153
+ export function namespaceViewOf(id) {
154
+ const at = id.indexOf("?");
155
+ if (at === -1 || id.startsWith("\0")) return null;
156
+ const name = new URLSearchParams(id.slice(at + 1)).get(NAMESPACE_QUERY);
157
+ return name == null || name === "" ? null : { file: id.slice(0, at), name };
158
+ }
159
+
160
+ /**
161
+ * Where each name a barrel exports is defined, read from its source.
162
+ *
163
+ * The barrel is Flow, read with the TypeScript grammar: the only type syntax a
164
+ * barrel has is `export type { A } from` and `import { type A }`, which the two
165
+ * grammars spell alike. A barrel this cannot parse throws, and its importers
166
+ * are left as they were.
167
+ *
168
+ * Each name maps to one of three things:
169
+ *
170
+ * * `{ kind: "binding", file, name }` — an export of another module, passed
171
+ * through under this name;
172
+ * * `{ kind: "namespace", parts }` — an object literal whose every property
173
+ * is such a binding, each part `{ key, file, name }`;
174
+ * * `OPAQUE` — anything else, which stays an import from the barrel.
175
+ *
176
+ * @param {string} source
177
+ * @param {string} file the barrel's absolute path
178
+ * @returns {Map<string, object>}
179
+ */
180
+ export function barrelExports(source, file) {
181
+ const program = parseAst(source, { lang: "ts" });
182
+ const directory = path.dirname(file);
183
+ const fileOf = (node) =>
184
+ typeof node?.value === "string" && /^\.\.?\//.test(node.value)
185
+ ? normalizePath(path.join(directory, node.value))
186
+ : null;
187
+
188
+ // Every binding an import made, and every `const` object, before any export
189
+ // is read: `export { Dialog }` may come before the `const` it names.
190
+ const bindings = new Map();
191
+ const objects = new Map();
192
+ for (const node of program.body) {
193
+ if (node.type === "ImportDeclaration" && node.importKind !== "type") {
194
+ const from = fileOf(node.source);
195
+ for (const specifier of node.specifiers) {
196
+ if (specifier.importKind === "type") continue;
197
+ const imported =
198
+ specifier.type === "ImportSpecifier"
199
+ ? nameOf(specifier.imported)
200
+ : specifier.type === "ImportDefaultSpecifier"
201
+ ? "default"
202
+ : null;
203
+ bindings.set(
204
+ specifier.local.name,
205
+ from == null || imported == null ? null : { file: from, name: imported },
206
+ );
207
+ }
208
+ }
209
+ const declaration = node.type === "ExportNamedDeclaration" ? node.declaration : node;
210
+ if (declaration?.type === "VariableDeclaration" && declaration.kind === "const") {
211
+ for (const declarator of declaration.declarations) {
212
+ if (declarator.id.type === "Identifier" && declarator.init?.type === "ObjectExpression") {
213
+ objects.set(declarator.id.name, declarator.init);
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ const local = (name) => {
220
+ const binding = bindings.get(name);
221
+ if (binding != null) return { kind: "binding", ...binding };
222
+ const object = objects.get(name);
223
+ return object == null ? OPAQUE : namespaceOf(object, bindings);
224
+ };
225
+
226
+ const exports = new Map();
227
+ for (const node of program.body) {
228
+ if (node.type === "ExportDefaultDeclaration") {
229
+ exports.set("default", OPAQUE);
230
+ }
231
+ if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue;
232
+ const { declaration } = node;
233
+ if (declaration != null) {
234
+ if (declaration.type === "VariableDeclaration") {
235
+ for (const declarator of declaration.declarations) {
236
+ if (declarator.id.type === "Identifier") {
237
+ exports.set(declarator.id.name, local(declarator.id.name));
238
+ }
239
+ }
240
+ } else if (declaration.id?.type === "Identifier" && !declaration.type.startsWith("TS")) {
241
+ exports.set(declaration.id.name, OPAQUE);
242
+ }
243
+ continue;
244
+ }
245
+ const from = node.source == null ? null : fileOf(node.source);
246
+ for (const specifier of node.specifiers) {
247
+ if (specifier.exportKind === "type") continue;
248
+ const name = nameOf(specifier.local);
249
+ exports.set(
250
+ nameOf(specifier.exported),
251
+ node.source == null
252
+ ? local(name)
253
+ : from == null
254
+ ? OPAQUE
255
+ : { kind: "binding", file: from, name },
256
+ );
257
+ }
258
+ }
259
+ return exports;
260
+ }
261
+
262
+ /**
263
+ * The module a view of a barrel's namespace is: the parts, imported from their
264
+ * files, and the object the barrel builds from them.
265
+ *
266
+ * A name that is not a namespace the barrel builds — the barrel changed under a
267
+ * development server after an importer was rewritten — is re-exported from the
268
+ * barrel itself, which answers correctly, if slowly, or with the bundler's own
269
+ * error for a name that is gone.
270
+ *
271
+ * @param {Map<string, object>} exports what `barrelExports` read
272
+ * @param {string} barrel the barrel's absolute path
273
+ * @param {string} name the namespace
274
+ */
275
+ export function namespaceViewSource(exports, barrel, name) {
276
+ const target = exports.get(name);
277
+ const directory = path.dirname(barrel);
278
+ const specifierOf = (file) => {
279
+ const relative = normalizePath(path.relative(directory, file));
280
+ return JSON.stringify(relative.startsWith("../") ? relative : `./${relative}`);
281
+ };
282
+ if (target?.kind !== "namespace") {
283
+ return `export { ${printName(name)} } from ${specifierOf(barrel)};\n`;
284
+ }
285
+ const locals = new Map();
286
+ const taken = new Set();
287
+ const imports = new Map();
288
+ for (const part of target.parts) {
289
+ const key = `${part.file}\0${part.name}`;
290
+ if (locals.has(key)) continue;
291
+ let alias = IDENTIFIER.test(part.name) ? part.name : "part";
292
+ while (taken.has(alias)) alias = `${alias}$`;
293
+ taken.add(alias);
294
+ locals.set(key, alias);
295
+ const specifiers = imports.get(part.file) ?? [];
296
+ specifiers.push(alias === part.name ? alias : `${printName(part.name)} as ${alias}`);
297
+ imports.set(part.file, specifiers);
298
+ }
299
+ const lines = [...imports].map(
300
+ ([file, specifiers]) => `import { ${specifiers.join(", ")} } from ${specifierOf(file)};`,
301
+ );
302
+ const properties = target.parts.map(
303
+ (part) => ` ${printName(part.key)}: ${locals.get(`${part.file}\0${part.name}`)},`,
304
+ );
305
+ lines.push(`export const ${name} = {`, ...properties, "};");
306
+ return `${lines.join("\n")}\n`;
307
+ }
308
+
309
+ /** An import or re-export whose source is one of `BARREL_PACKAGES`. */
310
+ function importsFromBarrel(node) {
311
+ if (node.type === "ImportDeclaration") {
312
+ return node.importKind !== "type" && BARREL_PACKAGES.includes(node.source.value);
313
+ }
314
+ return (
315
+ node.type === "ExportNamedDeclaration" &&
316
+ node.source != null &&
317
+ node.exportKind !== "type" &&
318
+ BARREL_PACKAGES.includes(node.source.value)
319
+ );
320
+ }
321
+
322
+ /**
323
+ * The statements one import or re-export from a barrel becomes, as source, or
324
+ * `null` to leave it as it was.
325
+ *
326
+ * @param {object} node the statement
327
+ * @param {Map<string, object>} exports the barrel's reading
328
+ * @param {string} barrel the barrel's absolute path
329
+ */
330
+ function rewriteStatement(node, exports, barrel) {
331
+ const isImport = node.type === "ImportDeclaration";
332
+ const specifiers = node.specifiers;
333
+ // A bare import binds nothing, and the barrel declares it has no side
334
+ // effects: what it loads is every module, for nothing.
335
+ if (isImport && specifiers.length === 0) return "";
336
+ // `import * as ui` names nothing that can be placed; see the header.
337
+ if (specifiers.some((specifier) => specifier.type === "ImportNamespaceSpecifier")) return null;
338
+
339
+ const kept = [];
340
+ let keptDefault = null;
341
+ const moved = new Map();
342
+ for (const specifier of specifiers) {
343
+ if (specifier.type === "ImportDefaultSpecifier") {
344
+ keptDefault = specifier.local.name;
345
+ continue;
346
+ }
347
+ const exported = nameOf(isImport ? specifier.imported : specifier.local);
348
+ const binding = isImport ? specifier.local.name : nameOf(specifier.exported);
349
+ const printed = (name) =>
350
+ name === binding ? printName(name) : `${printName(name)} as ${printName(binding)}`;
351
+ const target = specifier.importKind === "type" ? OPAQUE : (exports.get(exported) ?? OPAQUE);
352
+ if (target.kind === "opaque") {
353
+ kept.push(printed(exported));
354
+ continue;
355
+ }
356
+ const [source, name] =
357
+ target.kind === "binding"
358
+ ? [target.file, target.name]
359
+ : [`${normalizePath(barrel)}?${NAMESPACE_QUERY}=${encodeURIComponent(exported)}`, exported];
360
+ const list = moved.get(source) ?? [];
361
+ list.push(printed(name));
362
+ moved.set(source, list);
363
+ }
364
+ if (moved.size === 0) return null;
365
+
366
+ const keyword = isImport ? "import" : "export";
367
+ const statements = [];
368
+ if (kept.length > 0 || keptDefault != null) {
369
+ const clause = [keptDefault, kept.length > 0 ? `{ ${kept.join(", ")} }` : null]
370
+ .filter(Boolean)
371
+ .join(", ");
372
+ statements.push(`${keyword} ${clause} from ${JSON.stringify(node.source.value)};`);
373
+ }
374
+ for (const [source, list] of moved) {
375
+ statements.push(`${keyword} { ${list.join(", ")} } from ${JSON.stringify(source)};`);
376
+ }
377
+ return statements.join("\n");
378
+ }
379
+
380
+ /**
381
+ * `code` with each statement replaced, and every line after it where it was.
382
+ *
383
+ * No source map: the only lines that change are the imports themselves, and
384
+ * each replacement is padded to the line count it replaced, so a position
385
+ * anywhere after them maps as it did — the reason `clientReferencePlugin`
386
+ * returns `map: null` too.
387
+ */
388
+ function applyEdits(code, edits) {
389
+ let out = "";
390
+ let at = 0;
391
+ for (const { node, replacement } of [...edits].sort((a, b) => a.node.start - b.node.start)) {
392
+ const original = code.slice(node.start, node.end);
393
+ const lines = original.split("\n").length - 1;
394
+ const written = replacement.split("\n").length - 1;
395
+ const text =
396
+ written <= lines
397
+ ? `${replacement}${"\n".repeat(lines - written)}`
398
+ : `${replacement.split("\n").join(" ")}${"\n".repeat(lines)}`;
399
+ out += code.slice(at, node.start) + text;
400
+ at = node.end;
401
+ }
402
+ return out + code.slice(at);
403
+ }
404
+
405
+ /** An object literal of imported bindings, as a namespace, or `OPAQUE`. */
406
+ function namespaceOf(object, bindings) {
407
+ const parts = [];
408
+ for (const property of object.properties) {
409
+ if (property.type !== "Property" || property.kind !== "init" || property.computed) {
410
+ return OPAQUE;
411
+ }
412
+ if (property.method || property.value.type !== "Identifier") return OPAQUE;
413
+ const key =
414
+ property.key.type === "Identifier"
415
+ ? property.key.name
416
+ : typeof property.key.value === "string"
417
+ ? property.key.value
418
+ : null;
419
+ const binding = bindings.get(property.value.name);
420
+ if (key == null || binding == null) return OPAQUE;
421
+ parts.push({ key, ...binding });
422
+ }
423
+ return { kind: "namespace", parts };
424
+ }
425
+
426
+ /** A barrel's reading, re-read only when its size or modification time changes. */
427
+ function readBarrel(readings, file) {
428
+ let stats;
429
+ try {
430
+ stats = statSync(file);
431
+ } catch (error) {
432
+ return { exports: new Map(), problem: error.message };
433
+ }
434
+ const known = readings.get(file);
435
+ if (known != null && known.mtimeMs === stats.mtimeMs && known.size === stats.size) return known;
436
+ const reading = { mtimeMs: stats.mtimeMs, size: stats.size, exports: new Map(), problem: null };
437
+ try {
438
+ reading.exports = barrelExports(readFileSync(file, "utf8"), file);
439
+ } catch (error) {
440
+ reading.problem = error.message;
441
+ }
442
+ readings.set(file, reading);
443
+ return reading;
444
+ }
445
+
446
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
447
+
448
+ function nameOf(node) {
449
+ return node.type === "Identifier" ? node.name : String(node.value);
450
+ }
451
+
452
+ function printName(name) {
453
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
454
+ }
455
+
456
+ function cleanId(id) {
457
+ const at = id.indexOf("?");
458
+ return at === -1 ? id : id.slice(0, at);
459
+ }
@@ -0,0 +1,67 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // The embedded copy of `dist/`, handed to the bundler as a virtual module.
6
+ //
7
+ // `uf build --compile` puts everything `uf build` wrote inside the binary, and
8
+ // the way it travels there is one generated module: `uf_bundle::embed` writes
9
+ // `export const assets = JSON.parse("…")` into `.uf/build/compile/assets.js`,
10
+ // where the string is base64 of every file in the output directory. For the
11
+ // docs site that is eight megabytes; for a project with images it is tens.
12
+ //
13
+ // # Why it does not enter the graph as the file it is
14
+ //
15
+ // Because it is uf's own data, and the module graph is where uf's *source*
16
+ // transform is. `crates/uf_transform` refuses a source over
17
+ // `MAX_SOURCE_BYTES` — 8 MiB, a ceiling that bounds the parser against a file
18
+ // nobody meant to compile — and `.uf/build/compile/assets.js` is a file
19
+ // nobody wrote at all: its size is the size of the *build output*, which has
20
+ // no relationship to the size of any source file and grows every time a page
21
+ // is added. A site that crossed the line failed with
22
+ //
23
+ // TransformError: source is 8442536 bytes, over the 8388608 byte ceiling
24
+ // [plugin uf:flow] docs/.uf/build/compile/assets.js
25
+ //
26
+ // which names a file the project does not own, for a limit it did not break,
27
+ // at the end of a build that had already succeeded.
28
+ //
29
+ // So the payload enters the graph the way a build tool's own modules do,
30
+ // under a NUL-prefixed id. `@uniflowed/host/transform`'s `isFlowModule`
31
+ // declines those by name — "a build tool synthesises modules of its own", the
32
+ // first sentence of its contract — so the bytes go from disk to the bundler
33
+ // without being parsed as Flow on the way. Raising the ceiling instead would
34
+ // have weakened the one thing it is for, on every real source file, to make
35
+ // room for a file that is not source.
36
+ //
37
+ // The file on disk is still written and still what is served, so a person
38
+ // debugging a compiled binary can open the thing that went into it. What
39
+ // changed is only which name the bundler reaches it by.
40
+
41
+ import { readFileSync } from "node:fs";
42
+
43
+ /** What the generated entry imports. */
44
+ export const COMPILE_ASSETS_ID = "virtual:uf/compile-assets";
45
+
46
+ /** The resolved id Vite hands back for it. */
47
+ export const COMPILE_ASSETS_RESOLVED_ID = `\0${COMPILE_ASSETS_ID}`;
48
+
49
+ /**
50
+ * Serve `file` — `uf_bundle::embed`'s output — as [`COMPILE_ASSETS_ID`].
51
+ *
52
+ * Read at `load` rather than when the plugin is made, so a build that fails
53
+ * before it reaches the entry never pays for eight megabytes of string, and
54
+ * so the bytes are the ones on disk at the moment the bundler asked for them
55
+ * rather than at the moment the plugin list was assembled.
56
+ */
57
+ export function compileAssetsPlugin(file) {
58
+ return {
59
+ name: "uf:compile-assets",
60
+ resolveId(id) {
61
+ return id === COMPILE_ASSETS_ID ? COMPILE_ASSETS_RESOLVED_ID : null;
62
+ },
63
+ load(id) {
64
+ return id === COMPILE_ASSETS_RESOLVED_ID ? readFileSync(file, "utf8") : null;
65
+ },
66
+ };
67
+ }
@@ -72,7 +72,7 @@ export async function loadUfConfig(root) {
72
72
  }
73
73
 
74
74
  const compiled = await compileConfig(source, file, root);
75
- const module = await import(pathToFileURL(compiled).href);
75
+ const module = await importConfigModule(compiled);
76
76
  const config = module.default;
77
77
  if (config == null || typeof config !== "object") {
78
78
  throw new Error(
@@ -82,6 +82,20 @@ export async function loadUfConfig(root) {
82
82
  return { config, file };
83
83
  }
84
84
 
85
+ async function importConfigModule(compiled) {
86
+ const previous = process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG;
87
+ process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG = "1";
88
+ try {
89
+ return await import(pathToFileURL(compiled).href);
90
+ } finally {
91
+ if (previous == null) {
92
+ delete process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG;
93
+ } else {
94
+ process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG = previous;
95
+ }
96
+ }
97
+ }
98
+
85
99
  /**
86
100
  * Transform the config to JavaScript and write it where it can be imported.
87
101
  *
@@ -93,8 +107,14 @@ async function compileConfig(source, file, root) {
93
107
  const directory = path.join(root, COMPILED_DIR);
94
108
  const target = path.join(directory, `uf.config.${hash}.mjs`);
95
109
 
96
- const out = await transformFlow(source, file, { root, sourceMap: false });
97
- const code = rewriteRelativeImports(out?.code ?? source, path.dirname(file));
110
+ const out = await transformFlow(source, file, {
111
+ root,
112
+ sourceMap: false,
113
+ configBootstrap: true,
114
+ });
115
+ const code = rewriteConfigImports(
116
+ rewriteRelativeImports(out?.code ?? source, path.dirname(file)),
117
+ );
98
118
  // Atomically, because two `uf` commands in one project write this same path
99
119
  // at the same time — the hash is of the source, so they agree on the name —
100
120
  // and `writeFileSync` truncates before it writes. A reader that caught it
@@ -125,6 +145,13 @@ export function rewriteRelativeImports(code, baseDirectory) {
125
145
  );
126
146
  }
127
147
 
148
+ function rewriteConfigImports(code) {
149
+ return code.replace(
150
+ /^\s*import\s*\{\s*defineConfig\s*\}\s*from\s*["']@uniflowed\/config["'];?\n?/gm,
151
+ "function defineConfig(config) {\n return config;\n}\n",
152
+ );
153
+ }
154
+
128
155
  /**
129
156
  * The JSON projection of a config object.
130
157
  *