@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.5
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/driver.js +125 -17
- package/index.js +61 -6
- package/internal/config.js +22 -7
- package/internal/events.js +2 -0
- package/internal/flow-grammar-shim.js +241 -0
- package/internal/flow-keywords.js +505 -0
- package/internal/highlight.js +181 -0
- package/internal/refresh-runtime.js +221 -246
- package/internal/refresh.js +2 -0
- package/internal/routes.js +47 -6
- package/merge.js +87 -0
- package/package.json +9 -10
- package/bun-preload.js +0 -22
- package/internal/node-hooks.js +0 -111
- package/register.js +0 -11
- package/transform.js +0 -187
package/driver.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
1
3
|
// Plain JavaScript: the host runs this file directly.
|
|
2
4
|
//
|
|
3
5
|
// The driver `uf dev`, `uf build` and `uf preview` spawn.
|
|
@@ -23,6 +25,7 @@ import { pathToFileURL } from "node:url";
|
|
|
23
25
|
|
|
24
26
|
import { emit, errorEvent, eventLogger } from "./internal/events.js";
|
|
25
27
|
import { loadUfConfig, projectConfig } from "./internal/config.js";
|
|
28
|
+
import { withProjectConfig } from "./merge.js";
|
|
26
29
|
import { VIRTUAL, scanRoutes } from "./internal/routes.js";
|
|
27
30
|
|
|
28
31
|
function argument(name) {
|
|
@@ -41,10 +44,15 @@ const root = path.resolve(argument("--root") ?? process.cwd());
|
|
|
41
44
|
process.env.UF_PROJECT_ROOT = root;
|
|
42
45
|
|
|
43
46
|
// The config imports `@uniflowed/config`, which is Flow. Node needs the loader
|
|
44
|
-
// hooks for that; Bun is started with `--preload
|
|
45
|
-
// and has no `register`.
|
|
47
|
+
// hooks for that; Bun is started with `--preload` on the same package's
|
|
48
|
+
// preload instead, and has no `register`.
|
|
49
|
+
//
|
|
50
|
+
// The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
|
|
51
|
+
// on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
|
|
52
|
+
// the same package, which is what stopped a test run from depending on a
|
|
53
|
+
// bundler it never loads.
|
|
46
54
|
if (typeof Bun === "undefined" && typeof Deno === "undefined") {
|
|
47
|
-
register("
|
|
55
|
+
register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
process.stdin.on("end", () => process.exit(0));
|
|
@@ -78,16 +86,21 @@ async function viteConfig(config, mode) {
|
|
|
78
86
|
const userPlugins = Array.isArray(config.plugins) ? config.plugins : [];
|
|
79
87
|
const host = argument("--host") ?? dev.host ?? "127.0.0.1";
|
|
80
88
|
const port = Number(argument("--port") ?? dev.port ?? 5173);
|
|
81
|
-
const allowedHosts =
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
const allowedHosts =
|
|
90
|
+
Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
|
|
91
|
+
|
|
92
|
+
// What uf generates from the semantics it owns: where the project is, which
|
|
93
|
+
// plugins make Flow compile, and the few settings uf enforces rather than
|
|
94
|
+
// merely passes on — `allowedHosts` gates binding a routable address, and
|
|
95
|
+
// `manifest` is how the prerender finds its assets.
|
|
96
|
+
const generated = {
|
|
84
97
|
root,
|
|
85
98
|
configFile: false,
|
|
86
99
|
envFile: false,
|
|
87
100
|
mode,
|
|
88
101
|
clearScreen: false,
|
|
89
102
|
customLogger: eventLogger(argument("--log-level") ?? "info"),
|
|
90
|
-
plugins: [uniflowed({ root, config })
|
|
103
|
+
plugins: [uniflowed({ root, config })],
|
|
91
104
|
server: {
|
|
92
105
|
host,
|
|
93
106
|
port,
|
|
@@ -106,6 +119,14 @@ async function viteConfig(config, mode) {
|
|
|
106
119
|
emptyOutDir: true,
|
|
107
120
|
},
|
|
108
121
|
};
|
|
122
|
+
|
|
123
|
+
// Then the project's own Vite configuration, merged over it. uf does not
|
|
124
|
+
// read this and does not need to: an option added to Vite tomorrow works in
|
|
125
|
+
// a uf project tomorrow, rather than after a uf release that names it.
|
|
126
|
+
return withProjectConfig(generated, {
|
|
127
|
+
...(config.vite ?? {}),
|
|
128
|
+
plugins: [...(config.vite?.plugins ?? []), ...userPlugins],
|
|
129
|
+
});
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
/**
|
|
@@ -138,13 +159,27 @@ async function dev() {
|
|
|
138
159
|
const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
|
|
139
160
|
|
|
140
161
|
server.middlewares.use(async (request, response, next) => {
|
|
141
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
142
|
-
next();
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
162
|
const url = request.originalUrl ?? request.url ?? "/";
|
|
146
163
|
try {
|
|
147
164
|
const entry = await server.ssrLoadModule(VIRTUAL.server);
|
|
165
|
+
|
|
166
|
+
// Route handlers first, and for every method: a handler is the only
|
|
167
|
+
// thing that answers a POST, and it may also answer a GET for a path
|
|
168
|
+
// that has no page.
|
|
169
|
+
const handled = await entry.dispatch(await toRequest(request, server.config));
|
|
170
|
+
if (handled != null) {
|
|
171
|
+
await send(response, handled);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Only a navigation reaches the renderer. A page cannot answer a POST,
|
|
176
|
+
// and letting one try would turn a missing handler into a rendered page
|
|
177
|
+
// with a 200 rather than a 404.
|
|
178
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
179
|
+
next();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
const result = await entry.render(url, assets);
|
|
149
184
|
const html = await server.transformIndexHtml(url, result.html);
|
|
150
185
|
response.statusCode = result.status ?? 200;
|
|
@@ -162,7 +197,9 @@ async function dev() {
|
|
|
162
197
|
emit("listening", {
|
|
163
198
|
local: urls.local,
|
|
164
199
|
network: urls.network,
|
|
165
|
-
routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
|
|
200
|
+
routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
|
|
201
|
+
(route) => route.path,
|
|
202
|
+
),
|
|
166
203
|
});
|
|
167
204
|
|
|
168
205
|
const shutdown = async () => {
|
|
@@ -173,6 +210,62 @@ async function dev() {
|
|
|
173
210
|
process.on("SIGTERM", shutdown);
|
|
174
211
|
}
|
|
175
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A Node request as a `Request`.
|
|
215
|
+
*
|
|
216
|
+
* The handler contract is the platform's, so the adapter belongs here rather
|
|
217
|
+
* than in every handler. The body is read as a stream where the host supports
|
|
218
|
+
* it, because a handler that accepts an upload should not need the whole thing
|
|
219
|
+
* buffered before it starts.
|
|
220
|
+
*/
|
|
221
|
+
async function toRequest(incoming, config) {
|
|
222
|
+
const host = incoming.headers.host ?? "localhost";
|
|
223
|
+
const protocol = config?.server?.https == null ? "http" : "https";
|
|
224
|
+
const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
|
|
225
|
+
|
|
226
|
+
const headers = new Headers();
|
|
227
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
228
|
+
if (value == null) continue;
|
|
229
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
230
|
+
headers.append(name, entry);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const method = (incoming.method ?? "GET").toUpperCase();
|
|
235
|
+
const init = { method, headers };
|
|
236
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
237
|
+
// `duplex` is required by the specification whenever a body is a stream,
|
|
238
|
+
// and Node throws without it.
|
|
239
|
+
init.body = incoming;
|
|
240
|
+
init.duplex = "half";
|
|
241
|
+
}
|
|
242
|
+
return new Request(url, init);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Write a `Response` to a Node response. */
|
|
246
|
+
async function send(outgoing, result) {
|
|
247
|
+
outgoing.statusCode = result.status;
|
|
248
|
+
if (result.statusText !== "") {
|
|
249
|
+
outgoing.statusMessage = result.statusText;
|
|
250
|
+
}
|
|
251
|
+
for (const [name, value] of result.headers) {
|
|
252
|
+
outgoing.setHeader(name, value);
|
|
253
|
+
}
|
|
254
|
+
if (result.body == null) {
|
|
255
|
+
outgoing.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Streamed rather than buffered, so a handler returning a large or
|
|
259
|
+
// open-ended body is not read into memory first.
|
|
260
|
+
const reader = result.body.getReader();
|
|
261
|
+
while (true) {
|
|
262
|
+
const { done, value } = await reader.read();
|
|
263
|
+
if (done) break;
|
|
264
|
+
outgoing.write(value);
|
|
265
|
+
}
|
|
266
|
+
outgoing.end();
|
|
267
|
+
}
|
|
268
|
+
|
|
176
269
|
async function preview() {
|
|
177
270
|
const { preview: startPreview } = await import("vite");
|
|
178
271
|
const config = await loadConfig();
|
|
@@ -236,13 +329,23 @@ async function build() {
|
|
|
236
329
|
const file = htmlPathFor(outDir, url);
|
|
237
330
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
238
331
|
writeFileSync(file, result.html);
|
|
239
|
-
emit("page", {
|
|
332
|
+
emit("page", {
|
|
333
|
+
url,
|
|
334
|
+
file: path.relative(root, file),
|
|
335
|
+
status: result.status,
|
|
336
|
+
bytes: Buffer.byteLength(result.html),
|
|
337
|
+
});
|
|
240
338
|
}
|
|
241
339
|
if (server.notFound != null) {
|
|
242
340
|
const result = await server.render("/__uf_not_found__", assets);
|
|
243
341
|
const file = path.join(outDir, "404.html");
|
|
244
342
|
writeFileSync(file, result.html);
|
|
245
|
-
emit("page", {
|
|
343
|
+
emit("page", {
|
|
344
|
+
url: "/404",
|
|
345
|
+
file: path.relative(root, file),
|
|
346
|
+
status: 404,
|
|
347
|
+
bytes: Buffer.byteLength(result.html),
|
|
348
|
+
});
|
|
246
349
|
}
|
|
247
350
|
|
|
248
351
|
emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
|
|
@@ -346,9 +449,12 @@ function fillParams(routePath, params) {
|
|
|
346
449
|
.map((segment) => {
|
|
347
450
|
if (segment.endsWith("*")) {
|
|
348
451
|
const value = params[segment.slice(1, -1)];
|
|
349
|
-
return Array.isArray(value)
|
|
452
|
+
return Array.isArray(value)
|
|
453
|
+
? value.map(encodeURIComponent).join("/")
|
|
454
|
+
: encodeURIComponent(String(value ?? ""));
|
|
350
455
|
}
|
|
351
|
-
if (segment.startsWith(":"))
|
|
456
|
+
if (segment.startsWith(":"))
|
|
457
|
+
return encodeURIComponent(String(params[segment.slice(1)] ?? ""));
|
|
352
458
|
return segment;
|
|
353
459
|
})
|
|
354
460
|
.join("/");
|
|
@@ -356,5 +462,7 @@ function fillParams(routePath, params) {
|
|
|
356
462
|
|
|
357
463
|
function htmlPathFor(outDir, url) {
|
|
358
464
|
const pathname = url.split("?")[0].replace(/^\/+/, "");
|
|
359
|
-
return pathname === ""
|
|
465
|
+
return pathname === ""
|
|
466
|
+
? path.join(outDir, "index.html")
|
|
467
|
+
: path.join(outDir, pathname, "index.html");
|
|
360
468
|
}
|
package/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
1
3
|
// Plain JavaScript: Vite imports this module directly, before any transform.
|
|
2
4
|
//
|
|
3
5
|
// `@uniflowed/vite` — uf, as Vite plugins.
|
|
@@ -15,7 +17,8 @@
|
|
|
15
17
|
// development it also renders every HTML request on the
|
|
16
18
|
// server, so `uf dev` serves the same markup `uf build` writes.
|
|
17
19
|
// * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
|
|
18
|
-
// markdown, front matter
|
|
20
|
+
// markdown, front matter, heading ids and build-time syntax
|
|
21
|
+
// highlighting, so `.mdx` works with
|
|
19
22
|
// no configuration.
|
|
20
23
|
//
|
|
21
24
|
// `uniflowed(options)` returns the array; a project that wants to add a plugin
|
|
@@ -26,6 +29,8 @@ import path from "node:path";
|
|
|
26
29
|
|
|
27
30
|
import mdx from "@mdx-js/rollup";
|
|
28
31
|
import rehypeSlug from "rehype-slug";
|
|
32
|
+
|
|
33
|
+
import { highlightPlugin } from "./internal/highlight.js";
|
|
29
34
|
import remarkFrontmatter from "remark-frontmatter";
|
|
30
35
|
import remarkGfm from "remark-gfm";
|
|
31
36
|
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
|
|
@@ -44,12 +49,22 @@ import {
|
|
|
44
49
|
scanRoutes,
|
|
45
50
|
serverModuleSource,
|
|
46
51
|
} from "./internal/routes.js";
|
|
47
|
-
import { TransformService, isFlowModule } from "
|
|
52
|
+
import { TransformService, isFlowModule } from "@uniflowed/host/transform";
|
|
48
53
|
|
|
49
54
|
/** A resolved virtual id: Vite's convention is a leading NUL byte. */
|
|
50
55
|
const resolved = (id) => `\0${id}`;
|
|
51
56
|
const VIRTUAL_IDS = new Set(Object.values(VIRTUAL));
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Prefix of the virtual module that carries one source module's StyleX rules.
|
|
60
|
+
*
|
|
61
|
+
* Not NUL-prefixed, unlike the virtual modules above: Vite's CSS pipeline keys
|
|
62
|
+
* off the `.css` extension of a *resolvable* id, and a NUL-prefixed id is
|
|
63
|
+
* excluded from it. The prefix is distinctive enough that nothing else can
|
|
64
|
+
* collide with it.
|
|
65
|
+
*/
|
|
66
|
+
const STYLE_PREFIX = "uf-style:";
|
|
67
|
+
|
|
53
68
|
/** The URL a NUL-prefixed module is served at in development. */
|
|
54
69
|
export function devUrlFor(id) {
|
|
55
70
|
return `/@id/__x00__${id}`;
|
|
@@ -89,6 +104,15 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
89
104
|
let server = null;
|
|
90
105
|
/** @type {TransformService | null} */
|
|
91
106
|
let service = null;
|
|
107
|
+
/**
|
|
108
|
+
* Each module's compiled stylesheet, keyed by the virtual id serving it.
|
|
109
|
+
*
|
|
110
|
+
* A map rather than one accumulated sheet: Vite asks for a module's CSS when
|
|
111
|
+
* it loads that module, re-asks when the module changes, and drops it when
|
|
112
|
+
* the module goes away. One shared sheet would have to be invalidated by
|
|
113
|
+
* hand, which is the part that goes wrong.
|
|
114
|
+
*/
|
|
115
|
+
const styles = new Map();
|
|
92
116
|
|
|
93
117
|
const ensureService = () => {
|
|
94
118
|
service ??= new TransformService({ command, root });
|
|
@@ -144,6 +168,10 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
144
168
|
resolveId(id) {
|
|
145
169
|
if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
|
|
146
170
|
if (VIRTUAL_IDS.has(id)) return resolved(id);
|
|
171
|
+
// A module's own stylesheet, which `transform` below asked for by
|
|
172
|
+
// importing this id. Returning it unchanged marks it resolved without
|
|
173
|
+
// Vite going to the filesystem for a file that does not exist.
|
|
174
|
+
if (id.startsWith(STYLE_PREFIX)) return id;
|
|
147
175
|
return null;
|
|
148
176
|
},
|
|
149
177
|
|
|
@@ -152,6 +180,7 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
152
180
|
if (id === resolved(VIRTUAL.routes)) return routesModuleSource(scanRoutes(appRoot));
|
|
153
181
|
if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
|
|
154
182
|
if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
|
|
183
|
+
if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
|
|
155
184
|
return null;
|
|
156
185
|
},
|
|
157
186
|
|
|
@@ -169,9 +198,24 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
169
198
|
this.warn?.(`${diagnostic.function ?? "a function"}: ${diagnostic.message}`);
|
|
170
199
|
}
|
|
171
200
|
const map = out.map == null ? null : JSON.parse(out.map);
|
|
172
|
-
|
|
201
|
+
// StyleX. `uf transform` compiled the module's `stylex.create` calls into
|
|
202
|
+
// class names and handed back the rules they declared; the rules become a
|
|
203
|
+
// module of their own that this one imports.
|
|
204
|
+
//
|
|
205
|
+
// Handing the CSS to Vite as a module, rather than collecting it here and
|
|
206
|
+
// writing a stylesheet at the end, is what keeps uf out of the CSS
|
|
207
|
+
// business: Vite already injects a stylesheet in dev, extracts it in a
|
|
208
|
+
// build, code-splits it per chunk, and replaces it over HMR. A module
|
|
209
|
+
// whose styles are gone stops importing it, and Vite notices.
|
|
210
|
+
let output = out.code;
|
|
211
|
+
if (out.css != null && out.css !== "") {
|
|
212
|
+
const styleId = `${STYLE_PREFIX}${cleanId(id)}.css`;
|
|
213
|
+
styles.set(styleId, out.css);
|
|
214
|
+
output = `import ${JSON.stringify(styleId)};\n${output}`;
|
|
215
|
+
}
|
|
216
|
+
if (!refresh) return { code: output, map };
|
|
173
217
|
const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
|
|
174
|
-
return addRefreshWrapper(
|
|
218
|
+
return addRefreshWrapper(output, map, relative);
|
|
175
219
|
},
|
|
176
220
|
|
|
177
221
|
buildEnd() {
|
|
@@ -248,12 +292,23 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
248
292
|
function mdxPlugin(markdown) {
|
|
249
293
|
const mdxConfig = markdown.mdx ?? {};
|
|
250
294
|
if (mdxConfig.enabled === false) return { name: "uf:mdx" };
|
|
295
|
+
|
|
296
|
+
// Highlighting is on unless a project turns it off, and it happens here
|
|
297
|
+
// rather than in the browser: the colours are in the HTML, so a code sample
|
|
298
|
+
// is readable before any JavaScript loads and no highlighter is shipped.
|
|
299
|
+
const highlight = highlightPlugin(mdxConfig.highlight);
|
|
300
|
+
const rehypePlugins = highlight == null ? [rehypeSlug] : [rehypeSlug, highlight];
|
|
301
|
+
|
|
251
302
|
return {
|
|
252
303
|
enforce: "pre",
|
|
253
304
|
...mdx({
|
|
254
305
|
jsxImportSource: "react",
|
|
255
|
-
remarkPlugins: [
|
|
256
|
-
|
|
306
|
+
remarkPlugins: [
|
|
307
|
+
remarkGfm,
|
|
308
|
+
remarkFrontmatter,
|
|
309
|
+
[remarkMdxFrontmatter, { name: "frontmatter" }],
|
|
310
|
+
],
|
|
311
|
+
rehypePlugins,
|
|
257
312
|
}),
|
|
258
313
|
name: "uf:mdx",
|
|
259
314
|
};
|
package/internal/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
1
3
|
// Plain JavaScript: executed by the host that runs Vite, before any transform.
|
|
2
4
|
//
|
|
3
5
|
// Loading `uf.config.js`.
|
|
@@ -15,11 +17,12 @@
|
|
|
15
17
|
// data and cannot use the functions.
|
|
16
18
|
|
|
17
19
|
import { createHash } from "node:crypto";
|
|
18
|
-
import {
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import { pathToFileURL } from "node:url";
|
|
21
23
|
|
|
22
|
-
import { transformFlow } from "
|
|
24
|
+
import { transformFlow } from "@uniflowed/host/transform";
|
|
25
|
+
import { writeAtomically } from "@uniflowed/host/write-atomically";
|
|
23
26
|
|
|
24
27
|
/** The one config file name uf reads. */
|
|
25
28
|
export const CONFIG_FILES = ["uf.config.js"];
|
|
@@ -63,14 +66,18 @@ export async function loadUfConfig(root) {
|
|
|
63
66
|
|
|
64
67
|
const source = readFileSync(file, "utf8");
|
|
65
68
|
if (source.length > MAX_CONFIG_BYTES) {
|
|
66
|
-
throw new Error(
|
|
69
|
+
throw new Error(
|
|
70
|
+
`uf: ${file} is ${source.length} bytes, over the ${MAX_CONFIG_BYTES} byte ceiling`,
|
|
71
|
+
);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
const compiled = await compileConfig(source, file, root);
|
|
70
75
|
const module = await import(pathToFileURL(compiled).href);
|
|
71
76
|
const config = module.default;
|
|
72
77
|
if (config == null || typeof config !== "object") {
|
|
73
|
-
throw new Error(
|
|
78
|
+
throw new Error(
|
|
79
|
+
`uf: ${path.relative(root, file)} must \`export default defineConfig({ ... })\``,
|
|
80
|
+
);
|
|
74
81
|
}
|
|
75
82
|
return { config, file };
|
|
76
83
|
}
|
|
@@ -88,8 +95,13 @@ async function compileConfig(source, file, root) {
|
|
|
88
95
|
|
|
89
96
|
const out = await transformFlow(source, file, { root, sourceMap: false });
|
|
90
97
|
const code = rewriteRelativeImports(out?.code ?? source, path.dirname(file));
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
// Atomically, because two `uf` commands in one project write this same path
|
|
99
|
+
// at the same time — the hash is of the source, so they agree on the name —
|
|
100
|
+
// and `writeFileSync` truncates before it writes. A reader that caught it
|
|
101
|
+
// mid-write imported a module with no exports and reported it as
|
|
102
|
+
// `uf.config.js must export default defineConfig({ ... })`, which is a
|
|
103
|
+
// sentence about a file that is perfectly correct. See ubugeeei-prod/uf#240.
|
|
104
|
+
writeAtomically(target, `// Compiled from ${file}. Do not edit; edit the source.\n${code}`);
|
|
93
105
|
return target;
|
|
94
106
|
}
|
|
95
107
|
|
|
@@ -126,7 +138,10 @@ export function projectConfig(config) {
|
|
|
126
138
|
JSON.stringify(config, (key, value) => {
|
|
127
139
|
if (typeof value === "function") return undefined;
|
|
128
140
|
if (key === "plugins" && Array.isArray(value)) {
|
|
129
|
-
return value
|
|
141
|
+
return value
|
|
142
|
+
.flat(Infinity)
|
|
143
|
+
.map(pluginName)
|
|
144
|
+
.filter((name) => name != null);
|
|
130
145
|
}
|
|
131
146
|
return value;
|
|
132
147
|
}),
|
package/internal/events.js
CHANGED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// The Flow syntax a JavaScript grammar cannot parse, shown to it as
|
|
4
|
+
// JavaScript it can, and taken back afterwards.
|
|
5
|
+
//
|
|
6
|
+
// # Why re-tagging the word is not enough
|
|
7
|
+
//
|
|
8
|
+
// `internal/flow-keywords.js` colours Flow's words after the grammar has run.
|
|
9
|
+
// That works for a word the grammar tokenised and mis-labelled. It cannot work
|
|
10
|
+
// for syntax that stops the grammar, because there are then no tokens to
|
|
11
|
+
// re-label — the whole construct arrives as one unstyled run. Two pieces of
|
|
12
|
+
// Flow do that, and both are common enough that this repository's own
|
|
13
|
+
// documentation hit them on its first page about Flow.
|
|
14
|
+
//
|
|
15
|
+
// **A `component` or `hook` declaration.** A JavaScript grammar has no
|
|
16
|
+
// production for an identifier where a declaration keyword belongs, so it
|
|
17
|
+
// gives up on the rest of the line:
|
|
18
|
+
//
|
|
19
|
+
// export component Avatar(src: string, size: number = 32) {
|
|
20
|
+
// └ keyword ┘ └ name ┘ └───────── one grey token ─────────┘
|
|
21
|
+
//
|
|
22
|
+
// The parameter names, their types and the default value were all the same
|
|
23
|
+
// undifferentiated grey. `export hook useNow(…) {` was worse: the grammar's
|
|
24
|
+
// state did not recover, and all eight lines of that sample came out as one
|
|
25
|
+
// grey token each — a code block with no highlighting at all, on the page
|
|
26
|
+
// whose subject is Flow's syntax.
|
|
27
|
+
//
|
|
28
|
+
// **An exact object type, `{| … |}`.** This one is quieter and travels
|
|
29
|
+
// further. The grammar reads the `{|` as a brace and a bitwise or, and every
|
|
30
|
+
// line *after* it in the same block is then mis-scoped: `export` came out in
|
|
31
|
+
// the colour of a function call, `return` likewise, and a JSX tag lost its
|
|
32
|
+
// element colour. One type annotation discoloured the rest of the sample.
|
|
33
|
+
//
|
|
34
|
+
// # What this does instead
|
|
35
|
+
//
|
|
36
|
+
// It hands the grammar `function` where the source says `component` or `hook`,
|
|
37
|
+
// and a plain brace where the source says `{|`, takes the tokens that
|
|
38
|
+
// produces, and rebuilds them over the original text. The grammar then walks
|
|
39
|
+
// the parameter list, the return type and the body the way it does for any
|
|
40
|
+
// function, and `flow-keywords.js` recolours the restored words.
|
|
41
|
+
//
|
|
42
|
+
// # Why this cannot corrupt the sample
|
|
43
|
+
//
|
|
44
|
+
// Because {@link restoreLine} never copies from the text the grammar saw. It
|
|
45
|
+
// maps each token's boundaries back into the original line and slices *that*,
|
|
46
|
+
// so the concatenation of a restored line is the original line by
|
|
47
|
+
// construction, whatever the grammar decided to do with a stand-in. A
|
|
48
|
+
// mis-shimmed line can come out with the wrong colours. It cannot come out
|
|
49
|
+
// saying `function`.
|
|
50
|
+
//
|
|
51
|
+
// # What it does not cover
|
|
52
|
+
//
|
|
53
|
+
// The declaration rewrite is anchored to the start of a line, after an
|
|
54
|
+
// optional `export` or `export default`, which is where a declaration begins
|
|
55
|
+
// and where the grammar breaks. A declaration written anywhere else is left to
|
|
56
|
+
// the ordinary path.
|
|
57
|
+
//
|
|
58
|
+
// Neither rewrite asks whether the line is inside a string or a comment, so a
|
|
59
|
+
// line of quoted sample code that opens with `component Name(` is rewritten
|
|
60
|
+
// too. The text still survives exactly; only its colours are a function's
|
|
61
|
+
// rather than a string's, and `flow-keywords.js` still refuses to call the
|
|
62
|
+
// word a keyword. Buying the remaining fidelity would mean lexing the block
|
|
63
|
+
// twice, once here and once there, to fix a case that is a code sample inside
|
|
64
|
+
// a code sample.
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A `component` or `hook` declaration head at the start of a line.
|
|
68
|
+
*
|
|
69
|
+
* The name and the opening bracket are matched but not captured: requiring
|
|
70
|
+
* them is what distinguishes a declaration from `const component = 1`, and
|
|
71
|
+
* consuming them would mean putting them back.
|
|
72
|
+
*/
|
|
73
|
+
const DECLARATION_HEAD =
|
|
74
|
+
/^([ \t]*(?:export[ \t]+(?:default[ \t]+)?)?)(component|hook)(?=[ \t]+[A-Za-z_$][\w$]*[ \t]*[(<])/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The word a declaration keyword is shown as.
|
|
78
|
+
*
|
|
79
|
+
* `function` and not `function*`: a generator tokenises identically here, and
|
|
80
|
+
* the length no longer has to match now that restoration maps positions rather
|
|
81
|
+
* than assuming they line up.
|
|
82
|
+
*/
|
|
83
|
+
const DECLARATION_STAND_IN = "function";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The braces of an exact object type, and the ordinary braces they are shown
|
|
87
|
+
* as.
|
|
88
|
+
*
|
|
89
|
+
* Same length in both directions, so the rest of the line does not move; the
|
|
90
|
+
* mapping would cope either way, but a rewrite that cannot shift anything is
|
|
91
|
+
* one less thing to reason about. `{||}` — the empty exact object — is two
|
|
92
|
+
* adjacent rewrites rather than an overlapping one, which is why these are
|
|
93
|
+
* matched as a pair of two-character sequences rather than as one bracket.
|
|
94
|
+
*/
|
|
95
|
+
const EXACT_OBJECT = /\{\||\|\}/g;
|
|
96
|
+
|
|
97
|
+
const EXACT_OBJECT_STAND_INS = { "{|": "{ ", "|}": " }" };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `source` rewritten for the grammar, with the undo that belongs to it.
|
|
101
|
+
*
|
|
102
|
+
* The undo is returned rather than exported separately because it closes over
|
|
103
|
+
* the original lines, and pairing the wrong undo with a rewrite is the one
|
|
104
|
+
* mistake that would matter. `restore` is the identity when nothing was
|
|
105
|
+
* rewritten, so the caller has no case to distinguish.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} source
|
|
108
|
+
* @returns {{code: string, restore: (lines: Array<Array<object>>) => Array<Array<object>>}}
|
|
109
|
+
*/
|
|
110
|
+
export function shimFlowGrammar(source) {
|
|
111
|
+
const original = source.split("\n");
|
|
112
|
+
const edits = new Map();
|
|
113
|
+
|
|
114
|
+
const shimmed = original.map((line, index) => {
|
|
115
|
+
const lineEdits = editsFor(line);
|
|
116
|
+
if (lineEdits.length === 0) {
|
|
117
|
+
return line;
|
|
118
|
+
}
|
|
119
|
+
edits.set(index, lineEdits);
|
|
120
|
+
return rewrite(line, lineEdits);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (edits.size === 0) {
|
|
124
|
+
return { code: source, restore: (lines) => lines };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
code: shimmed.join("\n"),
|
|
128
|
+
restore: (lines) => restore(lines, edits, original),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Every rewrite one line needs, in the order they occur.
|
|
134
|
+
*
|
|
135
|
+
* Ordered and non-overlapping, because {@link mapping} walks them once and
|
|
136
|
+
* accumulates the shift each one makes. The declaration head is found first
|
|
137
|
+
* and starts at the line's indentation, so it can never overlap an exact
|
|
138
|
+
* object brace, which needs a `{` or a `|`.
|
|
139
|
+
*/
|
|
140
|
+
function editsFor(line) {
|
|
141
|
+
const found = [];
|
|
142
|
+
const head = DECLARATION_HEAD.exec(line);
|
|
143
|
+
if (head != null) {
|
|
144
|
+
found.push({ column: head[1].length, text: head[2], standIn: DECLARATION_STAND_IN });
|
|
145
|
+
}
|
|
146
|
+
for (const brace of line.matchAll(EXACT_OBJECT)) {
|
|
147
|
+
found.push({
|
|
148
|
+
column: brace.index ?? 0,
|
|
149
|
+
text: brace[0],
|
|
150
|
+
standIn: EXACT_OBJECT_STAND_INS[brace[0]],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `line` with every rewrite applied, left to right. */
|
|
157
|
+
function rewrite(line, edits) {
|
|
158
|
+
let out = "";
|
|
159
|
+
let at = 0;
|
|
160
|
+
for (const edit of edits) {
|
|
161
|
+
out += line.slice(at, edit.column) + edit.standIn;
|
|
162
|
+
at = edit.column + edit.text.length;
|
|
163
|
+
}
|
|
164
|
+
return out + line.slice(at);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The tokenised block, rebuilt over the original source.
|
|
169
|
+
*
|
|
170
|
+
* Offsets are recomputed rather than adjusted: a stand-in that is not the
|
|
171
|
+
* length of the text it stands for moves everything after it on the line, and
|
|
172
|
+
* every line after that one, so there is no correct delta to add. Walking the
|
|
173
|
+
* restored lines gives the right answer directly.
|
|
174
|
+
*/
|
|
175
|
+
function restore(lines, edits, original) {
|
|
176
|
+
let offset = 0;
|
|
177
|
+
return lines.map((tokens, index) => {
|
|
178
|
+
const text = original[index] ?? tokens.map((token) => token.content).join("");
|
|
179
|
+
const lineEdits = edits.get(index);
|
|
180
|
+
const restored =
|
|
181
|
+
lineEdits == null ? reoffset(tokens, offset) : restoreLine(tokens, text, lineEdits, offset);
|
|
182
|
+
offset += text.length + 1;
|
|
183
|
+
return restored;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A line the grammar saw unchanged, with its offsets moved to the original. */
|
|
188
|
+
function reoffset(tokens, offset) {
|
|
189
|
+
let at = 0;
|
|
190
|
+
return tokens.map((token) => {
|
|
191
|
+
const placed = { ...token, offset: offset + at };
|
|
192
|
+
at += token.content.length;
|
|
193
|
+
return placed;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One rewritten line's tokens, re-cut over the original text.
|
|
199
|
+
*
|
|
200
|
+
* Every token boundary is a position in the line the grammar saw; {@link
|
|
201
|
+
* mapping} turns it into a position in the line the reader gets. A boundary
|
|
202
|
+
* that falls *inside* a stand-in maps to the end of the text it stands for, so
|
|
203
|
+
* that text always lands in exactly one piece and no character of the line is
|
|
204
|
+
* dropped — the pieces still tile the line end to end even when the grammar
|
|
205
|
+
* split a stand-in or swallowed it into a longer token.
|
|
206
|
+
*/
|
|
207
|
+
function restoreLine(tokens, text, edits, offset) {
|
|
208
|
+
const map = mapping(edits);
|
|
209
|
+
const out = [];
|
|
210
|
+
let seen = 0;
|
|
211
|
+
let at = 0;
|
|
212
|
+
for (const token of tokens) {
|
|
213
|
+
const from = map(seen);
|
|
214
|
+
seen += token.content.length;
|
|
215
|
+
const to = map(seen);
|
|
216
|
+
if (to <= from) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
out.push({ ...token, content: text.slice(from, to), offset: offset + at });
|
|
220
|
+
at += to - from;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A position in the rewritten line, as a position in the original one. */
|
|
226
|
+
function mapping(edits) {
|
|
227
|
+
return (at) => {
|
|
228
|
+
let shift = 0;
|
|
229
|
+
for (const edit of edits) {
|
|
230
|
+
const from = edit.column + shift;
|
|
231
|
+
if (at <= from) {
|
|
232
|
+
return at - shift;
|
|
233
|
+
}
|
|
234
|
+
if (at < from + edit.standIn.length) {
|
|
235
|
+
return edit.column + edit.text.length;
|
|
236
|
+
}
|
|
237
|
+
shift += edit.standIn.length - edit.text.length;
|
|
238
|
+
}
|
|
239
|
+
return at - shift;
|
|
240
|
+
};
|
|
241
|
+
}
|