@nifrajs/web 5.1.0 → 5.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +12 -1
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +43 -6
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/codegen.d.ts +6 -0
  23. package/dist/internal/codegen.d.ts.map +1 -1
  24. package/dist/internal/codegen.js +23 -5
  25. package/dist/internal/codegen.js.map +1 -1
  26. package/dist/internal/copy-split.d.ts +27 -0
  27. package/dist/internal/copy-split.d.ts.map +1 -0
  28. package/dist/internal/copy-split.js +173 -0
  29. package/dist/internal/copy-split.js.map +1 -0
  30. package/dist/internal/page-execution.d.ts +19 -1
  31. package/dist/internal/page-execution.d.ts.map +1 -1
  32. package/dist/internal/page-execution.js +63 -8
  33. package/dist/internal/page-execution.js.map +1 -1
  34. package/dist/internal/render-document.d.ts +11 -2
  35. package/dist/internal/render-document.d.ts.map +1 -1
  36. package/dist/internal/render-document.js +38 -6
  37. package/dist/internal/render-document.js.map +1 -1
  38. package/dist/internal/typescript-api.d.ts +4 -0
  39. package/dist/internal/typescript-api.d.ts.map +1 -0
  40. package/dist/internal/typescript-api.js +8 -0
  41. package/dist/internal/typescript-api.js.map +1 -0
  42. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  43. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  44. package/dist/internal/vite-plugin-compat.js +82 -0
  45. package/dist/internal/vite-plugin-compat.js.map +1 -0
  46. package/dist/internal/web-app.d.ts +34 -0
  47. package/dist/internal/web-app.d.ts.map +1 -1
  48. package/dist/internal/web-app.js +22 -0
  49. package/dist/internal/web-app.js.map +1 -1
  50. package/dist/plugins/import-glob.d.ts +20 -0
  51. package/dist/plugins/import-glob.d.ts.map +1 -0
  52. package/dist/plugins/import-glob.js +401 -0
  53. package/dist/plugins/import-glob.js.map +1 -0
  54. package/dist/plugins/kit.d.ts +11 -0
  55. package/dist/plugins/kit.d.ts.map +1 -1
  56. package/dist/plugins/kit.js +32 -0
  57. package/dist/plugins/kit.js.map +1 -1
  58. package/dist/plugins/stylex.d.ts.map +1 -1
  59. package/dist/plugins/stylex.js +2 -2
  60. package/dist/plugins/stylex.js.map +1 -1
  61. package/dist/render-seam.d.ts +37 -0
  62. package/dist/render-seam.d.ts.map +1 -1
  63. package/dist/render-seam.js +33 -0
  64. package/dist/render-seam.js.map +1 -1
  65. package/dist/router.d.ts +3 -2
  66. package/dist/router.d.ts.map +1 -1
  67. package/dist/router.js +4 -4
  68. package/dist/router.js.map +1 -1
  69. package/dist/server-components/typescript.d.ts.map +1 -1
  70. package/dist/server-components/typescript.js +2 -7
  71. package/dist/server-components/typescript.js.map +1 -1
  72. package/dist/vite.d.ts +1 -28
  73. package/dist/vite.d.ts.map +1 -1
  74. package/dist/vite.js +3 -69
  75. package/dist/vite.js.map +1 -1
  76. package/package.json +5 -4
  77. package/src/build-plan.ts +21 -0
  78. package/src/build-vite.ts +60 -3
  79. package/src/build.ts +57 -4
  80. package/src/client.ts +15 -7
  81. package/src/index.ts +6 -0
  82. package/src/internal/codegen.ts +32 -3
  83. package/src/internal/copy-split.ts +220 -0
  84. package/src/internal/page-execution.ts +83 -7
  85. package/src/internal/render-document.ts +63 -6
  86. package/src/internal/typescript-api.ts +11 -0
  87. package/src/internal/vite-plugin-compat.ts +81 -0
  88. package/src/internal/web-app.ts +61 -1
  89. package/src/plugins/import-glob.ts +392 -0
  90. package/src/plugins/kit.ts +36 -0
  91. package/src/plugins/stylex.ts +2 -1
  92. package/src/render-seam.ts +60 -0
  93. package/src/router.ts +7 -6
  94. package/src/server-components/typescript.ts +2 -10
  95. package/src/vite.ts +3 -81
@@ -0,0 +1,392 @@
1
+ /**
2
+ * `import.meta.glob` for the Bun build. Vite expands it at build time and Bun has no equivalent, so
3
+ * without this a module that loads its siblings through a glob bundles with the call left in place:
4
+ * `import.meta.glob` is `undefined` in the browser and every file it named is missing from the build.
5
+ */
6
+ import { existsSync } from "node:fs"
7
+ import { dirname, extname, relative, resolve, sep } from "node:path"
8
+ import type { BunPlugin } from "bun"
9
+ import { readSource } from "./kit.ts"
10
+
11
+ const SOURCE = /\.[cm]?[jt]sx?$/
12
+ const CALL = /\bimport\.meta\.glob\b/g
13
+ const LOADERS: Readonly<Record<string, "ts" | "tsx" | "jsx" | "js">> = {
14
+ ".ts": "ts",
15
+ ".mts": "ts",
16
+ ".cts": "ts",
17
+ ".tsx": "tsx",
18
+ ".jsx": "jsx",
19
+ // Bun's own default: a `.js` module may hold JSX, a `.mjs` or `.cjs` one may not.
20
+ ".js": "jsx",
21
+ }
22
+
23
+ interface GlobOptions {
24
+ readonly eager: boolean
25
+ /** The export each entry takes: `"default"`, a name, or the whole module when unset. */
26
+ readonly pick: string | undefined
27
+ }
28
+
29
+ /** A literal the call's arguments may hold. */
30
+ type Literal = string | boolean | readonly string[] | Readonly<Record<string, string | boolean>>
31
+
32
+ /**
33
+ * `source` with every comment, string body and template text blanked to spaces, offsets kept, so a
34
+ * glob named in prose or inside a string is never read as a call. A template's `${}` stays code.
35
+ */
36
+ function codeOnly(source: string): string {
37
+ const out = source.split("")
38
+ const blank = (from: number, to: number): void => {
39
+ for (let index = from; index < to; index++) if (out[index] !== "\n") out[index] = " "
40
+ }
41
+ // Reads code from `start`; inside a template's `${`, returns past the `}` that closes it.
42
+ const code = (start: number, nested: boolean): number => {
43
+ let index = start
44
+ let depth = 0
45
+ while (index < source.length) {
46
+ const char = source[index]
47
+ if (source.startsWith("//", index)) {
48
+ const end = source.indexOf("\n", index)
49
+ const stop = end === -1 ? source.length : end
50
+ blank(index, stop)
51
+ index = stop
52
+ } else if (source.startsWith("/*", index)) {
53
+ const end = source.indexOf("*/", index + 2)
54
+ const stop = end === -1 ? source.length : end + 2
55
+ blank(index, stop)
56
+ index = stop
57
+ } else if (char === '"' || char === "'") {
58
+ let end = index + 1
59
+ while (end < source.length && source[end] !== char) end += source[end] === "\\" ? 2 : 1
60
+ blank(index + 1, Math.min(end, source.length))
61
+ index = end + 1
62
+ } else if (char === "`") {
63
+ index = template(index + 1)
64
+ } else if (nested && char === "}" && depth === 0) {
65
+ return index + 1
66
+ } else {
67
+ if (nested && char === "{") depth += 1
68
+ if (nested && char === "}") depth -= 1
69
+ index += 1
70
+ }
71
+ }
72
+ return index
73
+ }
74
+ const template = (start: number): number => {
75
+ let index = start
76
+ while (index < source.length && source[index] !== "`") {
77
+ if (source.startsWith("${", index)) {
78
+ index = code(index + 2, true)
79
+ } else {
80
+ const step = source[index] === "\\" ? 2 : 1
81
+ blank(index, index + step)
82
+ index += step
83
+ }
84
+ }
85
+ return index + 1
86
+ }
87
+ code(0, false)
88
+ return out.join("")
89
+ }
90
+
91
+ /** A reader over the call's argument text: string, array of strings, `{ key: literal }`, booleans. */
92
+ function argumentReader(text: string, start: number) {
93
+ let at = start
94
+ const space = (): void => {
95
+ while (at < text.length && /\s/.test(text[at] ?? "")) at += 1
96
+ }
97
+ const fail = (): never => {
98
+ throw new SyntaxError("not a literal")
99
+ }
100
+ const string = (): string => {
101
+ const quote = text[at]
102
+ if (quote !== '"' && quote !== "'" && quote !== "`") return fail()
103
+ let value = ""
104
+ at += 1
105
+ while (at < text.length && text[at] !== quote) {
106
+ const char = text[at] ?? ""
107
+ if (quote === "`" && char === "$" && text[at + 1] === "{") return fail()
108
+ if (char === "\\") {
109
+ value += text[at + 1] ?? ""
110
+ at += 2
111
+ } else {
112
+ value += char
113
+ at += 1
114
+ }
115
+ }
116
+ at += 1
117
+ return value
118
+ }
119
+ const literal = (): Literal => {
120
+ space()
121
+ const char = text[at]
122
+ if (char === "[") {
123
+ at += 1
124
+ const items: string[] = []
125
+ for (;;) {
126
+ space()
127
+ if (text[at] === "]") break
128
+ items.push(string())
129
+ space()
130
+ if (text[at] === ",") at += 1
131
+ else if (text[at] !== "]") return fail()
132
+ }
133
+ at += 1
134
+ return items
135
+ }
136
+ if (char === "{") {
137
+ at += 1
138
+ const entries: Record<string, string | boolean> = {}
139
+ for (;;) {
140
+ space()
141
+ if (text[at] === "}") break
142
+ const key = /^[A-Za-z_$][\w$]*/.exec(text.slice(at))?.[0]
143
+ const name = key ?? string()
144
+ if (key !== undefined) at += key.length
145
+ space()
146
+ if (text[at] !== ":") return fail()
147
+ at += 1
148
+ const value = literal()
149
+ if (typeof value !== "string" && typeof value !== "boolean") return fail()
150
+ entries[name] = value
151
+ space()
152
+ if (text[at] === ",") at += 1
153
+ else if (text[at] !== "}") return fail()
154
+ }
155
+ at += 1
156
+ return entries
157
+ }
158
+ if (text.startsWith("true", at) || text.startsWith("false", at)) {
159
+ const value = text.startsWith("true", at)
160
+ at += value ? 4 : 5
161
+ return value
162
+ }
163
+ return string()
164
+ }
165
+ return {
166
+ literal,
167
+ space,
168
+ get at() {
169
+ return at
170
+ },
171
+ expect(char: string): void {
172
+ space()
173
+ if (text[at] !== char) fail()
174
+ at += 1
175
+ },
176
+ peek(): string | undefined {
177
+ space()
178
+ return text[at]
179
+ },
180
+ }
181
+ }
182
+
183
+ const OPTION_NAMES = new Set(["eager", "import"])
184
+
185
+ // `Array.isArray` does not narrow a readonly array out of a union.
186
+ const isList = (value: Literal): value is readonly string[] => Array.isArray(value)
187
+
188
+ function readOptions(value: Literal | undefined): GlobOptions {
189
+ if (value === undefined) return { eager: false, pick: undefined }
190
+ if (typeof value !== "object" || isList(value)) throw new SyntaxError("options")
191
+ const options: Readonly<Record<string, string | boolean>> = value
192
+ for (const name of Object.keys(options))
193
+ if (!OPTION_NAMES.has(name)) throw new SyntaxError(`option ${name}`)
194
+ const eager = options.eager ?? false
195
+ const pick = options.import
196
+ if (typeof eager !== "boolean" || (pick !== undefined && typeof pick !== "string"))
197
+ throw new SyntaxError("option types")
198
+ return { eager, pick }
199
+ }
200
+
201
+ /** The files a set of patterns matches from `importer`, by Vite's rules, sorted; `!` excludes. */
202
+ function matchGlob(importer: string, root: string, patterns: readonly string[]): string[] {
203
+ // Globs read `\` as an escape, so on Windows every path is matched with forward slashes.
204
+ const slash = (path: string): string => (sep === "\\" ? path.replaceAll("\\", "/") : path)
205
+ const absolute = (pattern: string): string => {
206
+ if (pattern.startsWith("/")) return slash(resolve(root, `.${pattern}`))
207
+ if (pattern.startsWith("./") || pattern.startsWith("../"))
208
+ return slash(resolve(dirname(importer), pattern))
209
+ throw new SyntaxError(`pattern ${pattern}`)
210
+ }
211
+ const include = patterns.filter((pattern) => !pattern.startsWith("!")).map(absolute)
212
+ const exclude = patterns
213
+ .filter((pattern) => pattern.startsWith("!"))
214
+ .map((pattern) => new Bun.Glob(absolute(pattern.slice(1))))
215
+ const found = new Set<string>()
216
+ for (const pattern of include) {
217
+ const segments = pattern.split("/")
218
+ const wild = segments.findIndex((segment) => /[*?[\]{}]/.test(segment))
219
+ if (wild === -1) {
220
+ if (existsSync(pattern)) found.add(pattern)
221
+ continue
222
+ }
223
+ const base = segments.slice(0, wild).join("/") || "/"
224
+ const rest = segments.slice(wild).join("/")
225
+ for (const file of new Bun.Glob(rest).scanSync({ cwd: base, onlyFiles: true })) {
226
+ const path = slash(resolve(base, file))
227
+ if (!rest.includes("node_modules") && path.split("/").includes("node_modules")) continue
228
+ found.add(path)
229
+ }
230
+ }
231
+ const self = slash(importer)
232
+ return [...found]
233
+ .filter((path) => path !== self && !exclude.some((glob) => glob.match(path)))
234
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
235
+ }
236
+
237
+ /** `value` as a JS string literal that stays on one line for every parser: before ES2019, U+2028 and
238
+ * U+2029 end a line even inside a string. */
239
+ const stringLiteral = (value: string): string =>
240
+ JSON.stringify(value).replace(
241
+ /[\u2028\u2029]/g,
242
+ (char) => `\\u${char.charCodeAt(0).toString(16)}`,
243
+ )
244
+
245
+ /** How a matched file is keyed, as Vite keys it: from the importer (`./x.ts`) or the root (`/x.ts`). */
246
+ function keyOf(importer: string, root: string, file: string, rooted: boolean): string {
247
+ if (rooted) return `/${relative(root, file).replaceAll("\\", "/")}`
248
+ const path = relative(dirname(importer), file).replaceAll("\\", "/")
249
+ return path.startsWith(".") ? path : `./${path}`
250
+ }
251
+
252
+ /**
253
+ * How often `import.meta.glob` appears in the module's code as Bun reads it: types stripped and JSX
254
+ * text turned into strings, neither of which {@link codeOnly} can see in the source.
255
+ */
256
+ function globsInCode(source: string, file: string): number {
257
+ const js = new Bun.Transpiler({ loader: LOADERS[extname(file)] ?? "js" }).transformSync(source)
258
+ return codeOnly(js).match(CALL)?.length ?? 0
259
+ }
260
+
261
+ const unclear = (file: string): Error =>
262
+ new Error(
263
+ `[nifra/web] ${file}: the Bun build cannot tell this module's import.meta.glob calls from text that names it (JSX text, or a quote inside a regular expression). Move the calls into a .ts module, or build with --vite.`,
264
+ )
265
+
266
+ /**
267
+ * `source` (the module at `file`) with each `import.meta.glob(...)` call expanded into an object of
268
+ * the files it matches. Throws, naming the line, on a call this cannot read.
269
+ */
270
+ export function expandImportGlobs(source: string, file: string, root: string): string {
271
+ const real = globsInCode(source, file)
272
+ // A docs page that shows the call in JSX text has no call to expand, and rewriting its text is wrong.
273
+ if (real === 0) return source
274
+ // JSX text is not a string to `codeOnly`: an apostrophe there flips what it reads as code.
275
+ const code = codeOnly(source)
276
+ if ((code.match(CALL)?.length ?? 0) !== real) throw unclear(file)
277
+ const edits: Array<{ start: number; end: number; text: string }> = []
278
+ const hoisted: string[] = []
279
+ let call = 0
280
+ for (const match of code.matchAll(CALL)) {
281
+ let start = match.index
282
+ const line = source.slice(0, start).split("\n").length
283
+ // A bundle that expands the call has the function: a `typeof` guard around it reads so.
284
+ const guard = /\btypeof\s+$/.exec(code.slice(Math.max(0, start - 32), start))
285
+ if (guard !== null) {
286
+ start -= guard[0].length
287
+ edits.push({ start, end: match.index + match[0].length, text: '"function"' })
288
+ continue
289
+ }
290
+ const refuse = (why: string): never => {
291
+ throw new Error(
292
+ `[nifra/web] ${file}:${line}: import.meta.glob ${why}. The Bun build reads a literal pattern (a string or an array of strings starting with ./, ../ or /, "!" excluding) and the options eager and import; write it that way, or build with --vite.`,
293
+ )
294
+ }
295
+ let after = start + match[0].length
296
+ if (!/^\s*[(<]/.test(code.slice(after))) refuse("is read as a value, not called")
297
+ // `import.meta.glob<T>(...)`: TypeScript's type argument, dropped with the call.
298
+ if (code[after] === "<") {
299
+ const close = code.indexOf(">(", after)
300
+ if (close === -1) refuse("has a type argument this cannot read")
301
+ after = close + 1
302
+ }
303
+ const reader = argumentReader(source, after)
304
+ let patterns: readonly string[]
305
+ let options: GlobOptions
306
+ try {
307
+ reader.expect("(")
308
+ const first = reader.literal()
309
+ patterns = typeof first === "string" ? [first] : isList(first) ? first : []
310
+ if (patterns.length === 0) throw new SyntaxError("patterns")
311
+ let second: Literal | undefined
312
+ if (reader.peek() === ",") {
313
+ reader.expect(",")
314
+ if (reader.peek() !== ")") second = reader.literal()
315
+ }
316
+ options = readOptions(second)
317
+ if (reader.peek() === ",") reader.expect(",")
318
+ reader.expect(")")
319
+ } catch (error) {
320
+ return refuse(
321
+ error instanceof SyntaxError && error.message.startsWith("option ")
322
+ ? `takes the ${error.message.slice(7)} option, which the Bun build does not support`
323
+ : "takes arguments that are not literals",
324
+ )
325
+ }
326
+ let files: string[]
327
+ try {
328
+ files = matchGlob(file, root, patterns)
329
+ } catch {
330
+ return refuse("takes a pattern that does not start with ./, ../ or /")
331
+ }
332
+ const rooted = patterns.some((pattern) => pattern.replace(/^!/, "").startsWith("/"))
333
+ const entries = files.map((target, index) => {
334
+ const key = stringLiteral(keyOf(file, root, target, rooted))
335
+ const specifier = stringLiteral(keyOf(file, root, target, false))
336
+ if (options.eager) {
337
+ const name = `__nifra_glob_${call}_${index}`
338
+ hoisted.push(`import * as ${name} from ${specifier}`)
339
+ return `${key}: ${options.pick === undefined ? name : `${name}[${stringLiteral(options.pick)}]`}`
340
+ }
341
+ const load = `import(${specifier})`
342
+ return `${key}: () => ${options.pick === undefined ? load : `${load}.then((m) => m[${stringLiteral(options.pick)}])`}`
343
+ })
344
+ edits.push({ start, end: reader.at, text: `({${entries.join(", ")}})` })
345
+ call += 1
346
+ }
347
+ let next = source
348
+ for (const edit of edits.reverse())
349
+ next = next.slice(0, edit.start) + edit.text + next.slice(edit.end)
350
+ // Equal counts can still pair a text mention with a missed call; one left in the code is that.
351
+ if (globsInCode(next, file) !== 0) throw unclear(file)
352
+ // Import declarations are hoisted wherever they sit, so appending keeps any directive prologue first.
353
+ return hoisted.length === 0 ? next : `${next}\n${hoisted.join("\n")}\n`
354
+ }
355
+
356
+ /**
357
+ * Expand `import.meta.glob` in the app's own modules, for `Bun.build`. `root` is the app root, which a
358
+ * pattern starting with `/` is read from. Installed packages are left as published. Register it after
359
+ * every plugin that loads app modules itself (a server-function stub must win over it); a module such
360
+ * a plugin loads keeps its call, which {@link assertNoImportGlob} then refuses in the output. Register
361
+ * it before a plugin that rewrites source through `addSourceTransform`: it reads through that rewrite.
362
+ */
363
+ export function importGlobPlugin(options: { readonly root: string }): BunPlugin {
364
+ return {
365
+ name: "nifra-import-glob",
366
+ setup(build) {
367
+ build.onLoad({ filter: SOURCE }, async (args) => {
368
+ if (args.namespace !== "file" || args.path.split("/").includes("node_modules"))
369
+ return undefined
370
+ if (!(await Bun.file(args.path).text()).includes("import.meta.glob")) return undefined
371
+ // Claiming the module would skip a later plugin's rewrite of it (the copy split), so read
372
+ // through it.
373
+ const source = await readSource(build, args.path)
374
+ const contents = expandImportGlobs(source, args.path, options.root)
375
+ if (contents === source) return undefined
376
+ return { contents, loader: LOADERS[extname(args.path)] ?? "js" }
377
+ })
378
+ },
379
+ }
380
+ }
381
+
382
+ /** Refuse a bundle that still calls `import.meta.glob`: the browser has no such function, so every
383
+ * file the call named would be missing at run time. `outputs` are the emitted chunks, path to text. */
384
+ export function assertNoImportGlob(outputs: ReadonlyArray<readonly [string, string]>): void {
385
+ const left = outputs
386
+ .filter(([, text]) => /\bimport\.meta\.glob\s*\(/.test(text))
387
+ .map(([path]) => path)
388
+ if (left.length === 0) return
389
+ throw new Error(
390
+ `[nifra/web] ${left.join(", ")} still call${left.length === 1 ? "s" : ""} import.meta.glob, which no browser or server runtime defines. A plugin that loads a module itself runs before the glob expansion and keeps its call; expand it in that plugin, or build with --vite.`,
391
+ )
392
+ }
@@ -122,6 +122,42 @@ export function hash8(input: string): string {
122
122
  return h.toString(16).padStart(8, "0")
123
123
  }
124
124
 
125
+ const SOURCE_TRANSFORMS = Symbol.for("nifra.build.source-transforms")
126
+
127
+ /** A rewrite of a source file's text before any plugin compiles it; `undefined` leaves the text as is. */
128
+ export type SourceTransform = (path: string, source: string) => string | undefined
129
+
130
+ const isTransformList = (value: unknown): value is SourceTransform[] => Array.isArray(value)
131
+
132
+ /**
133
+ * Register a source rewrite for one build. Bun runs only the first `onLoad` that returns contents, so
134
+ * a plugin that claims a file and reads it off disk would compile it without the rewrite: such a plugin
135
+ * reads through {@link readSource} instead. Stored on the build's config, which every plugin of one
136
+ * `Bun.build` shares.
137
+ */
138
+ export function addSourceTransform(build: PluginBuilder, transform: SourceTransform): void {
139
+ const config: unknown = build.config
140
+ if (typeof config !== "object" || config === null) return
141
+ const existing: unknown = Reflect.get(config, SOURCE_TRANSFORMS)
142
+ if (isTransformList(existing)) existing.push(transform)
143
+ else Reflect.set(config, SOURCE_TRANSFORMS, [transform])
144
+ }
145
+
146
+ /** A source file's text as this build compiles it: off disk, through each {@link addSourceTransform}. */
147
+ export async function readSource(build: PluginBuilder, path: string): Promise<string> {
148
+ let source = await Bun.file(path).text()
149
+ // A runtime plugin (`Bun.plugin`) has no build config.
150
+ const config: unknown = build.config
151
+ const transforms: unknown =
152
+ typeof config === "object" && config !== null
153
+ ? Reflect.get(config, SOURCE_TRANSFORMS)
154
+ : undefined
155
+ if (isTransformList(transforms)) {
156
+ for (const transform of transforms) source = transform(path, source) ?? source
157
+ }
158
+ return source
159
+ }
160
+
125
161
  /**
126
162
  * Env flag a nifra dev server sets for its own lifetime, so a framework compiler plugin can tell a dev
127
163
  * compile from a `nifra build` one. Exported for the dev server that sets it; plugins ask
@@ -11,6 +11,7 @@ import {
11
11
  devServerCompile,
12
12
  hash8,
13
13
  normalizeFilePath,
14
+ readSource,
14
15
  reproduciblePath,
15
16
  requirePeer,
16
17
  } from "./kit.ts"
@@ -236,7 +237,7 @@ export function stylexBunPlugin(
236
237
  const dependencies = dependenciesFor(options)
237
238
  build.onLoad({ filter: JS_FILTER }, async (args) => {
238
239
  const path = normalizeFilePath(args.path)
239
- const source = await Bun.file(path).text()
240
+ const source = await readSource(build, path)
240
241
  const transformed = await transformModule(source, path, options, dependencies())
241
242
  if (transformed === undefined) return
242
243
  const contents =
@@ -33,6 +33,61 @@ export interface RenderProps {
33
33
  readonly boundaries?: BoundaryStates
34
34
  /** The rendered chain's module ids and `handle` exports, for the adapter's `useMatches`. */
35
35
  readonly matchChain?: MatchChain
36
+ /** The page's locale, when the app registers its locale routing (`createWebApp`'s `i18n`). Set on
37
+ * SSR only; the client names it from `path` (see {@link localeOfProps}). */
38
+ readonly locale?: PageLocale
39
+ }
40
+
41
+ /** A served locale as a page renders in it: from the app's locale registry. */
42
+ export interface PageLocale {
43
+ /** The registry key, which is also the URL segment (`ur`). */
44
+ readonly key: string
45
+ /** The BCP-47 tag `<html lang>` and `Content-Language` carry (`ur-PK`). */
46
+ readonly lang: string
47
+ readonly dir: "ltr" | "rtl"
48
+ }
49
+
50
+ /** The served locales, handed to the client so it names a page's locale from its path the way the
51
+ * server does. */
52
+ export interface PageLocales {
53
+ /** The default locale's key. */
54
+ readonly default: string
55
+ /** Every served locale, in registry order. */
56
+ readonly locales: readonly PageLocale[]
57
+ }
58
+
59
+ /** The handover global carrying {@link PageLocales}. */
60
+ export const LOCALES_GLOBAL = "__NIFRA_LOCALES__"
61
+
62
+ /**
63
+ * The locale a path is in, by the rule `@nifrajs/i18n/routing`'s `localeOf` follows: a first path
64
+ * segment that names a served locale, ignoring ASCII case, or else the default.
65
+ */
66
+ export function pageLocaleIn(config: PageLocales, path: string): PageLocale | undefined {
67
+ // One linear pass: separators, then the segment up to the next separator, query or hash.
68
+ const first = /^[/\\]*([^/\\?#]*)/.exec(path.replace(/[\t\n\r]/g, ""))?.[1]?.toLowerCase()
69
+ return (
70
+ config.locales.find((locale) => first !== "" && locale.key.toLowerCase() === first) ??
71
+ config.locales.find((locale) => locale.key === config.default)
72
+ )
73
+ }
74
+
75
+ const isPageLocales = (value: unknown): value is PageLocales =>
76
+ typeof value === "object" && value !== null && "locales" in value && Array.isArray(value.locales)
77
+
78
+ /** The locale of the page at `path`, from the served locales the server handed over; `undefined`
79
+ * outside a browser page, or when the app registers no locale routing. */
80
+ export function handoverLocale(path: string): PageLocale | undefined {
81
+ const config: unknown = Reflect.get(globalThis, LOCALES_GLOBAL)
82
+ return isPageLocales(config) ? pageLocaleIn(config, path) : undefined
83
+ }
84
+
85
+ /** The locale of the page `props` render: the server's answer on SSR, else the one the client names
86
+ * from `props.path`. What every adapter's `useLocale` reads. */
87
+ export function localeOfProps(props: RenderProps | undefined): PageLocale | undefined {
88
+ if (props === undefined) return undefined
89
+ if (props.locale !== undefined) return props.locale
90
+ return props.path === undefined ? undefined : handoverLocale(props.path)
36
91
  }
37
92
 
38
93
  /**
@@ -151,5 +206,10 @@ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
151
206
  /** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
152
207
  export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
153
208
  export const COMPONENT_GLOBAL = "__NIFRA_COMPONENTS__"
209
+ /** Locale key -> copy chunk URL, in a production build that split copy tables by locale. The name
210
+ * `@nifrajs/i18n/copy` reads. */
211
+ export const COPY_CHUNKS_GLOBAL = "__NIFRA_COPY__"
212
+ /** The registry `@nifrajs/i18n/copy` keeps its loaded chunks and definitions in. */
213
+ export const COPY_REALM = "nifra.copy"
154
214
  /** Marker attribute used to find a non-default hydration container. */
155
215
  export const ROOT_ATTRIBUTE = "data-nifra-root"
package/src/router.ts CHANGED
@@ -310,8 +310,9 @@ export interface ClientRouterOptions {
310
310
  /** Override the loader-data fetch (tests inject a stub; defaults to a same-origin JSON GET). */
311
311
  readonly fetchData?: FetchRouteData
312
312
  /** Ensure a route's code chunk is loaded before rendering (code-splitting). Awaited in parallel
313
- * with the loader data, so `pending` covers both. Omit when the bundle isn't split. */
314
- readonly loadModule?: (routeId: string) => Promise<void>
313
+ * with the loader data, so `pending` covers both. Omit when the bundle isn't split. `path` is the URL
314
+ * the route is about to render, for code loaded per URL (a production build's copy chunks). */
315
+ readonly loadModule?: (routeId: string, path?: string) => Promise<void>
315
316
  /** Terminal status → client route id. Generated entries populate this from `_404` and
316
317
  * `_<status>` files so soft navigation renders the same boundary as a hard request. */
317
318
  readonly statusRoutes?: Readonly<Record<number, string>>
@@ -667,7 +668,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
667
668
  )
668
669
  if (mine !== generation) return
669
670
  if (loaded.terminalRouteId !== undefined) {
670
- await loadModule?.(loaded.terminalRouteId)
671
+ await loadModule?.(loaded.terminalRouteId, state.path)
671
672
  state = { ...state, routeId: loaded.terminalRouteId, params: {}, data: null }
672
673
  } else {
673
674
  state = {
@@ -788,7 +789,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
788
789
  state = { ...state, pendingPath: at }
789
790
  emit()
790
791
  }
791
- await loadModule?.(route.routeId)
792
+ await loadModule?.(route.routeId, at)
792
793
  // Prefetched server data is one-shot; a clientLoader still runs over it without another request.
793
794
  const prefetchedData = usePrefetch ? freshPrefetch(at) : undefined
794
795
  if (usePrefetch) prefetched.delete(at)
@@ -801,7 +802,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
801
802
  prefetchedData,
802
803
  )
803
804
  if (loaded.terminalRouteId !== undefined) {
804
- await loadModule?.(loaded.terminalRouteId)
805
+ await loadModule?.(loaded.terminalRouteId, at)
805
806
  if (options.layoutStatusRoutes?.[loaded.terminalRouteId] === true) {
806
807
  throw new Error(
807
808
  `[nifra/web] ${loaded.terminalRouteId} renders inside layouts; loading ${at} as a document`,
@@ -1215,7 +1216,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
1215
1216
  // Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
1216
1217
  // otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
1217
1218
  const [, loaded] = await Promise.all([
1218
- loadModule?.(matched.routeId),
1219
+ loadModule?.(matched.routeId, path),
1219
1220
  loadRouteData(path, matched),
1220
1221
  ])
1221
1222
  prefetched.delete(path) // a stale entry re-enters as the newest
@@ -1,18 +1,10 @@
1
1
  import { createRequire } from "node:module"
2
2
  import type * as TS from "typescript"
3
+ import { isTypeScriptCompiler } from "../internal/typescript-api.ts"
3
4
 
4
5
  /** The code of the error a TypeScript without the in-process compiler API raises here. */
5
6
  export const UNSUPPORTED_TYPESCRIPT = "NIFRA_TS_UNSUPPORTED"
6
7
 
7
- function isCompiler(value: unknown): value is typeof TS {
8
- return (
9
- typeof value === "object" &&
10
- value !== null &&
11
- "createSourceFile" in value &&
12
- typeof value.createSourceFile === "function"
13
- )
14
- }
15
-
16
8
  /**
17
9
  * The TypeScript compiler API, resolved from `from` the way `import "typescript"` there would be.
18
10
  * TypeScript 7's package ships no in-process compiler API (its root export is only `version`), so
@@ -20,7 +12,7 @@ function isCompiler(value: unknown): value is typeof TS {
20
12
  */
21
13
  export function loadTypeScript(from: string = import.meta.url): typeof TS {
22
14
  const loaded: unknown = createRequire(from)("typescript")
23
- if (isCompiler(loaded)) return loaded
15
+ if (isTypeScriptCompiler(loaded)) return loaded
24
16
  const version =
25
17
  typeof loaded === "object" &&
26
18
  loaded !== null &&