@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.
- package/CHANGELOG.md +2970 -0
- package/dist/build-plan.d.ts +7 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +19 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +51 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +12 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +43 -6
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +6 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +23 -5
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/copy-split.d.ts +27 -0
- package/dist/internal/copy-split.d.ts.map +1 -0
- package/dist/internal/copy-split.js +173 -0
- package/dist/internal/copy-split.js.map +1 -0
- package/dist/internal/page-execution.d.ts +19 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +63 -8
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/render-document.d.ts +11 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +38 -6
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/typescript-api.d.ts +4 -0
- package/dist/internal/typescript-api.d.ts.map +1 -0
- package/dist/internal/typescript-api.js +8 -0
- package/dist/internal/typescript-api.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +28 -0
- package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
- package/dist/internal/vite-plugin-compat.js +82 -0
- package/dist/internal/vite-plugin-compat.js.map +1 -0
- package/dist/internal/web-app.d.ts +34 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +22 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/plugins/import-glob.d.ts +20 -0
- package/dist/plugins/import-glob.d.ts.map +1 -0
- package/dist/plugins/import-glob.js +401 -0
- package/dist/plugins/import-glob.js.map +1 -0
- package/dist/plugins/kit.d.ts +11 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +32 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/stylex.d.ts.map +1 -1
- package/dist/plugins/stylex.js +2 -2
- package/dist/plugins/stylex.js.map +1 -1
- package/dist/render-seam.d.ts +37 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +33 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.js.map +1 -1
- package/dist/server-components/typescript.d.ts.map +1 -1
- package/dist/server-components/typescript.js +2 -7
- package/dist/server-components/typescript.js.map +1 -1
- package/dist/vite.d.ts +1 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -69
- package/dist/vite.js.map +1 -1
- package/package.json +5 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +57 -4
- package/src/client.ts +15 -7
- package/src/index.ts +6 -0
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/page-execution.ts +83 -7
- package/src/internal/render-document.ts +63 -6
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +61 -1
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/render-seam.ts +60 -0
- package/src/router.ts +7 -6
- package/src/server-components/typescript.ts +2 -10
- 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
|
+
}
|
package/src/plugins/kit.ts
CHANGED
|
@@ -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
|
package/src/plugins/stylex.ts
CHANGED
|
@@ -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
|
|
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 =
|
package/src/render-seam.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
15
|
+
if (isTypeScriptCompiler(loaded)) return loaded
|
|
24
16
|
const version =
|
|
25
17
|
typeof loaded === "object" &&
|
|
26
18
|
loaded !== null &&
|