@uniflowed/vite 0.0.0-alpha.18 → 0.0.0-alpha.21

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/index.js CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  RESERVED,
81
81
  VIRTUAL,
82
82
  clientModuleSource,
83
+ resolveRouteTarget,
83
84
  routesModuleSource,
84
85
  scanRoutes,
85
86
  serverModuleSource,
@@ -115,6 +116,7 @@ export function devUrlFor(id) {
115
116
  * @typedef {object} UniflowedOptions
116
117
  * @property {string} [root] absolute project root; Vite's root by default
117
118
  * @property {object} [config] the loaded `uf.config.js` object
119
+ * @property {"web" | "native" | "ios" | "android"} [target] app target
118
120
  * @property {string} [command] the `uf` binary to transform through
119
121
  */
120
122
 
@@ -126,6 +128,7 @@ export function devUrlFor(id) {
126
128
  export default function uniflowed(options = {}) {
127
129
  const ufConfig = options.config ?? {};
128
130
  const app = ufConfig.app ?? {};
131
+ const routeTarget = resolveRouteTarget(ufConfig, options.target);
129
132
  const routerRoot = app.router?.root ?? "app";
130
133
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
131
134
  const markdown = app.builtins?.markdown ?? {};
@@ -135,6 +138,20 @@ export default function uniflowed(options = {}) {
135
138
  // no `uf.config.js` will mention. It only ever reaches the *development*
136
139
  // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
137
140
  const strictMode = app.react?.strictMode !== false;
141
+ // What the browser does with a link, read here for the reason Strict Mode is
142
+ // and honoured in every command rather than in the build alone: it is the
143
+ // one setting whose whole effect is what happens on a click, so a dev server
144
+ // that disagreed with the deployment would be the wrong application to look
145
+ // at. Anything but `"document"` is the client router, which is what every
146
+ // project that has not heard of the key has.
147
+ const navigation = app.rendering?.navigation === "document" ? "document" : "client";
148
+ // Whether this application starts by attaching to markup or by rendering
149
+ // into an empty root. `["csr"]` is the only list that means the second, and
150
+ // `uf` refuses that value beside any other while the config is read — so the
151
+ // question here is "is it in the list", not "is it the only thing in it",
152
+ // and a driver started by hand on a config `uf` never validated gets the same
153
+ // answer for the same reason a project would want.
154
+ const mount = (app.rendering?.modes ?? []).includes("csr") ? "render" : "hydrate";
138
155
 
139
156
  const accessibility = ufConfig.accessibility ?? {};
140
157
 
@@ -142,7 +159,10 @@ export default function uniflowed(options = {}) {
142
159
  flowPlugin({
143
160
  routerRoot,
144
161
  appEntry,
162
+ routeTarget,
145
163
  strictMode,
164
+ navigation,
165
+ mount,
146
166
  command: options.command,
147
167
  accessibility,
148
168
  }),
@@ -157,7 +177,16 @@ export default function uniflowed(options = {}) {
157
177
  ];
158
178
  }
159
179
 
160
- function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }) {
180
+ function flowPlugin({
181
+ routerRoot,
182
+ appEntry,
183
+ routeTarget,
184
+ strictMode,
185
+ navigation,
186
+ mount,
187
+ command,
188
+ accessibility,
189
+ }) {
161
190
  let root = process.cwd();
162
191
  let isProduction = false;
163
192
  /**
@@ -236,10 +265,15 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
236
265
  // survives the build — so the browser's table was shipping the absolute
237
266
  // path of every page on the machine that built the site, to every visitor.
238
267
  // The server's table is read where those files are and keeps them.
239
- return routesModuleSource(table, {
240
- shipsPage: (route) => kept.has(route),
241
- relativeTo: root,
242
- });
268
+ // HTTP handlers are exclusively server capabilities. Even an unused
269
+ // dynamic import makes Vite traverse the handler's database/auth imports.
270
+ return routesModuleSource(
271
+ { ...table, handlers: [] },
272
+ {
273
+ shipsPage: (route) => kept.has(route),
274
+ relativeTo: root,
275
+ },
276
+ );
243
277
  };
244
278
 
245
279
  /**
@@ -331,7 +365,10 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
331
365
  ensureService();
332
366
  },
333
367
 
334
- resolveId(id) {
368
+ resolveId(id, importer, resolveOptions) {
369
+ if (id === "@uniflowed/react" && !isSsr(this, resolveOptions)) {
370
+ return this.resolve("react", importer, { ...resolveOptions, skipSelf: true });
371
+ }
335
372
  if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
336
373
  if (id === AUDIT_PUBLIC_PATH) return AUDIT_RESOLVED_ID;
337
374
  if (VIRTUAL_IDS.has(id)) return resolved(id);
@@ -346,7 +383,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
346
383
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
347
384
  if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
348
385
  if (id === resolved(VIRTUAL.routes)) {
349
- const table = scanRoutes(appRoot);
386
+ const table = scanRoutes(appRoot, { target: routeTarget });
350
387
  // The server renders every route, so the server's table is the whole
351
388
  // one and is generated with no filter at all. Only the browser's copy
352
389
  // is split.
@@ -356,8 +393,17 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
356
393
  // Strict Mode belongs to the client entry and to development only: a
357
394
  // build passes `false`, so the generated module is the one that existed
358
395
  // before #516 and a visitor's browser renders once.
396
+ //
397
+ // `navigation` is in the same entry and has no `isProduction` beside it,
398
+ // deliberately: it is what a link does, and a dev server whose links
399
+ // behave differently from the deployment is the wrong thing to be
400
+ // looking at. See `clientModuleSource`.
359
401
  if (id === resolved(VIRTUAL.client)) {
360
- return clientModuleSource(entryPath, { strictMode: strictMode && !isProduction });
402
+ return clientModuleSource(entryPath, {
403
+ strictMode: strictMode && !isProduction,
404
+ navigation,
405
+ mount,
406
+ });
361
407
  }
362
408
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
363
409
  // Only `virtual:uf/server` imports this, so it is only ever asked for in
@@ -474,12 +520,13 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
474
520
  const tags = [
475
521
  {
476
522
  tag: "script",
523
+ attrs: { "data-uf-dev-head-preamble": "react-devtools" },
477
524
  children: devtoolsPreamble(),
478
525
  injectTo: "head-prepend",
479
526
  },
480
527
  {
481
528
  tag: "script",
482
- attrs: { type: "module" },
529
+ attrs: { type: "module", "data-uf-dev-head-preamble": "react-refresh" },
483
530
  children: preambleCode(base),
484
531
  injectTo: "head-prepend",
485
532
  },
@@ -509,9 +556,8 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
509
556
  // missing `route` — so adding a route handler to a running dev server
510
557
  // did not rebuild the table and the handler stayed invisible until a
511
558
  // restart. A list that has to match another list has to be that list.
512
- const stems = Object.values(RESERVED)
513
- .map((stem) => stem.replaceAll(".", "\\."))
514
- .join("|");
559
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
560
+ const stems = Object.values(RESERVED).map(escapeRegExp).join("|");
515
561
  const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
516
562
  const onRouteFile = (file) => {
517
563
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
@@ -562,7 +608,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
562
608
  // claimed, this one decided, and the other was reached only for what
563
609
  // this one declined. Route-handler dispatch was in the other. A
564
610
  // `GET /feed` from a browser is `Accept: text/html` with no extension,
565
- // so it looked like a document, so `app/feed/_uf.route.js` was never
611
+ // so it looked like a document, so `app/feed/$route.js` was never
566
612
  // asked and the reader got the route table's page — or the not-found
567
613
  // page — for a path that had a handler. See ubugeeei-prod/uf#349.
568
614
  //
@@ -598,7 +644,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
598
644
  // list rather than something this middleware can decide on its own.
599
645
  return () => {
600
646
  // The browser's own reporting channel, mounted above the application
601
- // so that a report never reaches a project's `_uf.middleware.js` or
647
+ // so that a report never reaches a project's `$middleware.js` or
602
648
  // its route table. See `internal/diagnostics.js`.
603
649
  devServer.middlewares.use(
604
650
  createChannelMiddleware((diagnostic) => emit("diagnostic", diagnostic)),
@@ -687,6 +733,30 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
687
733
  }
688
734
  if (notDocument != null) return false;
689
735
 
736
+ // A single-page project's deployment answers every navigation
737
+ // with the same empty shell, so this does too. Rendering the
738
+ // route here instead would have been the better-looking dev
739
+ // server and the wrong one: a page that only works because the
740
+ // server rendered it would work all through development and be
741
+ // blank the day it shipped. It is the same argument
742
+ // `app.rendering.navigation` makes about a link, one level up.
743
+ //
744
+ // The three steps above still ran — the guard, the action, the
745
+ // handler — and each of them is something `uf build` refuses in
746
+ // a `["csr"]` project by name. A dev server that skipped them
747
+ // would hide the very thing the build is going to stop.
748
+ if (mount === "render") {
749
+ response.statusCode = 200;
750
+ response.setHeader("content-type", "text/html; charset=utf-8");
751
+ const shell = entry.shellDocument({
752
+ scripts: [devUrlFor(VIRTUAL.client)],
753
+ styles: [],
754
+ preloads: [],
755
+ });
756
+ response.end(await devServer.transformIndexHtml(url, shell));
757
+ return true;
758
+ }
759
+
690
760
  const result = await entry.render(
691
761
  url,
692
762
  { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
@@ -699,6 +769,22 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
699
769
  // Vite sees the head and only the head. That is what lets the
700
770
  // development server stream like every other host — see below.
701
771
  transformHead: (head) => devServer.transformIndexHtml(url, head),
772
+ // And what the streaming actually did, when it changed. The
773
+ // router has already decided there is something worth saying
774
+ // and written the words — see its `internal/inspector.js`,
775
+ // which cannot be imported from here because this file is
776
+ // plain JavaScript that Vite loads before any Flow transform
777
+ // exists. `info`, because a page that streamed is a
778
+ // measurement and not a problem; `origin`, because a report
779
+ // that does not say which page produced it is one somebody
780
+ // has to reproduce before they can act on it.
781
+ onStream: (diagnostic) =>
782
+ emit("diagnostic", {
783
+ severity: "info",
784
+ origin: url,
785
+ message: diagnostic.message,
786
+ detail: diagnostic.detail,
787
+ }),
702
788
  },
703
789
  );
704
790
  if (result.error != null) reportRenderError(devServer, url, result.error);
@@ -707,7 +793,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
707
793
  // `transformIndexHtml` is a *whole document* hook — which made the
708
794
  // one place a developer would notice streaming the one place it
709
795
  // did not happen: a slow page showed nothing until it was finished
710
- // and `_uf.loading.js` looked broken.
796
+ // and `$loading.js` looked broken.
711
797
  //
712
798
  // `transformHead` above is the seam. `internal/stream.js` already
713
799
  // held the opening chunk back until the head was complete and
package/internal/a11y.js CHANGED
@@ -86,15 +86,17 @@ export function auditRuntimeSource(settings) {
86
86
  /**
87
87
  * The tag that loads it, or `null` when this project has no engine.
88
88
  *
89
- * `injectTo: "body"` rather than the head: the audit reads the rendered tree,
90
- * so there is nothing for it to do until there is one, and a script in the
91
- * head would only sit through the same wait with the parser stopped behind it.
89
+ * `injectTo: "head"` because `uf dev` streams the body. Vite's HTML hook only
90
+ * sees the opening chunk; a `body` injection can land at a React chunk boundary
91
+ * and split an attribute before the rest of the body arrives. A module in the
92
+ * head still waits for a settled DOM before auditing — see the runtime — while
93
+ * staying in markup the opening transform can safely rewrite.
92
94
  */
93
95
  export function auditTag(base, available) {
94
96
  if (!available) return null;
95
97
  return {
96
98
  tag: "script",
97
99
  attrs: { type: "module", src: `${base}${AUDIT_PUBLIC_PATH.slice(1)}` },
98
- injectTo: "body",
100
+ injectTo: "head",
99
101
  };
100
102
  }
@@ -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
  *
@@ -23,8 +23,8 @@
23
23
  // how the preamble is injected, could have taken it away in a diff nobody would
24
24
  // read as being about DevTools. See ubugeeei-prod/uf#503.
25
25
  //
26
- // So the hook is installed here, first, deliberately, and `tests/library/
27
- // devtools.test.js` runs this script's own text against a fake window.
26
+ // So the hook is installed here, first, deliberately, and `devtools.test.js`
27
+ // beside this package runs this script's own text against a fake window.
28
28
  //
29
29
  // # Three things DevTools needs, and what carries each
30
30
  //
@@ -57,7 +57,7 @@
57
57
  // `@uniflowed/router`'s `internal/diagnostics.js` are the same two strings, and
58
58
  // they are the contract. They cannot be *imported* here: this module is loaded
59
59
  // by Vite before any Flow transform exists, and both of those are Flow. So they
60
- // are written out, and `tests/library/dev-channel.test.js` asserts that all
60
+ // are written out, and `packages/vite/dev-channel.test.js` asserts that all
61
61
  // four spellings agree — a duplicated constant with a test on it is honest, and
62
62
  // one without is how the browser ends up posting to a path nothing serves.
63
63
  //
@@ -105,7 +105,7 @@ const SEVERITIES = new Set(["error", "warn", "info"]);
105
105
  * The connect middleware that answers the channel.
106
106
  *
107
107
  * Mounted **before** the application middleware, so a request under `/__uf/`
108
- * never reaches a project's `_uf.middleware.js` or its route table. A guard
108
+ * never reaches a project's `$middleware.js` or its route table. A guard
109
109
  * that ran for a page's own telemetry would be a guard asked a question the
110
110
  * application never asks, and one that redirected it would turn a report into
111
111
  * a login page.
@@ -435,7 +435,7 @@ export function markLines(lines) {
435
435
  *
436
436
  * Exported because it is where the decision is visible without starting Shiki:
437
437
  * give it the token split a grammar would produce and it says which words it
438
- * marked. `tests/library/highlight.test.js` uses exactly that, and the splits
438
+ * marked. `packages/vite/highlight.test.js` uses exactly that, and the splits
439
439
  * in it are the ones that had bugs.
440
440
  */
441
441
  export function markLine(line) {
@@ -0,0 +1,218 @@
1
+ // @noflow
2
+ //
3
+ // Route-handler OpenAPI output.
4
+ //
5
+ // The build driver imports this module before it registers the Node Flow
6
+ // hooks, so it must stay plain JavaScript and must not statically import
7
+ // Flow-authored packages. The schema and method vocabulary are loaded inside
8
+ // `createOpenApiDocument`, after the driver has installed the hooks.
9
+
10
+ const JSON = "application/json";
11
+ const QUERY_EXTENSION = "x-uf-query";
12
+ const STANDARD_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
13
+
14
+ export async function createOpenApiDocument(handlers, options = {}) {
15
+ const [{ HANDLER_METHODS }, { toJsonSchema }] = await Promise.all([
16
+ import("@uniflowed/router/handler"),
17
+ import("@uniflowed/validator/json-schema"),
18
+ ]);
19
+ const paths = {};
20
+ for (const record of handlers ?? []) {
21
+ const routePath = openApiPath(record.path);
22
+ const pathItem = paths[routePath] ?? {};
23
+ paths[routePath] = pathItem;
24
+ const module = await loadHandlerModule(record, pathItem);
25
+ if (module == null) continue;
26
+ for (const method of implementedMethods(module, HANDLER_METHODS)) {
27
+ const operation = createOperation(record, method, schemaFor(module, method), toJsonSchema);
28
+ if (STANDARD_METHODS.has(method)) {
29
+ pathItem[method.toLowerCase()] = operation;
30
+ } else if (method === "QUERY") {
31
+ pathItem[QUERY_EXTENSION] = operation;
32
+ }
33
+ }
34
+ }
35
+ return {
36
+ openapi: "3.1.0",
37
+ info: {
38
+ title: options.title ?? "uf application",
39
+ version: options.version ?? "0.0.0",
40
+ },
41
+ paths,
42
+ };
43
+ }
44
+
45
+ async function loadHandlerModule(record, pathItem) {
46
+ try {
47
+ return await record.load();
48
+ } catch (error) {
49
+ if (record.file != null) pathItem["x-uf-source"] = record.file;
50
+ pathItem["x-uf-schema-unavailable"] = errorMessage(error);
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function implementedMethods(module, methods) {
56
+ return methods.filter(
57
+ (method) =>
58
+ typeof module[method] === "function" ||
59
+ (method === "HEAD" && typeof module.GET === "function"),
60
+ );
61
+ }
62
+
63
+ function schemaFor(module, method) {
64
+ const table = module.schemas;
65
+ if (!isRecord(table)) return null;
66
+ const schema = table[method];
67
+ if (schema == null) return null;
68
+ if (!isRecord(schema)) {
69
+ throw new Error(`route handler schemas.${method} must be an object`);
70
+ }
71
+ return schema;
72
+ }
73
+
74
+ function createOperation(record, method, schema, toJsonSchema) {
75
+ const parameters = pathParameters(record);
76
+ const operation = {
77
+ operationId: operationId(record, method),
78
+ responses: untypedResponses(method),
79
+ };
80
+ if (record.file != null) operation["x-uf-source"] = record.file;
81
+ if (parameters.length > 0) operation.parameters = parameters;
82
+ if (schema == null) {
83
+ operation["x-uf-untyped"] = true;
84
+ return operation;
85
+ }
86
+
87
+ const unrepresentable = [];
88
+ if (schema.query != null) {
89
+ addQuerySchema(operation, exportSchema(toJsonSchema, schema.query, "query", unrepresentable));
90
+ }
91
+ if (schema.body != null) {
92
+ operation.requestBody = {
93
+ required: true,
94
+ content: {
95
+ [JSON]: { schema: exportSchema(toJsonSchema, schema.body, "body", unrepresentable) },
96
+ },
97
+ };
98
+ }
99
+ if (schema.response != null) {
100
+ operation.responses = {
101
+ "200": {
102
+ description: method === "HEAD" ? "Typed response headers" : "Typed response",
103
+ content:
104
+ method === "HEAD"
105
+ ? undefined
106
+ : {
107
+ [JSON]: {
108
+ schema: exportSchema(toJsonSchema, schema.response, "response", unrepresentable),
109
+ },
110
+ },
111
+ },
112
+ };
113
+ if (method === "HEAD") delete operation.responses["200"].content;
114
+ }
115
+ if (unrepresentable.length > 0) {
116
+ operation["x-uf-unrepresentable"] = unrepresentable;
117
+ }
118
+ return operation;
119
+ }
120
+
121
+ function untypedResponses(method) {
122
+ return {
123
+ "200": {
124
+ description: method === "HEAD" ? "Untyped response headers" : "Untyped response",
125
+ },
126
+ };
127
+ }
128
+
129
+ function exportSchema(toJsonSchema, schema, part, unrepresentable) {
130
+ const exported = toJsonSchema(schema);
131
+ for (const item of exported.unrepresentable) {
132
+ unrepresentable.push({ part, path: item.path, kind: item.kind });
133
+ }
134
+ return stripDialect(exported.schema);
135
+ }
136
+
137
+ function addQuerySchema(operation, schema) {
138
+ if (schema.type !== "object" || !isRecord(schema.properties) || hasOwn(schema, "$defs")) {
139
+ operation["x-uf-query-schema"] = schema;
140
+ return;
141
+ }
142
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
143
+ const parameters = Object.keys(schema.properties).map((name) => ({
144
+ name,
145
+ in: "query",
146
+ required: required.has(name),
147
+ schema: schema.properties[name],
148
+ }));
149
+ operation.parameters = [...(operation.parameters ?? []), ...parameters];
150
+ }
151
+
152
+ function stripDialect(value) {
153
+ if (Array.isArray(value)) return value.map(stripDialect);
154
+ if (!isRecord(value)) return value;
155
+ const out = {};
156
+ for (const key of Object.keys(value)) {
157
+ if (key === "$schema") continue;
158
+ out[key] = stripDialect(value[key]);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ function openApiPath(routePath) {
164
+ return routePath
165
+ .split("/")
166
+ .map((segment) => {
167
+ if (!segment.startsWith(":")) return segment;
168
+ return `{${segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1)}}`;
169
+ })
170
+ .join("/");
171
+ }
172
+
173
+ function pathParameters(record) {
174
+ const params = record.params ?? paramsFromPath(record.path);
175
+ return params.map((param) => {
176
+ const parameter = {
177
+ name: param.name,
178
+ in: "path",
179
+ required: true,
180
+ schema: { type: "string" },
181
+ };
182
+ if (param.catchAll) {
183
+ parameter.description = "Catch-all route segment, slash-separated in the URL.";
184
+ }
185
+ return parameter;
186
+ });
187
+ }
188
+
189
+ function paramsFromPath(routePath) {
190
+ return routePath
191
+ .split("/")
192
+ .filter((segment) => segment.startsWith(":"))
193
+ .map((segment) => ({
194
+ name: segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1),
195
+ catchAll: segment.endsWith("*"),
196
+ }));
197
+ }
198
+
199
+ function operationId(record, method) {
200
+ const name = `${method.toLowerCase()} ${record.path}`;
201
+ return name
202
+ .replaceAll(/[^a-zA-Z0-9]+/g, " ")
203
+ .trim()
204
+ .replaceAll(/ ([a-zA-Z0-9])/g, (_, char) => char.toUpperCase());
205
+ }
206
+
207
+ function isRecord(value) {
208
+ return value != null && typeof value === "object" && !Array.isArray(value);
209
+ }
210
+
211
+ function hasOwn(value, key) {
212
+ return Object.prototype.hasOwnProperty.call(value, key);
213
+ }
214
+
215
+ function errorMessage(error) {
216
+ if (error instanceof Error) return error.message;
217
+ return String(error);
218
+ }