@nifrajs/web 0.1.0-alpha.1 → 0.1.0-alpha.3

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 (49) hide show
  1. package/README.md +8 -0
  2. package/dist/build.d.ts +252 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +755 -6
  5. package/dist/build.js.map +1 -1
  6. package/dist/chunk-names.d.ts +4 -0
  7. package/dist/chunk-names.d.ts.map +1 -0
  8. package/dist/chunk-names.js +46 -0
  9. package/dist/chunk-names.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +19 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/dev.d.ts +1 -1
  14. package/dist/dev.d.ts.map +1 -1
  15. package/dist/dev.js +2 -1
  16. package/dist/dev.js.map +1 -1
  17. package/dist/fonts-google.d.ts +2 -1
  18. package/dist/fonts-google.d.ts.map +1 -1
  19. package/dist/fonts-google.js.map +1 -1
  20. package/dist/fonts.d.ts +2 -1
  21. package/dist/fonts.d.ts.map +1 -1
  22. package/dist/fonts.js +0 -16
  23. package/dist/fonts.js.map +1 -1
  24. package/dist/index.d.ts +138 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +268 -23
  27. package/dist/index.js.map +1 -1
  28. package/dist/manifest.d.ts +82 -5
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/server-only.d.ts +21 -0
  32. package/dist/server-only.d.ts.map +1 -0
  33. package/dist/server-only.js +25 -0
  34. package/dist/server-only.js.map +1 -0
  35. package/dist/vite.d.ts +21 -1
  36. package/dist/vite.d.ts.map +1 -1
  37. package/dist/vite.js +68 -4
  38. package/dist/vite.js.map +1 -1
  39. package/package.json +7 -2
  40. package/src/build.ts +1066 -12
  41. package/src/chunk-names.ts +45 -0
  42. package/src/client.ts +19 -2
  43. package/src/dev.ts +3 -2
  44. package/src/fonts-google.ts +3 -2
  45. package/src/fonts.ts +2 -1
  46. package/src/index.ts +373 -32
  47. package/src/manifest.ts +84 -6
  48. package/src/server-only.ts +25 -0
  49. package/src/vite.ts +82 -5
@@ -0,0 +1,45 @@
1
+ /**
2
+ * URL-safe output chunk naming. Bun names a code-split chunk after its source file's basename, so a
3
+ * dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js` — the `[ ]` make a URL a static server
4
+ * (server-bun, CF Pages) reject with 400, so the lazy import fails and the route silently never
5
+ * hydrates (SSR looks perfect; all interactivity is dead). `sanitizeOutputNames` renames every output
6
+ * whose basename isn't URL-path-safe (`[slug]` → `_slug_`) and rewrites the references to it inside the
7
+ * other JS/CSS chunks (where the bootstrap's lazy `import("…/[slug]-hash.js")` lives). The returned
8
+ * unsafe→safe basename map lets the build manifest point at the renamed files.
9
+ */
10
+ import { readFileSync, renameSync, writeFileSync } from "node:fs"
11
+
12
+ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
13
+ const URL_UNSAFE_NAME = /[^A-Za-z0-9._-]/g
14
+
15
+ export function sanitizeOutputNames(
16
+ outputs: readonly { readonly path: string }[],
17
+ ): Map<string, string> {
18
+ const renames = new Map<string, string>()
19
+ for (const out of outputs) {
20
+ const base = basename(out.path)
21
+ const safe = base.replace(URL_UNSAFE_NAME, "_")
22
+ if (safe !== base) renames.set(base, safe)
23
+ }
24
+ if (renames.size === 0) return renames
25
+ // The hashed basename is a unique token, so replacing it literally inside the other chunks is safe.
26
+ for (const out of outputs) {
27
+ if (!out.path.endsWith(".js") && !out.path.endsWith(".css")) continue
28
+ let text = readFileSync(out.path, "utf8")
29
+ let changed = false
30
+ for (const [from, to] of renames) {
31
+ if (text.includes(from)) {
32
+ text = text.split(from).join(to)
33
+ changed = true
34
+ }
35
+ }
36
+ if (changed) writeFileSync(out.path, text)
37
+ }
38
+ for (const out of outputs) {
39
+ const safe = renames.get(basename(out.path))
40
+ if (safe !== undefined) {
41
+ renameSync(out.path, out.path.slice(0, out.path.lastIndexOf("/") + 1) + safe)
42
+ }
43
+ }
44
+ return renames
45
+ }
package/src/client.ts CHANGED
@@ -186,14 +186,31 @@ export function installHistory(
186
186
  export function applyHead(head: Meta): void {
187
187
  if (head.title !== undefined) document.title = head.title
188
188
  for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
189
- const add = (tag: "meta" | "link", attrs: Record<string, string>): void => {
189
+ // Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
190
+ // `true` sets the bare boolean attribute, `false`/`undefined` skip it — so a soft-nav head matches
191
+ // the server-rendered one exactly (no hydration drift on the managed tags).
192
+ const add = (tag: "meta" | "link", attrs: Record<string, string | boolean | undefined>): void => {
190
193
  const el = document.createElement(tag)
191
- for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value)
194
+ for (const [name, value] of Object.entries(attrs)) {
195
+ if (value === undefined || value === false) continue
196
+ el.setAttribute(name, value === true ? "" : value)
197
+ }
192
198
  el.setAttribute("data-nifra", "")
193
199
  document.head.appendChild(el)
194
200
  }
195
201
  for (const m of head.meta ?? []) add("meta", m)
196
202
  for (const l of head.link ?? []) add("link", l)
203
+ // `<script>` slot (JSON-LD etc.) — soft-nav parity with the SSR `headTags` script render. Set the body
204
+ // via `textContent`, NOT `innerHTML`: `textContent` assigns the raw string without HTML re-parsing, so
205
+ // a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out — the DOM-native equivalent of
206
+ // the server's `escapeScriptContent`. `type` defaults to `application/ld+json` (matching the SSR side).
207
+ for (const s of head.script ?? []) {
208
+ const el = document.createElement("script")
209
+ el.setAttribute("type", s.type ?? "application/ld+json")
210
+ el.textContent = s.content
211
+ el.setAttribute("data-nifra", "")
212
+ document.head.appendChild(el)
213
+ }
197
214
  }
198
215
 
199
216
  export function installForms(router: ClientRouter): () => void {
package/src/dev.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { readdirSync, unwatchFile, watchFile } from "node:fs"
9
9
  import { type BuildClientOptions, buildClient } from "./build.ts"
10
+ import { DEFAULT_DEV_PORT } from "./index.ts"
10
11
 
11
12
  /** Minimal app surface the dev server needs — `createWebApp(...)` satisfies it. */
12
13
  interface FetchApp {
@@ -22,7 +23,7 @@ export interface DevServerOptions extends Omit<BuildClientOptions, "minify"> {
22
23
  readonly createApp: (clientEntry: string, importQuery: string) => FetchApp | Promise<FetchApp>
23
24
  /** Directories to watch (default: `[routesDir]`). */
24
25
  readonly watch?: readonly string[]
25
- /** Port to listen on (default 3000). */
26
+ /** Port to listen on (default {@link DEFAULT_DEV_PORT}). */
26
27
  readonly port?: number
27
28
  }
28
29
 
@@ -38,7 +39,7 @@ const reloadClient = `<script>(()=>{const x=()=>{const w=new WebSocket((location
38
39
 
39
40
  /** Start the dev server: build → serve → watch → rebuild + reload on change. */
40
41
  export async function createDevServer(options: DevServerOptions): Promise<DevServer> {
41
- const { createApp, port = 3000, outDir, routesDir } = options
42
+ const { createApp, port = DEFAULT_DEV_PORT, outDir, routesDir } = options
42
43
  const publicPath = options.publicPath ?? "/assets/"
43
44
  let version = 0
44
45
  const rebuild = async (): Promise<FetchApp> => {
@@ -24,6 +24,7 @@
24
24
  import { writeFile as fsWriteFile, mkdir } from "node:fs/promises"
25
25
  import { dirname, join } from "node:path"
26
26
  import { type FontDisplay, fontFace, fontPreload } from "./fonts.ts"
27
+ import type { LinkDescriptor } from "./manifest.ts"
27
28
 
28
29
  /** Options describing the Google font to fetch + self-host. */
29
30
  export interface GoogleFontOptions {
@@ -82,7 +83,7 @@ export interface LoadGoogleFontResult {
82
83
  readonly assets: readonly FontAsset[]
83
84
  /** `fontPreload()` link-attribute sets — spread the ones you want into a layout's `meta.link`.
84
85
  * Preloading *every* weight/subset is wasteful; usually preload just the primary subset + weight. */
85
- readonly preloads: readonly Record<string, string>[]
86
+ readonly preloads: readonly LinkDescriptor[]
86
87
  }
87
88
 
88
89
  export interface LoadGoogleFontIO {
@@ -340,7 +341,7 @@ export async function loadGoogleFont(
340
341
 
341
342
  const assets: FontAsset[] = []
342
343
  const rules: string[] = []
343
- const preloads: Record<string, string>[] = []
344
+ const preloads: LinkDescriptor[] = []
344
345
 
345
346
  for (const face of faces) {
346
347
  // Prefer woff2; the CSS2 endpoint with a Chrome UA only ever returns woff2, but be defensive.
package/src/fonts.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * // a root layout's meta — becomes a <link rel="preload" as="font"> in <head>
15
15
  * export const meta = { link: [fontPreload({ href: "/fonts/inter.woff2" })] }
16
16
  */
17
+ import type { LinkDescriptor } from "./manifest.ts"
17
18
 
18
19
  /** `font-display` strategy. `swap` (the default here) paints fallback text immediately, then swaps. */
19
20
  export type FontDisplay = "auto" | "block" | "swap" | "fallback" | "optional"
@@ -135,7 +136,7 @@ export interface FontPreloadInput {
135
136
  * escaped at injection by the head renderer. Preloading the font file removes a render-blocking round
136
137
  * trip (the browser would otherwise discover the font only after parsing the CSS).
137
138
  */
138
- export function fontPreload(input: FontPreloadInput): Record<string, string> {
139
+ export function fontPreload(input: FontPreloadInput): LinkDescriptor {
139
140
  const type = input.type ?? MIME_BY_EXT[extOf(input.href)]
140
141
  return {
141
142
  rel: "preload",