@nifrajs/web 2.14.1 → 3.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 (145) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-plan.d.ts +228 -0
  6. package/dist/build-plan.d.ts.map +1 -0
  7. package/dist/build-plan.js +514 -0
  8. package/dist/build-plan.js.map +1 -0
  9. package/dist/build-vite.d.ts +2 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +5 -3
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +2 -197
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +92 -515
  16. package/dist/build.js.map +1 -1
  17. package/dist/chunk-names.d.ts.map +1 -1
  18. package/dist/chunk-names.js +3 -2
  19. package/dist/chunk-names.js.map +1 -1
  20. package/dist/dev-ssr-graph.d.ts.map +1 -1
  21. package/dist/dev-ssr-graph.js +63 -28
  22. package/dist/dev-ssr-graph.js.map +1 -1
  23. package/dist/diagnostic.d.ts.map +1 -1
  24. package/dist/diagnostic.js +9 -2
  25. package/dist/diagnostic.js.map +1 -1
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +3 -1
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/fs.d.ts.map +1 -1
  30. package/dist/fs.js +2 -1
  31. package/dist/fs.js.map +1 -1
  32. package/dist/index.d.ts +16 -589
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +23 -1824
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/codegen.d.ts +68 -0
  37. package/dist/internal/codegen.d.ts.map +1 -0
  38. package/dist/internal/codegen.js +317 -0
  39. package/dist/internal/codegen.js.map +1 -0
  40. package/dist/internal/fetcher-runtime.d.ts +19 -0
  41. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  42. package/dist/internal/fetcher-runtime.js +26 -0
  43. package/dist/internal/fetcher-runtime.js.map +1 -0
  44. package/dist/internal/head-merge.d.ts +2 -0
  45. package/dist/internal/head-merge.d.ts.map +1 -1
  46. package/dist/internal/head-merge.js +17 -1
  47. package/dist/internal/head-merge.js.map +1 -1
  48. package/dist/internal/identity-policy.d.ts +51 -0
  49. package/dist/internal/identity-policy.d.ts.map +1 -0
  50. package/dist/internal/identity-policy.js +67 -0
  51. package/dist/internal/identity-policy.js.map +1 -0
  52. package/dist/internal/parity.d.ts +104 -8
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +472 -56
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/query-runtime.d.ts +8 -0
  57. package/dist/internal/query-runtime.d.ts.map +1 -0
  58. package/dist/internal/query-runtime.js +29 -0
  59. package/dist/internal/query-runtime.js.map +1 -0
  60. package/dist/internal/render-document.d.ts +372 -0
  61. package/dist/internal/render-document.d.ts.map +1 -0
  62. package/dist/internal/render-document.js +744 -0
  63. package/dist/internal/render-document.js.map +1 -0
  64. package/dist/internal/runtime-contract.d.ts +48 -0
  65. package/dist/internal/runtime-contract.d.ts.map +1 -0
  66. package/dist/internal/runtime-contract.js +27 -0
  67. package/dist/internal/runtime-contract.js.map +1 -0
  68. package/dist/internal/web-app.d.ts +171 -0
  69. package/dist/internal/web-app.d.ts.map +1 -0
  70. package/dist/internal/web-app.js +844 -0
  71. package/dist/internal/web-app.js.map +1 -0
  72. package/dist/islands.d.ts +50 -2
  73. package/dist/islands.d.ts.map +1 -1
  74. package/dist/islands.js +96 -36
  75. package/dist/islands.js.map +1 -1
  76. package/dist/manifest.d.ts +41 -6
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js.map +1 -1
  79. package/dist/module-graph.d.ts.map +1 -1
  80. package/dist/module-graph.js +22 -9
  81. package/dist/module-graph.js.map +1 -1
  82. package/dist/nano.d.ts +125 -0
  83. package/dist/nano.d.ts.map +1 -0
  84. package/dist/nano.js +190 -0
  85. package/dist/nano.js.map +1 -0
  86. package/dist/plugins/css-modules.js +2 -2
  87. package/dist/plugins/css-modules.js.map +1 -1
  88. package/dist/plugins/kit.d.ts +12 -0
  89. package/dist/plugins/kit.d.ts.map +1 -1
  90. package/dist/plugins/kit.js +62 -12
  91. package/dist/plugins/kit.js.map +1 -1
  92. package/dist/plugins/postcss.js +2 -2
  93. package/dist/plugins/postcss.js.map +1 -1
  94. package/dist/plugins/scss.js +2 -2
  95. package/dist/plugins/scss.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts +70 -0
  97. package/dist/plugins/stylex.d.ts.map +1 -0
  98. package/dist/plugins/stylex.js +166 -0
  99. package/dist/plugins/stylex.js.map +1 -0
  100. package/dist/plugins/svg.js +2 -2
  101. package/dist/plugins/svg.js.map +1 -1
  102. package/dist/render-seam.d.ts +5 -0
  103. package/dist/render-seam.d.ts.map +1 -1
  104. package/dist/render-seam.js +10 -0
  105. package/dist/render-seam.js.map +1 -1
  106. package/dist/router.d.ts +11 -1
  107. package/dist/router.d.ts.map +1 -1
  108. package/dist/router.js +254 -47
  109. package/dist/router.js.map +1 -1
  110. package/dist/vite.d.ts.map +1 -1
  111. package/dist/vite.js +34 -16
  112. package/dist/vite.js.map +1 -1
  113. package/package.json +49 -2
  114. package/src/boundary.ts +407 -0
  115. package/src/build-plan.ts +741 -0
  116. package/src/build-vite.ts +6 -7
  117. package/src/build.ts +120 -730
  118. package/src/chunk-names.ts +3 -2
  119. package/src/dev-ssr-graph.ts +63 -28
  120. package/src/diagnostic.ts +14 -2
  121. package/src/fonts-google.ts +3 -1
  122. package/src/fs.ts +2 -1
  123. package/src/index.ts +107 -2553
  124. package/src/internal/codegen.ts +400 -0
  125. package/src/internal/fetcher-runtime.ts +36 -0
  126. package/src/internal/head-merge.ts +19 -1
  127. package/src/internal/identity-policy.ts +94 -0
  128. package/src/internal/parity.ts +603 -71
  129. package/src/internal/query-runtime.ts +31 -0
  130. package/src/internal/render-document.ts +1116 -0
  131. package/src/internal/runtime-contract.ts +54 -0
  132. package/src/internal/web-app.ts +1114 -0
  133. package/src/islands.ts +106 -40
  134. package/src/manifest.ts +50 -3
  135. package/src/module-graph.ts +23 -9
  136. package/src/nano.ts +246 -0
  137. package/src/plugins/css-modules.ts +2 -2
  138. package/src/plugins/kit.ts +65 -12
  139. package/src/plugins/postcss.ts +2 -2
  140. package/src/plugins/scss.ts +2 -2
  141. package/src/plugins/stylex.ts +298 -0
  142. package/src/plugins/svg.ts +2 -2
  143. package/src/render-seam.ts +6 -0
  144. package/src/router.ts +293 -51
  145. package/src/vite.ts +41 -15
@@ -5,7 +5,8 @@
5
5
  * `@nifrajs/web` (imported by the sibling plugin modules); not a public subpath.
6
6
  */
7
7
  import { existsSync } from "node:fs"
8
- import { dirname, relative } from "node:path"
8
+ import { dirname, join, relative } from "node:path"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
9
10
  import type { BunPlugin } from "bun"
10
11
 
11
12
  /** The argument Bun passes to a plugin's `setup` - Bun doesn't export the type, so derive it. */
@@ -23,6 +24,38 @@ export { rewriteSsrImports } from "../dev-ssr-graph.ts"
23
24
 
24
25
  const packageRootCache = new Map<string, string>()
25
26
 
27
+ /**
28
+ * Convert a plugin/runtime path into a path the host filesystem can open.
29
+ *
30
+ * Bun and URL-based test/build APIs can hand a Windows path to a plugin as `/D:/…` (the
31
+ * `pathname` form of a file URL). Node's filesystem APIs correctly treat the native form as
32
+ * `D:\…`, but do not reinterpret that URL pathname. Keep this conversion at the boundary so
33
+ * generated module specifiers can remain slash-normalized without making filesystem reads
34
+ * platform-dependent.
35
+ */
36
+ export function normalizeFilePath(path: string): string {
37
+ let value = path.split("?", 1)[0] ?? path
38
+ if (value.startsWith("file://")) {
39
+ try {
40
+ value = fileURLToPath(value)
41
+ } catch {
42
+ // Preserve malformed URL-shaped input. Converting it with Windows slash rules would turn
43
+ // `file://%` into a plausible-looking local path (`file:\\%`) and hide the actual bad specifier.
44
+ return value
45
+ }
46
+ }
47
+ if (process.platform === "win32") {
48
+ if (/^\/[A-Za-z]:[\\/]/.test(value)) value = value.slice(1)
49
+ return value.replaceAll("/", "\\")
50
+ }
51
+ return value
52
+ }
53
+
54
+ /** A stable slash-separated path for generated imports, virtual module IDs, and diagnostics. */
55
+ export function portablePath(path: string): string {
56
+ return normalizeFilePath(path).replaceAll("\\", "/")
57
+ }
58
+
26
59
  /** The nearest ancestor directory of `startDir` that holds a `package.json` (the file's package root),
27
60
  * cached. Falls back to `startDir` if none is found, so it never throws. */
28
61
  function packageRootOf(startDir: string): string {
@@ -30,7 +63,7 @@ function packageRootOf(startDir: string): string {
30
63
  if (cached !== undefined) return cached
31
64
  let dir = startDir
32
65
  for (;;) {
33
- if (existsSync(`${dir}/package.json`)) break
66
+ if (existsSync(join(dir, "package.json"))) break
34
67
  const parent = dirname(dir)
35
68
  if (parent === dir) {
36
69
  dir = startDir // reached the filesystem root with no package.json - anchor on the file's own dir
@@ -52,8 +85,9 @@ function packageRootOf(startDir: string): string {
52
85
  * cwd-relative path would silently desync if the SSR server started from a non-project-root cwd.)
53
86
  */
54
87
  export function reproduciblePath(absolutePath: string): string {
55
- const root = packageRootOf(dirname(absolutePath))
56
- return relative(root, absolutePath).replaceAll("\\", "/")
88
+ const filePath = normalizeFilePath(absolutePath)
89
+ const root = packageRootOf(dirname(filePath))
90
+ return relative(root, filePath).replaceAll("\\", "/")
57
91
  }
58
92
 
59
93
  /**
@@ -100,7 +134,14 @@ export const DEV_ROUTES_ENV = "NIFRA_DEV_ROUTES"
100
134
  const under = (path: string, dir: string | undefined): boolean =>
101
135
  dir !== undefined &&
102
136
  dir !== "" &&
103
- (path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`))
137
+ (() => {
138
+ const normalizedPath = portablePath(path)
139
+ const normalizedDir = portablePath(dir)
140
+ return (
141
+ normalizedPath === normalizedDir ||
142
+ normalizedPath.startsWith(normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`)
143
+ )
144
+ })()
104
145
 
105
146
  /**
106
147
  * Whether a dev compile of `path` may wrap the module in component-level hot-patching - i.e. whether it
@@ -124,7 +165,7 @@ const under = (path: string, dir: string | undefined): boolean =>
124
165
  export function devHotComponent(path: string): boolean {
125
166
  const root = process.env[DEV_ROOT_ENV]
126
167
  if (!under(path, root)) return false
127
- if (path.includes("/node_modules/")) return false
168
+ if (portablePath(path).includes("/node_modules/")) return false
128
169
  return !under(path, process.env[DEV_ROUTES_ENV])
129
170
  }
130
171
 
@@ -151,17 +192,20 @@ export function createStylesheetEmitter(
151
192
  const cssByPath = new Map<string, string>()
152
193
  // `\?${namespace}$`: namespaces are plain identifiers, so no regex metachars to escape.
153
194
  build.onResolve({ filter: new RegExp(`\\?${namespace}$`) }, (args) => ({
154
- path: args.path,
195
+ // Bun may hand this hook a native Windows path while the emitter stores portable module IDs.
196
+ // Normalize the path portion but preserve the virtual namespace suffix.
197
+ path: `${portablePath(args.path.slice(0, -suffix.length))}${suffix}`,
155
198
  namespace,
156
199
  }))
157
200
  build.onLoad({ filter: /.*/, namespace }, (args) => ({
158
- contents: cssByPath.get(args.path.slice(0, -suffix.length)) ?? "",
201
+ contents: cssByPath.get(portablePath(args.path.slice(0, -suffix.length))) ?? "",
159
202
  loader: "css",
160
203
  }))
161
204
  return {
162
205
  emit(path, css) {
163
- cssByPath.set(path, css)
164
- return `import ${JSON.stringify(path + suffix)}\n`
206
+ const filePath = portablePath(path)
207
+ cssByPath.set(filePath, css)
208
+ return `import ${JSON.stringify(filePath + suffix)}\n`
165
209
  },
166
210
  }
167
211
  }
@@ -175,13 +219,22 @@ export async function requirePeer<T>(
175
219
  specifier: string,
176
220
  hint: { readonly feature: string; readonly install: string },
177
221
  ): Promise<T> {
222
+ const isPathSpecifier =
223
+ specifier.startsWith("file://") ||
224
+ /^\/[A-Za-z]:[\\/]/.test(specifier) ||
225
+ /^[A-Za-z]:[\\/]/.test(specifier)
178
226
  try {
179
- return (await import(specifier)) as T
227
+ // Dynamic import treats a Windows URL pathname (`/D:/…`) as a POSIX root. Convert local path
228
+ // seams to a file URL so the runtime, regardless of platform, receives an unambiguous module ID.
229
+ const importPath = isPathSpecifier
230
+ ? pathToFileURL(normalizeFilePath(specifier)).href
231
+ : specifier
232
+ return (await import(importPath)) as T
180
233
  } catch (err) {
181
234
  // Only a genuine resolution failure means "not installed" - surface anything else (a corrupt
182
235
  // install, a native-binding error, a throw at module top-level) as the real error rather than
183
236
  // masking it behind a misleading "please install it" hint.
184
- if ((err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
237
+ if (!isPathSpecifier && (err as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
185
238
  throw new Error(
186
239
  `[nifra/web] ${hint.feature} requires the optional peer "${specifier}". Install it: ${hint.install}`,
187
240
  )
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { BunPlugin } from "bun"
20
20
  import { transformCssModule } from "./css-modules.ts"
21
- import { createStylesheetEmitter, reproduciblePath, requirePeer } from "./kit.ts"
21
+ import { createStylesheetEmitter, normalizeFilePath, reproduciblePath, requirePeer } from "./kit.ts"
22
22
 
23
23
  const STYLE_NS = "nifra-postcss"
24
24
 
@@ -93,7 +93,7 @@ export function postcssBunPlugin(
93
93
  }
94
94
 
95
95
  build.onLoad({ filter: CSS_FILTER }, async (args) => {
96
- const path = args.path.split("?")[0] ?? args.path
96
+ const path = normalizeFilePath(args.path)
97
97
  const source = await Bun.file(path).text()
98
98
  const postcss = await getProcessor()
99
99
  const plugins = await getPlugins()
@@ -17,7 +17,7 @@ import { dirname } from "node:path"
17
17
  import { pathToFileURL } from "node:url"
18
18
  import type { BunPlugin } from "bun"
19
19
  import { transformCssModule } from "./css-modules.ts"
20
- import { createStylesheetEmitter, reproduciblePath, requirePeer } from "./kit.ts"
20
+ import { createStylesheetEmitter, normalizeFilePath, reproduciblePath, requirePeer } from "./kit.ts"
21
21
 
22
22
  const STYLE_NS = "nifra-scss"
23
23
 
@@ -65,7 +65,7 @@ export function scssBunPlugin(generate: "dom" | "ssr", options: ScssPluginOption
65
65
  }
66
66
 
67
67
  build.onLoad({ filter: /\.s[ac]ss(\?|$)/ }, async (args) => {
68
- const path = args.path.split("?")[0] ?? args.path
68
+ const path = normalizeFilePath(args.path)
69
69
  const source = await Bun.file(path).text()
70
70
  const sass = await getCompiler()
71
71
  let css: string
@@ -0,0 +1,298 @@
1
+ /**
2
+ * StyleX integration for Nifra's Bun and Vite pipelines.
3
+ *
4
+ * The StyleX compiler stays an optional peer owned by the StyleX project. This module owns only the
5
+ * Nifra boundary: transform route/component modules in both pipelines, emit client CSS through the
6
+ * existing virtual stylesheet seam, and run the same transform without CSS for SSR.
7
+ */
8
+ import type { BunPlugin } from "bun"
9
+ import {
10
+ createStylesheetEmitter,
11
+ devServerCompile,
12
+ hash8,
13
+ normalizeFilePath,
14
+ reproduciblePath,
15
+ requirePeer,
16
+ } from "./kit.ts"
17
+
18
+ const STYLE_NS = "nifra-stylex"
19
+ const JS_FILTER = /\.[cm]?[jt]sx?(\?|$)/
20
+
21
+ /** StyleX's module resolution option, kept structural so the compiler remains an optional peer. */
22
+ export interface StylexModuleResolution {
23
+ readonly type: "commonJS" | "haste"
24
+ readonly rootDir: string
25
+ }
26
+
27
+ /** Structural slice of `@babel/core` used by the adapter. */
28
+ export interface StylexBabelCompiler {
29
+ transformAsync(
30
+ source: string,
31
+ options: Readonly<Record<string, unknown>>,
32
+ ): Promise<StylexBabelResult | null | undefined>
33
+ }
34
+
35
+ /** Structural slice of the StyleX Babel plugin used by the adapter. */
36
+ export interface StylexCompiler {
37
+ withOptions(options: Readonly<Record<string, unknown>>): unknown
38
+ processStylexRules(rules: readonly unknown[], options: Readonly<Record<string, unknown>>): string
39
+ }
40
+
41
+ /** Optional peer injection and compiler configuration shared by Bun and Vite adapters. */
42
+ export interface StylexPluginOptions {
43
+ /** Inject `@babel/core` in tests or advanced integrations. Defaults to the optional peer. */
44
+ readonly babel?: StylexBabelCompiler
45
+ /** Inject `@stylexjs/babel-plugin` in tests or advanced integrations. */
46
+ readonly compiler?: StylexCompiler
47
+ /** Inject Babel syntax plugins. Defaults to the three official syntax peers. */
48
+ readonly syntaxPlugins?: {
49
+ readonly flow: unknown
50
+ readonly jsx: unknown
51
+ readonly typescript: unknown
52
+ }
53
+ /** StyleX import sources to transform. Defaults to `stylex` and `@stylexjs/stylex`. */
54
+ readonly importSources?: readonly string[]
55
+ /** StyleX development transform mode. Defaults to Nifra's dev-server signal. */
56
+ readonly dev?: boolean
57
+ /** StyleX CSS layer output. Defaults to `true` for deterministic precedence. */
58
+ readonly useCSSLayers?: boolean
59
+ /** StyleX's module resolution policy. */
60
+ readonly unstable_moduleResolution?: StylexModuleResolution
61
+ /** Extra Babel plugins and presets merged before the StyleX plugin. */
62
+ readonly babelConfig?: {
63
+ readonly plugins?: readonly unknown[]
64
+ readonly presets?: readonly unknown[]
65
+ }
66
+ }
67
+
68
+ interface StylexBabelResult {
69
+ readonly code?: unknown
70
+ readonly metadata?: unknown
71
+ }
72
+
73
+ interface StylexTransformedModule {
74
+ readonly code: string
75
+ readonly css: string
76
+ }
77
+
78
+ interface StylexDependencies {
79
+ readonly babel: StylexBabelCompiler
80
+ readonly compiler: StylexCompiler
81
+ readonly syntax: Required<StylexPluginOptions>["syntaxPlugins"]
82
+ }
83
+
84
+ interface RecordLike {
85
+ readonly [key: string]: unknown
86
+ }
87
+
88
+ const isRecord = (value: unknown): value is RecordLike =>
89
+ (typeof value === "object" && value !== null) || typeof value === "function"
90
+
91
+ const unwrapDefault = (value: unknown): unknown => {
92
+ if (!isRecord(value)) return value
93
+ return value.default ?? value
94
+ }
95
+
96
+ const isBabelCompiler = (value: unknown): value is StylexBabelCompiler =>
97
+ isRecord(value) && typeof value.transformAsync === "function"
98
+
99
+ const isStylexCompiler = (value: unknown): value is StylexCompiler =>
100
+ isRecord(value) &&
101
+ typeof value.withOptions === "function" &&
102
+ typeof value.processStylexRules === "function"
103
+
104
+ const syntaxPeer = async (specifier: string, feature: string): Promise<unknown> =>
105
+ unwrapDefault(
106
+ await requirePeer<unknown>(specifier, {
107
+ feature,
108
+ install: `bun add -d ${specifier}`,
109
+ }),
110
+ )
111
+
112
+ const loadBabelCompiler = async (options: StylexPluginOptions): Promise<StylexBabelCompiler> => {
113
+ if (options.babel !== undefined) return options.babel
114
+ const loaded = await requirePeer<unknown>("@babel/core", {
115
+ feature: "StyleX compilation",
116
+ install: "bun add -d @babel/core",
117
+ })
118
+ const compiler = unwrapDefault(loaded)
119
+ if (!isBabelCompiler(compiler)) {
120
+ throw new Error("[nifra/web] the installed @babel/core does not expose transformAsync")
121
+ }
122
+ return compiler
123
+ }
124
+
125
+ const loadStylexCompiler = async (options: StylexPluginOptions): Promise<StylexCompiler> => {
126
+ if (options.compiler !== undefined) return options.compiler
127
+ const loaded = await requirePeer<unknown>("@stylexjs/babel-plugin", {
128
+ feature: "StyleX compilation",
129
+ install: "bun add -d @stylexjs/babel-plugin",
130
+ })
131
+ const compiler = unwrapDefault(loaded)
132
+ if (!isStylexCompiler(compiler)) {
133
+ throw new Error(
134
+ "[nifra/web] the installed @stylexjs/babel-plugin does not expose the StyleX compiler API",
135
+ )
136
+ }
137
+ return compiler
138
+ }
139
+
140
+ const loadSyntaxPlugins = async (
141
+ options: StylexPluginOptions,
142
+ ): Promise<Required<StylexPluginOptions>["syntaxPlugins"]> => {
143
+ if (options.syntaxPlugins !== undefined) return options.syntaxPlugins
144
+ const [flow, jsx, typescript] = await Promise.all([
145
+ syntaxPeer("@babel/plugin-syntax-flow", "StyleX Flow syntax parsing"),
146
+ syntaxPeer("@babel/plugin-syntax-jsx", "StyleX JSX syntax parsing"),
147
+ syntaxPeer("@babel/plugin-syntax-typescript", "StyleX TypeScript syntax parsing"),
148
+ ])
149
+ return { flow, jsx, typescript }
150
+ }
151
+
152
+ const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
153
+
154
+ const containsStylexImport = (source: string, importSources: readonly string[]): boolean =>
155
+ importSources.some((specifier) => {
156
+ const escaped = escapeRegExp(specifier)
157
+ return new RegExp(
158
+ `(?:from\\s*['"]${escaped}['"]|import\\s*\\(\\s*['"]${escaped}['"]\\s*\\)|require\\s*\\(\\s*['"]${escaped}['"]\\s*\\))`,
159
+ "m",
160
+ ).test(source)
161
+ })
162
+
163
+ const loaderFor = (path: string): "js" | "jsx" | "ts" | "tsx" => {
164
+ const clean = path.split("?", 1)[0] ?? path
165
+ if (clean.endsWith(".tsx")) return "tsx"
166
+ if (clean.endsWith(".jsx")) return "jsx"
167
+ if (clean.endsWith(".ts") || clean.endsWith(".mts") || clean.endsWith(".cts")) return "ts"
168
+ return "js"
169
+ }
170
+
171
+ const moduleResolution = (options: StylexPluginOptions): StylexModuleResolution =>
172
+ options.unstable_moduleResolution ?? { type: "commonJS", rootDir: process.cwd() }
173
+
174
+ const transformModule = async (
175
+ source: string,
176
+ filename: string,
177
+ options: StylexPluginOptions,
178
+ dependencies: Promise<StylexDependencies>,
179
+ ): Promise<StylexTransformedModule | undefined> => {
180
+ if (!containsStylexImport(source, options.importSources ?? ["stylex", "@stylexjs/stylex"])) {
181
+ return undefined
182
+ }
183
+
184
+ const { babel, compiler, syntax } = await dependencies
185
+ const result = await babel.transformAsync(source, {
186
+ babelrc: false,
187
+ filename,
188
+ presets: [...(options.babelConfig?.presets ?? [])],
189
+ plugins: [
190
+ ...((options.babelConfig?.plugins ?? []) as readonly unknown[]),
191
+ /\.jsx?$/.test(filename) ? syntax.flow : [syntax.typescript, { isTSX: true }],
192
+ syntax.jsx,
193
+ compiler.withOptions({
194
+ dev: options.dev ?? devServerCompile(),
195
+ importSources: options.importSources ?? ["stylex", "@stylexjs/stylex"],
196
+ treeshakeCompensation: true,
197
+ unstable_moduleResolution: moduleResolution(options),
198
+ }),
199
+ ],
200
+ })
201
+ const code = typeof result?.code === "string" ? result.code : undefined
202
+ if (code === undefined) return undefined
203
+
204
+ const metadata = isRecord(result?.metadata) ? result.metadata : undefined
205
+ const rules = Array.isArray(metadata?.stylex) ? metadata.stylex : []
206
+ const css =
207
+ rules.length === 0
208
+ ? ""
209
+ : compiler.processStylexRules(rules, {
210
+ useCSSLayers: options.useCSSLayers ?? true,
211
+ })
212
+ return { code, css }
213
+ }
214
+
215
+ const dependenciesFor = (options: StylexPluginOptions): (() => Promise<StylexDependencies>) => {
216
+ let dependencies: Promise<StylexDependencies> | undefined
217
+ return (): Promise<StylexDependencies> => {
218
+ dependencies ??= Promise.all([
219
+ loadBabelCompiler(options),
220
+ loadStylexCompiler(options),
221
+ loadSyntaxPlugins(options),
222
+ ]).then(([babel, compiler, syntax]) => ({ babel, compiler, syntax }))
223
+ return dependencies
224
+ }
225
+ }
226
+
227
+ /** Build a Bun plugin that transforms StyleX and emits CSS for the browser or class maps for SSR. */
228
+ export function stylexBunPlugin(
229
+ generate: "dom" | "ssr",
230
+ options: StylexPluginOptions = {},
231
+ ): BunPlugin {
232
+ return {
233
+ name: `nifra-stylex-${generate}`,
234
+ setup(build) {
235
+ const stylesheet = generate === "dom" ? createStylesheetEmitter(build, STYLE_NS) : undefined
236
+ const dependencies = dependenciesFor(options)
237
+ build.onLoad({ filter: JS_FILTER }, async (args) => {
238
+ const path = normalizeFilePath(args.path)
239
+ const source = await Bun.file(path).text()
240
+ const transformed = await transformModule(source, path, options, dependencies())
241
+ if (transformed === undefined) return
242
+ const contents =
243
+ generate === "dom" && stylesheet !== undefined && transformed.css !== ""
244
+ ? `${transformed.code}\n${stylesheet.emit(path, transformed.css)}`
245
+ : transformed.code
246
+ return { contents, loader: loaderFor(path) }
247
+ })
248
+ },
249
+ }
250
+ }
251
+
252
+ /** Structural Vite plugin return type. Vite remains an optional peer of `@nifrajs/web`. */
253
+ export interface StylexVitePlugin {
254
+ readonly name: string
255
+ transform(
256
+ source: string,
257
+ id: string,
258
+ ): Promise<{ readonly code: string; readonly map?: null } | undefined>
259
+ resolveId(id: string): string | undefined
260
+ load(id: string): string | undefined
261
+ }
262
+
263
+ /** Build a Vite plugin that maps each transformed StyleX module to a virtual CSS asset. */
264
+ export function stylexVitePlugin(
265
+ generate: "dom" | "ssr",
266
+ options: StylexPluginOptions = {},
267
+ ): StylexVitePlugin {
268
+ const cssById = new Map<string, string>()
269
+ const dependencies = dependenciesFor(options)
270
+ const virtualIdFor = (id: string): string =>
271
+ `virtual:nifra-stylex/${hash8(reproduciblePath(normalizeFilePath(id)))}.css`
272
+
273
+ return {
274
+ name: `nifra-stylex-${generate}`,
275
+ async transform(source, id) {
276
+ const path = normalizeFilePath(id)
277
+ const transformed = await transformModule(source, path, options, dependencies())
278
+ if (transformed === undefined) return undefined
279
+ if (generate === "ssr" || transformed.css === "") return { code: transformed.code, map: null }
280
+ const cssId = virtualIdFor(path)
281
+ cssById.set(cssId, transformed.css)
282
+ return {
283
+ code: `${transformed.code}\nimport ${JSON.stringify(cssId)}`,
284
+ map: null,
285
+ }
286
+ },
287
+ resolveId(id) {
288
+ return cssById.has(id) ? id : undefined
289
+ },
290
+ load(id) {
291
+ return cssById.get(id)
292
+ },
293
+ }
294
+ }
295
+
296
+ /** Convenience alias for apps that configure only a browser Vite build. */
297
+ export const stylexVite = (options: StylexPluginOptions = {}): StylexVitePlugin =>
298
+ stylexVitePlugin("dom", options)
@@ -16,7 +16,7 @@
16
16
  * `style="..."` into an object, and drops XML declarations/comments so the result is valid JSX.
17
17
  */
18
18
  import type { BunPlugin } from "bun"
19
- import { requirePeer } from "./kit.ts"
19
+ import { normalizeFilePath, requirePeer } from "./kit.ts"
20
20
 
21
21
  /** The subset of the `svgo` API this plugin uses (structural, so no hard dependency on its types). */
22
22
  export interface SvgOptimizer {
@@ -168,7 +168,7 @@ export function svgComponentBunPlugin(
168
168
  }
169
169
 
170
170
  build.onLoad({ filter: SVG_FILTER }, async (args) => {
171
- const path = args.path.split("?")[0] ?? args.path
171
+ const path = normalizeFilePath(args.path)
172
172
  let xml = await Bun.file(path).text()
173
173
  const svgo = await getOptimizer()
174
174
  if (svgo !== undefined) {
@@ -6,6 +6,8 @@
6
6
  * without importing the document renderer through the root module. The singleton is stored on
7
7
  * globalThis because the Bun and Vite dev pipelines can evaluate two copies in one process.
8
8
  */
9
+
10
+ import type { BoundaryStates } from "./boundary.ts"
9
11
  import type { Submission } from "./router.ts"
10
12
 
11
13
  /** The data handed to a route component. */
@@ -26,6 +28,8 @@ export interface RenderProps {
26
28
  readonly path?: string
27
29
  /** The route's typed, validated search params. */
28
30
  readonly search?: Record<string, unknown>
31
+ /** Neutral named-boundary states; adapters choose how each boundary's `render` UI is mounted. */
32
+ readonly boundaries?: BoundaryStates
29
33
  }
30
34
 
31
35
  /**
@@ -72,5 +76,7 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
72
76
  /** Global the server serializes an action's data return into (absent on GETs); the client reads it
73
77
  * so hydration after a native form POST matches the server-rendered markup. */
74
78
  export const ACTION_GLOBAL = "__NIFRA_ACTION__"
79
+ /** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
80
+ export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
75
81
  /** Marker attribute used to find a non-default hydration container. */
76
82
  export const ROOT_ATTRIBUTE = "data-nifra-root"