@nifrajs/web 5.0.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/README.md +8 -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 +7 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -22
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +3 -2
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +2 -6
- package/dist/dev-port.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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/package-entries.d.ts +34 -0
- package/dist/internal/package-entries.d.ts.map +1 -0
- package/dist/internal/package-entries.js +324 -0
- package/dist/internal/package-entries.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 +123 -14
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +15 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +8 -0
- package/dist/internal/private-env.d.ts.map +1 -1
- package/dist/internal/private-env.js +25 -10
- package/dist/internal/private-env.js.map +1 -1
- package/dist/internal/render-document.d.ts +15 -46
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +59 -62
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/route-path.d.ts +6 -0
- package/dist/internal/route-path.d.ts.map +1 -1
- package/dist/internal/route-path.js +11 -0
- package/dist/internal/route-path.js.map +1 -1
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +103 -1
- package/dist/internal/secret-scan.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 +38 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +30 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/without-query.d.ts +4 -0
- package/dist/internal/without-query.d.ts.map +1 -0
- package/dist/internal/without-query.js +9 -0
- package/dist/internal/without-query.js.map +1 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +17 -6
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +15 -2
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +9 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -0
- package/dist/manifest.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/plugins/vite-leak-guard.d.ts +6 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +80 -7
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +17 -21
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/plugins/zone-guard.d.ts.map +1 -1
- package/dist/plugins/zone-guard.js +44 -3
- package/dist/plugins/zone-guard.js.map +1 -1
- package/dist/prerender.d.ts +5 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +41 -19
- package/dist/prerender.js.map +1 -1
- package/dist/render-seam.d.ts +60 -2
- 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 +13 -9
- package/dist/router.js.map +1 -1
- package/dist/seo.d.ts +49 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +48 -0
- package/dist/seo.js.map +1 -0
- 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/dist/zones.d.ts +30 -8
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +185 -46
- package/dist/zones.js.map +1 -1
- package/package.json +10 -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 +42 -22
- package/src/dev-port.ts +4 -10
- package/src/index.ts +8 -4
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +150 -13
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/render-document.ts +100 -81
- package/src/internal/route-path.ts +16 -0
- package/src/internal/secret-scan.ts +101 -1
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +71 -6
- package/src/internal/without-query.ts +8 -0
- package/src/internal/zone-graph.ts +19 -6
- package/src/isr.ts +13 -2
- package/src/manifest.ts +10 -0
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/plugins/vite-leak-guard.ts +99 -7
- package/src/plugins/vite-zone-guard.ts +21 -19
- package/src/plugins/zone-guard.ts +41 -2
- package/src/prerender.ts +39 -18
- package/src/render-seam.ts +88 -2
- package/src/router.ts +16 -11
- package/src/seo.ts +68 -0
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +3 -81
- package/src/zones.ts +237 -52
package/src/internal/codegen.ts
CHANGED
|
@@ -5,9 +5,12 @@ import {
|
|
|
5
5
|
ACTION_GLOBAL,
|
|
6
6
|
BOUNDARY_GLOBAL,
|
|
7
7
|
COMPONENT_GLOBAL,
|
|
8
|
+
COPY_CHUNKS_GLOBAL,
|
|
9
|
+
COPY_REALM,
|
|
8
10
|
DATA_GLOBAL,
|
|
9
11
|
HANDOVER_ID,
|
|
10
12
|
LAYOUT_DATA_GLOBAL,
|
|
13
|
+
LOCALES_GLOBAL,
|
|
11
14
|
ROOT_ATTRIBUTE,
|
|
12
15
|
ROUTE_GLOBAL,
|
|
13
16
|
} from "../render-seam.ts"
|
|
@@ -23,6 +26,7 @@ const HANDOVER_GLOBALS = [
|
|
|
23
26
|
BOUNDARY_GLOBAL,
|
|
24
27
|
COMPONENT_GLOBAL,
|
|
25
28
|
PRERENDERED_GLOBAL,
|
|
29
|
+
LOCALES_GLOBAL,
|
|
26
30
|
]
|
|
27
31
|
export interface GenerateClientEntryOptions {
|
|
28
32
|
/**
|
|
@@ -39,6 +43,9 @@ export interface GenerateClientEntryOptions {
|
|
|
39
43
|
/** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
|
|
40
44
|
readonly resolve: (file: string) => string
|
|
41
45
|
readonly serverComponents?: boolean
|
|
46
|
+
/** The build moved copy tables into per-locale chunks: the handover carries the chunk URLs, and a
|
|
47
|
+
* navigation loads the copy of the URL it goes to beside the route's code. */
|
|
48
|
+
readonly copyChunks?: boolean
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -222,7 +229,7 @@ export function generateClientEntry(
|
|
|
222
229
|
// routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
|
|
223
230
|
`const layoutIdsOf = ${jsValueLiteral(layoutIdsOf)}`,
|
|
224
231
|
"const matchChains = {}",
|
|
225
|
-
|
|
232
|
+
`const ${options.copyChunks === true ? "loadRoute" : "loadModule"} = async (id) => {`,
|
|
226
233
|
" if (chains[id]) return",
|
|
227
234
|
" const mods = await loaders[id]()",
|
|
228
235
|
// For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
|
|
@@ -260,6 +267,17 @@ export function generateClientEntry(
|
|
|
260
267
|
" routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
|
|
261
268
|
" if (page.ssr === false) holds[id] = [...chains[id].slice(0, -1), page.HydrateFallback ?? (() => null)]",
|
|
262
269
|
"}",
|
|
270
|
+
...(options.copyChunks === true
|
|
271
|
+
? [
|
|
272
|
+
`const copyRealm = () => globalThis[Symbol.for(${jsStringLiteral(COPY_REALM)})]`,
|
|
273
|
+
// A second prepare covers a copy definition the route's own code brought in.
|
|
274
|
+
"const loadModule = async (id, path) => {",
|
|
275
|
+
" if (path === undefined) return loadRoute(id)",
|
|
276
|
+
" await Promise.all([loadRoute(id), copyRealm()?.prepare(path)])",
|
|
277
|
+
" await copyRealm()?.prepare(path)",
|
|
278
|
+
"}",
|
|
279
|
+
]
|
|
280
|
+
: []),
|
|
263
281
|
// The server hands page state over as one inert JSON script; lift it onto the globals the
|
|
264
282
|
// router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
|
|
265
283
|
// matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
|
|
@@ -268,7 +286,7 @@ export function generateClientEntry(
|
|
|
268
286
|
"const handover = handovers[handovers.length - 1]",
|
|
269
287
|
"if (handover !== undefined) {",
|
|
270
288
|
' const state = JSON.parse(handover.textContent || "{}")',
|
|
271
|
-
` for (const key of ${jsValueLiteral(HANDOVER_GLOBALS)}) {`,
|
|
289
|
+
` for (const key of ${jsValueLiteral(options.copyChunks === true ? [...HANDOVER_GLOBALS, COPY_CHUNKS_GLOBAL] : HANDOVER_GLOBALS)}) {`,
|
|
272
290
|
" if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
|
|
273
291
|
" }",
|
|
274
292
|
"}",
|
|
@@ -383,7 +401,7 @@ export function generateClientEntry(
|
|
|
383
401
|
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
|
|
384
402
|
" const nonce = currentDocumentNonce()",
|
|
385
403
|
" const args = { data: s.data, pageData: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
|
|
386
|
-
" applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)))",
|
|
404
|
+
" applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)), s.path)",
|
|
387
405
|
" }",
|
|
388
406
|
" })",
|
|
389
407
|
"}).catch((error) => console.error('[nifra/web] hydration failed:', error))",
|
|
@@ -453,6 +471,9 @@ export interface GenerateServerManifestOptions {
|
|
|
453
471
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
454
472
|
/** How framework-owned stylesheet links should activate before the generated client mounts. */
|
|
455
473
|
readonly cssLoading?: CssLoadingMode
|
|
474
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
|
|
475
|
+
* `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
|
|
476
|
+
readonly copyChunks?: Readonly<Record<string, string>> | undefined
|
|
456
477
|
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
457
478
|
* eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
|
|
458
479
|
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
@@ -500,6 +521,7 @@ export function generateServerManifest(
|
|
|
500
521
|
styles,
|
|
501
522
|
routeStyles,
|
|
502
523
|
cssLoading: requestedCssLoading,
|
|
524
|
+
copyChunks,
|
|
503
525
|
lazy = false,
|
|
504
526
|
} = options
|
|
505
527
|
// A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
|
|
@@ -541,6 +563,11 @@ export function generateServerManifest(
|
|
|
541
563
|
const stylesLine = `export const styles = ${jsValueLiteral(styles ?? [])}`
|
|
542
564
|
const routeStylesLine = `export const routeStyles = ${jsValueLiteral(routeStyles ?? {})}`
|
|
543
565
|
const cssLoadingLine = `export const cssLoading = ${jsStringLiteral(cssLoading)}`
|
|
566
|
+
// Only a build that split copy tables has the line; its server entry is the one that imports it.
|
|
567
|
+
const copyChunksLines =
|
|
568
|
+
copyChunks === undefined || Object.keys(copyChunks).length === 0
|
|
569
|
+
? []
|
|
570
|
+
: [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
|
|
544
571
|
if (lazy) {
|
|
545
572
|
// Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
|
|
546
573
|
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
@@ -558,6 +585,7 @@ export function generateServerManifest(
|
|
|
558
585
|
stylesLine,
|
|
559
586
|
routeStylesLine,
|
|
560
587
|
cssLoadingLine,
|
|
588
|
+
...copyChunksLines,
|
|
561
589
|
`export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
|
|
562
590
|
].join("\n")}\n`
|
|
563
591
|
}
|
|
@@ -577,6 +605,7 @@ export function generateServerManifest(
|
|
|
577
605
|
stylesLine,
|
|
578
606
|
routeStylesLine,
|
|
579
607
|
cssLoadingLine,
|
|
608
|
+
...copyChunksLines,
|
|
580
609
|
`export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
|
|
581
610
|
].join("\n")}\n`
|
|
582
611
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy tables (`@nifrajs/i18n/copy`) in a production client build. Each table a module declares is
|
|
3
|
+
* replaced by its chunk id, and its values move into one chunk per locale, so a page's JavaScript carries
|
|
4
|
+
* the copy of the locale it renders in and none of the others. The server bundle keeps every table whole.
|
|
5
|
+
*
|
|
6
|
+
* The tables are found by `@nifrajs/i18n/extract` with the app's own TypeScript, both resolved from the
|
|
7
|
+
* app root: `@nifrajs/web` depends on neither. An app with no `defineCopy` pays one source scan.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import { type Dirent, readdirSync, readFileSync } from "node:fs"
|
|
11
|
+
import { createRequire } from "node:module"
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path"
|
|
13
|
+
import { pathToFileURL } from "node:url"
|
|
14
|
+
import type { BunPlugin } from "bun"
|
|
15
|
+
import type * as TS from "typescript"
|
|
16
|
+
import { addSourceTransform, normalizeFilePath } from "../plugins/kit.ts"
|
|
17
|
+
import { isTypeScriptCompiler } from "./typescript-api.ts"
|
|
18
|
+
|
|
19
|
+
const COPY_MODULE = "@nifrajs/i18n/copy"
|
|
20
|
+
const EXTRACT_MODULE = "@nifrajs/i18n/extract"
|
|
21
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/
|
|
22
|
+
const REWRITABLE = /\.m?[jt]sx?(\?|$)/
|
|
23
|
+
const DEPENDENCY = /[\\/]node_modules[\\/]/
|
|
24
|
+
const MAX_DEPTH = 32
|
|
25
|
+
|
|
26
|
+
/** The part of `@nifrajs/i18n/extract` the build reads. */
|
|
27
|
+
interface TableSite {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly values?: Readonly<Record<string, unknown>>
|
|
30
|
+
readonly definition: { readonly file: string; readonly name: string }
|
|
31
|
+
}
|
|
32
|
+
interface Scanner {
|
|
33
|
+
tables(file: string): readonly TableSite[]
|
|
34
|
+
definition(ref: TableSite["definition"]): { readonly locales?: unknown } | undefined
|
|
35
|
+
rewrite(file: string): string | undefined
|
|
36
|
+
}
|
|
37
|
+
interface SplitTable {
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly values: Readonly<Record<string, unknown>>
|
|
40
|
+
readonly locales: unknown
|
|
41
|
+
}
|
|
42
|
+
interface Extract {
|
|
43
|
+
createCopyScanner(
|
|
44
|
+
ts: typeof TS,
|
|
45
|
+
host: {
|
|
46
|
+
readonly root: string
|
|
47
|
+
read(file: string): string | undefined
|
|
48
|
+
resolve(specifier: string, from: string): string | undefined
|
|
49
|
+
},
|
|
50
|
+
): Scanner
|
|
51
|
+
copyChunks(tables: Iterable<SplitTable>): ReadonlyMap<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isExtract = (value: unknown): value is Extract =>
|
|
55
|
+
typeof value === "object" &&
|
|
56
|
+
value !== null &&
|
|
57
|
+
"createCopyScanner" in value &&
|
|
58
|
+
typeof value.createCopyScanner === "function" &&
|
|
59
|
+
"copyChunks" in value &&
|
|
60
|
+
typeof value.copyChunks === "function"
|
|
61
|
+
|
|
62
|
+
/** A chunk file the build wrote. */
|
|
63
|
+
export interface CopyChunkFile {
|
|
64
|
+
/** The file name, served under the build's public path. */
|
|
65
|
+
readonly name: string
|
|
66
|
+
readonly text: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CopySplit {
|
|
70
|
+
/** The client build's plugin, registered after every other one. */
|
|
71
|
+
readonly plugin: BunPlugin
|
|
72
|
+
/** A module with its tables replaced by chunk ids, or `undefined` when it has none to split. */
|
|
73
|
+
rewrite(file: string, source: string): string | undefined
|
|
74
|
+
/** One chunk per locale for the tables this build split, and each one's URL by locale key. */
|
|
75
|
+
chunks(publicPath: string): {
|
|
76
|
+
readonly urls: Readonly<Record<string, string>>
|
|
77
|
+
readonly files: readonly CopyChunkFile[]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const readText = (file: string): string | undefined => {
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(file, "utf8")
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a module under `dir` imports the copy module: the only way an app defines copy tables. */
|
|
90
|
+
function definesCopy(dir: string, skip: ReadonlySet<string>, depth = 0): boolean {
|
|
91
|
+
let entries: Dirent[]
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue
|
|
99
|
+
const path = join(dir, entry.name)
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
if (depth < MAX_DEPTH && !skip.has(path) && definesCopy(path, skip, depth + 1)) return true
|
|
102
|
+
} else if (entry.isFile() && SCRIPT.test(entry.name)) {
|
|
103
|
+
if (readText(path)?.includes(COPY_MODULE) === true) return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const loaderOf = (path: string): "js" | "jsx" | "ts" | "tsx" => {
|
|
110
|
+
if (path.endsWith("x")) return path.endsWith(".tsx") ? "tsx" : "jsx"
|
|
111
|
+
return /\.m?ts$/.test(path) ? "ts" : "js"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The copy split for an app, or `undefined` when it defines no copy tables. An app that does, but whose
|
|
116
|
+
* TypeScript has no compiler API, builds with its tables whole, with a warning.
|
|
117
|
+
*/
|
|
118
|
+
export async function loadCopySplit(
|
|
119
|
+
root: string,
|
|
120
|
+
options: { readonly skip?: readonly string[]; readonly warn?: (message: string) => void } = {},
|
|
121
|
+
): Promise<CopySplit | undefined> {
|
|
122
|
+
const appRoot = resolvePath(root)
|
|
123
|
+
const require = createRequire(join(appRoot, "package.json"))
|
|
124
|
+
let extractPath: string
|
|
125
|
+
try {
|
|
126
|
+
extractPath = require.resolve(EXTRACT_MODULE)
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
if (!definesCopy(appRoot, new Set((options.skip ?? []).map((dir) => resolvePath(dir))))) {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
const warn = options.warn ?? console.warn
|
|
134
|
+
let ts: unknown
|
|
135
|
+
try {
|
|
136
|
+
ts = require("typescript")
|
|
137
|
+
} catch {
|
|
138
|
+
ts = undefined
|
|
139
|
+
}
|
|
140
|
+
if (!isTypeScriptCompiler(ts)) {
|
|
141
|
+
warn(
|
|
142
|
+
"[nifra/web] copy tables ship every locale in the client bundle: splitting them by locale reads the source with the TypeScript compiler API, and the app's TypeScript has none (TypeScript 7 ships none; install typescript@^6 as a dev dependency).",
|
|
143
|
+
)
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
const extract: unknown = await import(pathToFileURL(extractPath).href)
|
|
147
|
+
if (!isExtract(extract)) {
|
|
148
|
+
warn(
|
|
149
|
+
`[nifra/web] copy tables ship every locale in the client bundle: ${EXTRACT_MODULE} at ${extractPath} predates the client split; update @nifrajs/i18n.`,
|
|
150
|
+
)
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
const scanner = extract.createCopyScanner(ts, {
|
|
154
|
+
root: appRoot.replaceAll("\\", "/"),
|
|
155
|
+
read: readText,
|
|
156
|
+
resolve: (specifier, from) => {
|
|
157
|
+
try {
|
|
158
|
+
return createRequire(from).resolve(specifier)
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const split = new Map<string, readonly SplitTable[]>()
|
|
166
|
+
const rewrite = (file: string, source: string): string | undefined => {
|
|
167
|
+
const path = normalizeFilePath(file)
|
|
168
|
+
// The scanner reads the file off disk: text another transform changed first is left alone, since
|
|
169
|
+
// its tables' positions are no longer the file's.
|
|
170
|
+
if (!REWRITABLE.test(path) || DEPENDENCY.test(path) || readText(path) !== source) {
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
const out = scanner.rewrite(path)
|
|
174
|
+
if (out === undefined) return undefined
|
|
175
|
+
split.set(
|
|
176
|
+
path,
|
|
177
|
+
scanner.tables(path).flatMap((site) => {
|
|
178
|
+
const locales =
|
|
179
|
+
site.values === undefined ? undefined : scanner.definition(site.definition)?.locales
|
|
180
|
+
return site.values === undefined || locales === undefined
|
|
181
|
+
? []
|
|
182
|
+
: [{ id: site.id, values: site.values, locales }]
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
plugin: {
|
|
190
|
+
name: "nifra-copy-split",
|
|
191
|
+
setup(build) {
|
|
192
|
+
addSourceTransform(build, rewrite)
|
|
193
|
+
build.onLoad({ filter: REWRITABLE }, async (args) => {
|
|
194
|
+
if (args.namespace !== "file") return undefined
|
|
195
|
+
const path = normalizeFilePath(args.path)
|
|
196
|
+
const contents = rewrite(path, await Bun.file(path).text())
|
|
197
|
+
return contents === undefined ? undefined : { contents, loader: loaderOf(path) }
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
rewrite,
|
|
202
|
+
chunks(publicPath) {
|
|
203
|
+
// Sorted, so a chunk's bytes, and so its name, do not depend on the order modules loaded in.
|
|
204
|
+
const tables = [...split.values()].flat().sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
205
|
+
const urls: Record<string, string> = {}
|
|
206
|
+
const files: CopyChunkFile[] = []
|
|
207
|
+
if (tables.length === 0) return { urls, files }
|
|
208
|
+
const chunks = [...extract.copyChunks(tables)].sort(([a], [b]) => (a < b ? -1 : 1))
|
|
209
|
+
for (const [locale, chunk] of chunks) {
|
|
210
|
+
// A JSON string parses faster than the same object as a literal.
|
|
211
|
+
const text = `export default JSON.parse(${JSON.stringify(JSON.stringify(chunk))})\n`
|
|
212
|
+
const hash = createHash("sha256").update(text).digest("hex").slice(0, 12)
|
|
213
|
+
const name = `copy-${locale.replace(/[^A-Za-z0-9-]/g, "_")}-${hash}.js`
|
|
214
|
+
urls[locale] = `${publicPath}${name}`
|
|
215
|
+
files.push({ name, text })
|
|
216
|
+
}
|
|
217
|
+
return { urls, files }
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-entry package sides: `"nifra": { "environment": { ".": "backend", "./react": "frontend" } }`.
|
|
3
|
+
*
|
|
4
|
+
* An import names an entry, and that entry's side decides it. A path names no entry, so a file is owned
|
|
5
|
+
* by every mapped entry that reaches it inside the package: the entry's targets under any export
|
|
6
|
+
* condition, followed through relative imports. A stage that sees only paths decides by those owners;
|
|
7
|
+
* which entry an import may name is decided where the import is written, from its specifier.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
10
|
+
import { dirname, extname, join, relative } from "node:path"
|
|
11
|
+
import type { PackageEnvironment } from "../zones.ts"
|
|
12
|
+
import { type Token, tokenize } from "./private-env.ts"
|
|
13
|
+
import { withoutQuery } from "./without-query.ts"
|
|
14
|
+
|
|
15
|
+
export type EnvironmentDeclaration =
|
|
16
|
+
| { readonly kind: "none" }
|
|
17
|
+
| { readonly kind: "package"; readonly side: PackageEnvironment }
|
|
18
|
+
| { readonly kind: "entries"; readonly entries: Readonly<Record<string, PackageEnvironment>> }
|
|
19
|
+
| { readonly kind: "invalid"; readonly problem: string }
|
|
20
|
+
|
|
21
|
+
const SIDES: ReadonlySet<string> = new Set(["frontend", "backend", "shared", "library"])
|
|
22
|
+
const isSide = (value: unknown): value is PackageEnvironment =>
|
|
23
|
+
typeof value === "string" && SIDES.has(value)
|
|
24
|
+
|
|
25
|
+
/** A manifest's `nifra.environment`. A string naming no side declares nothing, as it always has. */
|
|
26
|
+
export function parseEnvironment(value: unknown): EnvironmentDeclaration {
|
|
27
|
+
if (isSide(value)) return { kind: "package", side: value }
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { kind: "none" }
|
|
29
|
+
const entries: Record<string, PackageEnvironment> = {}
|
|
30
|
+
for (const [key, side] of Object.entries(value)) {
|
|
31
|
+
if (key !== "." && !key.startsWith("./"))
|
|
32
|
+
return {
|
|
33
|
+
kind: "invalid",
|
|
34
|
+
problem: `key "${key}" must be "." or a subpath starting with "./"`,
|
|
35
|
+
}
|
|
36
|
+
if (key.indexOf("*") !== key.lastIndexOf("*"))
|
|
37
|
+
return { kind: "invalid", problem: `key "${key}" may hold one "*"` }
|
|
38
|
+
if (!isSide(side))
|
|
39
|
+
return {
|
|
40
|
+
kind: "invalid",
|
|
41
|
+
problem: `"${key}" must be one of frontend, backend, shared, library`,
|
|
42
|
+
}
|
|
43
|
+
entries[key] = side
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(entries).length === 0
|
|
46
|
+
? {
|
|
47
|
+
kind: "invalid",
|
|
48
|
+
problem: 'maps no entry; give each export subpath (".", "./react") a side',
|
|
49
|
+
}
|
|
50
|
+
: { kind: "entries", entries }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The mapped key that decides `subpath`: the exact key, else the `*` pattern with the longest prefix,
|
|
55
|
+
* then the longest key, where `*` matches at least one character - as Node matches `exports`.
|
|
56
|
+
*/
|
|
57
|
+
export function entryKey(
|
|
58
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
59
|
+
subpath: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (Object.hasOwn(entries, subpath)) return subpath
|
|
62
|
+
let best: string | undefined
|
|
63
|
+
for (const key of Object.keys(entries)) {
|
|
64
|
+
const star = key.indexOf("*")
|
|
65
|
+
if (star === -1 || subpath.length < key.length) continue
|
|
66
|
+
if (!subpath.startsWith(key.slice(0, star)) || !subpath.endsWith(key.slice(star + 1))) continue
|
|
67
|
+
const bestStar = best?.indexOf("*") ?? -1
|
|
68
|
+
if (best === undefined || star > bestStar || (star === bestStar && key.length > best.length))
|
|
69
|
+
best = key
|
|
70
|
+
}
|
|
71
|
+
return best
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EntryOwnership {
|
|
75
|
+
/** Each file a mapped entry reaches, by real path, with the sides of every entry that reaches it. */
|
|
76
|
+
readonly owners: ReadonlyMap<string, ReadonlySet<PackageEnvironment>>
|
|
77
|
+
/** Export subpaths the map gives no side. */
|
|
78
|
+
readonly unmapped: readonly string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isFile = (path: string): boolean =>
|
|
82
|
+
statSync(path, { throwIfNoEntry: false })?.isFile() ?? false
|
|
83
|
+
|
|
84
|
+
const real = (path: string): string => {
|
|
85
|
+
try {
|
|
86
|
+
return realpathSync.native(path).replaceAll("\\", "/")
|
|
87
|
+
} catch {
|
|
88
|
+
return path.replaceAll("\\", "/")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SCRIPT = /\.(?:[cm]?[jt]sx?|svelte|vue)$/
|
|
93
|
+
const STYLESHEET = /\.(?:css|scss|sass|less|styl)$/
|
|
94
|
+
const DECLARATION = /\.d\.[cm]?ts$/
|
|
95
|
+
const PROBE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]
|
|
96
|
+
/** A TypeScript source imports its sibling by the extension it compiles to. */
|
|
97
|
+
const SOURCE_TWINS: Readonly<Record<string, readonly string[]>> = {
|
|
98
|
+
".js": [".ts", ".tsx"],
|
|
99
|
+
".jsx": [".tsx"],
|
|
100
|
+
".mjs": [".mts"],
|
|
101
|
+
".cjs": [".cts"],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function probe(base: string): string | undefined {
|
|
105
|
+
if (isFile(base)) return base
|
|
106
|
+
const extension = extname(base)
|
|
107
|
+
for (const twin of SOURCE_TWINS[extension] ?? []) {
|
|
108
|
+
const candidate = `${base.slice(0, -extension.length)}${twin}`
|
|
109
|
+
if (isFile(candidate)) return candidate
|
|
110
|
+
}
|
|
111
|
+
for (const candidate of PROBE_EXTENSIONS.map((ext) => `${base}${ext}`)) {
|
|
112
|
+
if (isFile(candidate)) return candidate
|
|
113
|
+
}
|
|
114
|
+
for (const candidate of PROBE_EXTENSIONS.map((ext) => join(base, `index${ext}`))) {
|
|
115
|
+
if (isFile(candidate)) return candidate
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CLAUSE_PUNCT: ReadonlySet<string> = new Set(["{", "}", ",", "*"])
|
|
121
|
+
const isPunct = (token: Token | undefined, value: string): boolean =>
|
|
122
|
+
token?.kind === "punct" && token.value === value
|
|
123
|
+
const isId = (token: Token | undefined, value: string): boolean =>
|
|
124
|
+
token?.kind === "id" && token.value === value
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether the `import`/`export` clause ending at the `from` token `at` binds only types: `import type`,
|
|
128
|
+
* `export type`, or a `{ }` list whose every specifier is `type`. Such an edge grants no ownership - a
|
|
129
|
+
* backend file a frontend entry merely types against stays backend. `import { type A }` survives as
|
|
130
|
+
* `import {}` under Vite with `verbatimModuleSyntax`; dropping it then fails closed, since the guard
|
|
131
|
+
* judges that load by the file's remaining owners.
|
|
132
|
+
*/
|
|
133
|
+
function typeOnlyClause(tokens: readonly Token[], at: number): boolean {
|
|
134
|
+
let start = at - 1
|
|
135
|
+
for (; start >= 0; start--) {
|
|
136
|
+
const token = tokens[start]
|
|
137
|
+
if (isId(token, "import") || isId(token, "export")) break
|
|
138
|
+
if (token?.kind !== "id" && !(token?.kind === "punct" && CLAUSE_PUNCT.has(token.value)))
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
if (start < 0) return false
|
|
142
|
+
const clause = tokens.slice(start + 1, at)
|
|
143
|
+
// `import type from "./x"` and `import type, { a } from` bind a default import named `type`.
|
|
144
|
+
if (isId(clause[0], "type")) return clause.length > 1 && !isPunct(clause[1], ",")
|
|
145
|
+
if (!isPunct(clause[0], "{") || !isPunct(clause.at(-1), "}")) return false
|
|
146
|
+
const specifiers: Token[][] = [[]]
|
|
147
|
+
for (const token of clause.slice(1, -1)) {
|
|
148
|
+
if (isPunct(token, ",")) specifiers.push([])
|
|
149
|
+
else specifiers.at(-1)?.push(token)
|
|
150
|
+
}
|
|
151
|
+
const named = specifiers.filter((specifier) => specifier.length > 0)
|
|
152
|
+
// `{ type as x }` imports a value named `type`; `{ type A }` and `{ type A as B }` import types.
|
|
153
|
+
return (
|
|
154
|
+
named.length > 0 &&
|
|
155
|
+
named.every(
|
|
156
|
+
(specifier) =>
|
|
157
|
+
isId(specifier[0], "type") &&
|
|
158
|
+
(specifier.length === 2 || (specifier.length === 4 && isId(specifier[2], "as"))),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The relative specifiers a module loads: imports, re-exports, `import()`s, `require`s and
|
|
164
|
+
* `new URL()`s (a worker or an asset beside it). A clause binding only types loads nothing. */
|
|
165
|
+
function relativeImports(code: string): string[] {
|
|
166
|
+
const tokens = tokenize(code)
|
|
167
|
+
const found: string[] = []
|
|
168
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
169
|
+
const token = tokens[i]
|
|
170
|
+
if (token?.kind !== "id") continue
|
|
171
|
+
const previous = tokens[i - 1]
|
|
172
|
+
if (previous?.kind === "punct" && previous.value === ".") continue
|
|
173
|
+
let next = tokens[i + 1]
|
|
174
|
+
const call = next?.kind === "punct" && next.value === "("
|
|
175
|
+
const url = token.value === "URL" && previous?.kind === "id" && previous.value === "new"
|
|
176
|
+
if (token.value === "require" || url || (token.value === "import" && call)) {
|
|
177
|
+
if (!call) continue
|
|
178
|
+
next = tokens[i + 2]
|
|
179
|
+
} else if (token.value !== "from" && token.value !== "import") continue
|
|
180
|
+
if (next?.kind !== "string" || !/^\.\.?\//.test(next.value)) continue
|
|
181
|
+
if (token.value === "from" && typeOnlyClause(tokens, i)) continue
|
|
182
|
+
found.push(next.value)
|
|
183
|
+
}
|
|
184
|
+
return found
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** What a stylesheet pulls in beside it: `@import`s and `url()`s that name no scheme or root. */
|
|
188
|
+
function stylesheetImports(code: string): string[] {
|
|
189
|
+
const text = code.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
190
|
+
const found: string[] = []
|
|
191
|
+
for (const match of text.matchAll(
|
|
192
|
+
/@import\s+(?:url\(\s*)?["']?([^"'()\s;]+)|url\(\s*["']?([^"'()\s]+)/g,
|
|
193
|
+
)) {
|
|
194
|
+
const reference = match[1] ?? match[2] ?? ""
|
|
195
|
+
if (reference !== "" && !/^(?:[a-z][\w+.-]*:|\/|#|~)/i.test(reference)) found.push(reference)
|
|
196
|
+
}
|
|
197
|
+
return found
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every string target of an export value, under every condition. */
|
|
201
|
+
function targetsOf(value: unknown): string[] {
|
|
202
|
+
if (typeof value === "string") return value.startsWith("./") ? [value] : []
|
|
203
|
+
if (Array.isArray(value)) return value.flatMap(targetsOf)
|
|
204
|
+
if (typeof value === "object" && value !== null) return Object.values(value).flatMap(targetsOf)
|
|
205
|
+
return []
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `exports` as subpath entries; without it, the root is `main`/`module`/`browser` and any file a subpath. */
|
|
209
|
+
function exportEntries(
|
|
210
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
211
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
212
|
+
): Array<readonly [string, readonly string[]]> {
|
|
213
|
+
const exports = manifest.exports
|
|
214
|
+
if (exports === undefined || exports === null) {
|
|
215
|
+
const roots = [manifest.main, manifest.module, manifest.browser]
|
|
216
|
+
.filter((root): root is string => typeof root === "string")
|
|
217
|
+
.map((root) => (root.startsWith("./") ? root : `./${root}`))
|
|
218
|
+
return [
|
|
219
|
+
[".", roots.length > 0 ? roots : ["./index.js"]],
|
|
220
|
+
...Object.keys(entries)
|
|
221
|
+
.filter((key) => key !== ".")
|
|
222
|
+
.map((key) => [key, [key]] as const),
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
if (typeof exports !== "object" || Array.isArray(exports)) return [[".", targetsOf(exports)]]
|
|
226
|
+
const keys = Object.keys(exports)
|
|
227
|
+
if (keys.length === 0 || !keys.every((key) => key.startsWith(".")))
|
|
228
|
+
return [[".", targetsOf(exports)]]
|
|
229
|
+
return Object.entries(exports).map(([key, value]) => [key, targetsOf(value)] as const)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Every file under `dir`, outside `node_modules`. */
|
|
233
|
+
function filesUnder(dir: string): string[] {
|
|
234
|
+
let names: string[]
|
|
235
|
+
try {
|
|
236
|
+
names = readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
237
|
+
} catch {
|
|
238
|
+
return []
|
|
239
|
+
}
|
|
240
|
+
return names
|
|
241
|
+
.filter((name) => !/(?:^|[\\/])node_modules(?:[\\/]|$)/.test(name))
|
|
242
|
+
.map((name) => join(dir, name))
|
|
243
|
+
.filter(isFile)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Which mapped entries reach each file of the package at `dir` (a real path). */
|
|
247
|
+
export function entryOwnership(
|
|
248
|
+
dir: string,
|
|
249
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
250
|
+
entries: Readonly<Record<string, PackageEnvironment>>,
|
|
251
|
+
): EntryOwnership {
|
|
252
|
+
const owners = new Map<string, Set<PackageEnvironment>>()
|
|
253
|
+
const unmapped = new Set<string>()
|
|
254
|
+
const queue: Array<readonly [string, PackageEnvironment]> = []
|
|
255
|
+
const reach = (path: string, side: PackageEnvironment): void => {
|
|
256
|
+
const file = real(path)
|
|
257
|
+
if (!file.startsWith(`${dir}/`) || file.slice(dir.length).includes("/node_modules/")) return
|
|
258
|
+
const sides = owners.get(file) ?? new Set<PackageEnvironment>()
|
|
259
|
+
if (sides.has(side)) return
|
|
260
|
+
sides.add(side)
|
|
261
|
+
owners.set(file, sides)
|
|
262
|
+
queue.push([file, side])
|
|
263
|
+
}
|
|
264
|
+
const sideOf = (subpath: string, exportKey = subpath): PackageEnvironment | undefined => {
|
|
265
|
+
const key = entryKey(entries, subpath)
|
|
266
|
+
if (key === undefined) unmapped.add(exportKey)
|
|
267
|
+
return key === undefined ? undefined : entries[key]
|
|
268
|
+
}
|
|
269
|
+
for (const [key, targets] of exportEntries(manifest, entries)) {
|
|
270
|
+
const star = key.indexOf("*")
|
|
271
|
+
if (star === -1) {
|
|
272
|
+
const side = sideOf(key)
|
|
273
|
+
if (side === undefined) continue
|
|
274
|
+
for (const target of targets) {
|
|
275
|
+
const file = target.includes("*") ? undefined : probe(join(dir, target))
|
|
276
|
+
if (file !== undefined) reach(file, side)
|
|
277
|
+
}
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
// A pattern entry: each file a target pattern matches is the subpath its `*` spells.
|
|
281
|
+
for (const target of targets) {
|
|
282
|
+
const at = target.indexOf("*")
|
|
283
|
+
if (at === -1) continue
|
|
284
|
+
const prefix = target.slice(0, at)
|
|
285
|
+
const suffix = target.slice(at + 1)
|
|
286
|
+
for (const file of filesUnder(join(dir, prefix.slice(0, prefix.lastIndexOf("/") + 1)))) {
|
|
287
|
+
const path = `./${relative(dir, file).replaceAll("\\", "/")}`
|
|
288
|
+
if (path.length <= prefix.length + suffix.length) continue
|
|
289
|
+
if (!path.startsWith(prefix) || !path.endsWith(suffix)) continue
|
|
290
|
+
const matched = path.slice(prefix.length, path.length - suffix.length)
|
|
291
|
+
const side = sideOf(`${key.slice(0, star)}${matched}${key.slice(star + 1)}`, key)
|
|
292
|
+
if (side !== undefined) reach(file, side)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const imports = new Map<string, readonly string[]>()
|
|
297
|
+
for (const [file, side] of queue) {
|
|
298
|
+
const script = SCRIPT.test(file) && !DECLARATION.test(file)
|
|
299
|
+
if (!script && !STYLESHEET.test(file)) continue
|
|
300
|
+
let specifiers = imports.get(file)
|
|
301
|
+
if (specifiers === undefined) {
|
|
302
|
+
try {
|
|
303
|
+
const code = readFileSync(file, "utf8")
|
|
304
|
+
specifiers = script ? relativeImports(code) : stylesheetImports(code)
|
|
305
|
+
} catch {
|
|
306
|
+
specifiers = []
|
|
307
|
+
}
|
|
308
|
+
imports.set(file, specifiers)
|
|
309
|
+
}
|
|
310
|
+
for (const specifier of specifiers) {
|
|
311
|
+
const found = probe(join(dirname(file), withoutQuery(specifier)))
|
|
312
|
+
if (found !== undefined) reach(found, side)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { owners, unmapped: [...unmapped].sort() }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The `package.json` of the installed package `name`, as Node finds it: the nearest
|
|
320
|
+
* `node_modules/<name>` at or above `fromDir`.
|
|
321
|
+
*/
|
|
322
|
+
export function installedManifest(fromDir: string, name: string): string | undefined {
|
|
323
|
+
let dir = fromDir
|
|
324
|
+
for (;;) {
|
|
325
|
+
const manifest = join(dir, "node_modules", name, "package.json")
|
|
326
|
+
if (isFile(manifest)) return manifest
|
|
327
|
+
const parent = dirname(dir)
|
|
328
|
+
if (parent === dir) return undefined
|
|
329
|
+
dir = parent
|
|
330
|
+
}
|
|
331
|
+
}
|