@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 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 ./bun-preload.js` instead
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("./internal/node-hooks.js", import.meta.url, { data: { root } });
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 = Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
82
-
83
- return {
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 }), ...userPlugins],
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((route) => route.path),
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", { url, file: path.relative(root, file), status: result.status, bytes: Buffer.byteLength(result.html) });
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", { url: "/404", file: path.relative(root, file), status: 404, bytes: Buffer.byteLength(result.html) });
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) ? value.map(encodeURIComponent).join("/") : encodeURIComponent(String(value ?? ""));
452
+ return Array.isArray(value)
453
+ ? value.map(encodeURIComponent).join("/")
454
+ : encodeURIComponent(String(value ?? ""));
350
455
  }
351
- if (segment.startsWith(":")) return encodeURIComponent(String(params[segment.slice(1)] ?? ""));
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 === "" ? path.join(outDir, "index.html") : path.join(outDir, pathname, "index.html");
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 and heading ids, so `.mdx` works with
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 "./transform.js";
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
- if (!refresh) return { code: out.code, map };
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(out.code, map, relative);
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: [remarkGfm, remarkFrontmatter, [remarkMdxFrontmatter, { name: "frontmatter" }]],
256
- rehypePlugins: [rehypeSlug],
306
+ remarkPlugins: [
307
+ remarkGfm,
308
+ remarkFrontmatter,
309
+ [remarkMdxFrontmatter, { name: "frontmatter" }],
310
+ ],
311
+ rehypePlugins,
257
312
  }),
258
313
  name: "uf:mdx",
259
314
  };
@@ -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 { mkdirSync, readFileSync, writeFileSync } from "node:fs";
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 "../transform.js";
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(`uf: ${file} is ${source.length} bytes, over the ${MAX_CONFIG_BYTES} byte ceiling`);
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(`uf: ${path.relative(root, file)} must \`export default defineConfig({ ... })\``);
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
- mkdirSync(directory, { recursive: true });
92
- writeFileSync(target, `// Compiled from ${file}. Do not edit; edit the source.\n${code}`);
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.flat(Infinity).map(pluginName).filter((name) => name != null);
141
+ return value
142
+ .flat(Infinity)
143
+ .map(pluginName)
144
+ .filter((name) => name != null);
130
145
  }
131
146
  return value;
132
147
  }),
@@ -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
  // The driver's control channel.
@@ -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
+ }