nukejs 0.0.6 → 0.0.7

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 (40) hide show
  1. package/README.md +87 -5
  2. package/dist/{as-is/Link.js → Link.js} +3 -1
  3. package/dist/Link.js.map +7 -0
  4. package/dist/build-common.d.ts +6 -0
  5. package/dist/build-common.js +20 -6
  6. package/dist/build-common.js.map +2 -2
  7. package/dist/build-node.js.map +1 -1
  8. package/dist/build-vercel.js.map +1 -1
  9. package/dist/builder.d.ts +4 -10
  10. package/dist/builder.js +7 -38
  11. package/dist/builder.js.map +2 -2
  12. package/dist/bundle.js +60 -4
  13. package/dist/bundle.js.map +2 -2
  14. package/dist/component-analyzer.d.ts +6 -0
  15. package/dist/component-analyzer.js +12 -1
  16. package/dist/component-analyzer.js.map +2 -2
  17. package/dist/hmr-bundle.js +17 -4
  18. package/dist/hmr-bundle.js.map +2 -2
  19. package/dist/html-store.d.ts +7 -0
  20. package/dist/html-store.js.map +2 -2
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +2 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/renderer.js +2 -7
  25. package/dist/renderer.js.map +2 -2
  26. package/dist/router.d.ts +20 -19
  27. package/dist/router.js +14 -6
  28. package/dist/router.js.map +2 -2
  29. package/dist/ssr.js +21 -4
  30. package/dist/ssr.js.map +2 -2
  31. package/dist/use-html.js +5 -1
  32. package/dist/use-html.js.map +2 -2
  33. package/dist/{as-is/useRouter.js → use-router.js} +1 -1
  34. package/dist/{as-is/useRouter.js.map → use-router.js.map} +2 -2
  35. package/package.json +1 -1
  36. package/dist/as-is/Link.js.map +0 -7
  37. package/dist/as-is/Link.tsx +0 -20
  38. package/dist/as-is/useRouter.ts +0 -33
  39. /package/dist/{as-is/Link.d.ts → Link.d.ts} +0 -0
  40. /package/dist/{as-is/useRouter.d.ts → use-router.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/component-analyzer.ts"],
4
- "sourcesContent": ["/**\n * component-analyzer.ts \u2014 Static Import Analyzer & Client Component Registry\n *\n * This module solves a core problem in NukeJS's partial hydration model:\n * the server needs to know *at render time* which components in a page's\n * import tree are \"use client\" boundaries so it can:\n *\n * 1. Emit <span data-hydrate-id=\"\u2026\"> markers instead of rendering them.\n * 2. Inject the matching bundle URLs into the page's runtime data blob.\n * 3. Serialize the props passed to those components so the browser can\n * reconstruct them after loading the bundle.\n *\n * How it works:\n * - analyzeComponent() checks whether a file starts with \"use client\"\n * and assigns a stable content-hash ID if it does.\n * - extractImports() parses `import \u2026 from '\u2026'` statements with a\n * regex and resolves relative/absolute paths.\n * - findClientComponentsInTree() recursively walks the import graph, stopping\n * at client boundaries (they own their subtree).\n *\n * Results are memoised in `componentCache` (process-lifetime) so repeated SSR\n * renders don't re-read and re-hash files they've already seen.\n *\n * ID scheme:\n * The ID for a client component is `cc_` + the first 8 hex chars of the MD5\n * hash of its path relative to pagesDir. This is stable across restarts and\n * matches what the browser will request from /__client-component/<id>.js.\n */\n\nimport path from 'path';\nimport fs from 'fs';\nimport { createHash } from 'node:crypto';\nimport { fileURLToPath } from 'url';\n\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ComponentInfo {\n filePath: string;\n /** True when the file's first non-comment line is \"use client\". */\n isClientComponent: boolean;\n /** Stable hash-based ID, present only for client components. */\n clientComponentId?: string;\n}\n\n// \u2500\u2500\u2500 In-process cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// Memoises analyze results for the lifetime of the dev server process.\n// In production builds the analysis runs once per build, so no cache is needed.\nconst componentCache = new Map<string, ComponentInfo>();\n\n// \u2500\u2500\u2500 Client boundary detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Returns true when a file begins with a `\"use client\"` or `'use client'`\n * directive (ignoring blank lines and line/block comment prefixes).\n *\n * Only the first five lines are checked \u2014 the directive must appear before\n * any executable code.\n */\nfunction isClientComponent(filePath: string): boolean {\n const content = fs.readFileSync(filePath, 'utf-8');\n for (const line of content.split('\\n').slice(0, 5)) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;\n if (/^[\"']use client[\"'];?$/.test(trimmed)) return true;\n break; // First substantive line is not \"use client\"\n }\n return false;\n}\n\n/**\n * Generates a deterministic, short ID for a client component.\n * The path is made relative to pagesDir before hashing so the ID is\n * portable across machines (absolute paths differ per developer).\n */\nfunction getClientComponentId(filePath: string, pagesDir: string): string {\n return 'cc_' + createHash('md5')\n .update(path.relative(pagesDir, filePath))\n .digest('hex')\n .substring(0, 8);\n}\n\n// \u2500\u2500\u2500 Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Analyses a component file and returns cached results on subsequent calls.\n *\n * @param filePath Absolute path to the source file.\n * @param pagesDir Absolute path to the pages root (used for ID generation).\n */\nexport function analyzeComponent(filePath: string, pagesDir: string): ComponentInfo {\n if (componentCache.has(filePath)) return componentCache.get(filePath)!;\n\n const isClient = isClientComponent(filePath);\n const info: ComponentInfo = {\n filePath,\n isClientComponent: isClient,\n clientComponentId: isClient ? getClientComponentId(filePath, pagesDir) : undefined,\n };\n\n componentCache.set(filePath, info);\n return info;\n}\n\n// \u2500\u2500\u2500 Import extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Parses `import \u2026 from '\u2026'` and `export \u2026 from '\u2026'` statements in a file\n * and returns a list of resolved absolute paths for all *local* imports.\n *\n * Non-local specifiers (npm packages) are skipped, except `nukejs` itself \u2014\n * which is resolved to our own index file so built-in \"use client\" components\n * like `<Link>` are included in the client component discovery walk.\n *\n * Extensions are tried in priority order if the specifier has none.\n */\nfunction extractImports(filePath: string): string[] {\n const content = fs.readFileSync(filePath, 'utf-8');\n const dir = path.dirname(filePath);\n const imports: string[] = [];\n\n const importRegex =\n /(?:import|export)\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]/g;\n let match: RegExpExecArray | null;\n\n while ((match = importRegex.exec(content)) !== null) {\n const spec = match[1];\n\n // Special case: resolve the 'nukejs' package to our own source so\n // built-in \"use client\" exports (Link, useRouter, etc.) are discovered.\n if (spec === 'nukejs') {\n const selfDir = path.dirname(fileURLToPath(import.meta.url));\n for (const candidate of [\n path.join(selfDir, 'index.ts'),\n path.join(selfDir, 'index.js'),\n ]) {\n if (fs.existsSync(candidate)) { imports.push(candidate); break; }\n }\n continue;\n }\n\n // Skip npm packages and other non-local specifiers.\n if (!spec.startsWith('.') && !spec.startsWith('/')) continue;\n\n // Resolve to an absolute path, trying extensions if needed.\n let resolved = path.resolve(dir, spec);\n const EXTS = ['.tsx', '.ts', '.jsx', '.js'] as const;\n const isFile = (p: string) => fs.existsSync(p) && fs.statSync(p).isFile();\n\n if (!isFile(resolved)) {\n let found = false;\n\n // 1. Try appending an extension (./Button \u2192 ./Button.tsx)\n for (const ext of EXTS) {\n if (isFile(resolved + ext)) { resolved += ext; found = true; break; }\n }\n\n // 2. Try an index file inside the directory (./components \u2192 ./components/index.tsx)\n if (!found && fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n for (const ext of EXTS) {\n const candidate = path.join(resolved, `index${ext}`);\n if (isFile(candidate)) { resolved = candidate; found = true; break; }\n }\n }\n\n if (!found) continue; // Unresolvable \u2014 skip silently\n }\n\n imports.push(resolved);\n }\n\n return imports;\n}\n\n// \u2500\u2500\u2500 Tree walk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Recursively walks the import graph from `filePath`, collecting every\n * \"use client\" file encountered.\n *\n * The walk stops at client boundaries: a \"use client\" file is recorded and\n * its own imports are NOT walked (the client runtime handles their subtree).\n *\n * The `visited` set prevents infinite loops from circular imports.\n *\n * @returns Map<id, absoluteFilePath> for every client component reachable\n * from `filePath` (including `filePath` itself if it's a client).\n */\nexport function findClientComponentsInTree(\n filePath: string,\n pagesDir: string,\n visited = new Set<string>(),\n): Map<string, string> {\n const found = new Map<string, string>();\n if (visited.has(filePath)) return found;\n visited.add(filePath);\n\n const info = analyzeComponent(filePath, pagesDir);\n\n if (info.isClientComponent && info.clientComponentId) {\n found.set(info.clientComponentId, filePath);\n return found; // Stop \u2014 client boundary owns its subtree\n }\n\n for (const importPath of extractImports(filePath)) {\n for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {\n found.set(id, p);\n }\n }\n\n return found;\n}\n\n// \u2500\u2500\u2500 Cache access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Looks up the absolute file path for a client component by its ID.\n * Returns undefined when the ID is not in the cache.\n */\nexport function getComponentById(id: string): string | undefined {\n for (const [filePath, info] of componentCache) {\n if (info.clientComponentId === id) return filePath;\n }\n return undefined;\n}\n\n/** Returns the live component cache (used by bundler.ts for ID\u2192path lookup). */\nexport function getComponentCache(): Map<string, ComponentInfo> {\n return componentCache;\n}\n\n/**\n * Removes a single file's analysis entry from the cache.\n * Call this whenever a source file changes in dev mode so the next render\n * re-analyses the file (picks up added/removed \"use client\" directives and\n * changed import graphs).\n */\nexport function invalidateComponentCache(filePath: string): void {\n componentCache.delete(filePath);\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * component-analyzer.ts \u2014 Static Import Analyzer & Client Component Registry\r\n *\r\n * This module solves a core problem in NukeJS's partial hydration model:\r\n * the server needs to know *at render time* which components in a page's\r\n * import tree are \"use client\" boundaries so it can:\r\n *\r\n * 1. Emit <span data-hydrate-id=\"\u2026\"> markers instead of rendering them.\r\n * 2. Inject the matching bundle URLs into the page's runtime data blob.\r\n * 3. Serialize the props passed to those components so the browser can\r\n * reconstruct them after loading the bundle.\r\n *\r\n * How it works:\r\n * - analyzeComponent() checks whether a file starts with \"use client\"\r\n * and assigns a stable content-hash ID if it does.\r\n * - extractImports() parses `import \u2026 from '\u2026'` statements with a\r\n * regex and resolves relative/absolute paths.\r\n * - findClientComponentsInTree() recursively walks the import graph, stopping\r\n * at client boundaries (they own their subtree).\r\n *\r\n * Results are memoised in `componentCache` (process-lifetime) so repeated SSR\r\n * renders don't re-read and re-hash files they've already seen.\r\n *\r\n * ID scheme:\r\n * The ID for a client component is `cc_` + the first 8 hex chars of the MD5\r\n * hash of its path relative to pagesDir. This is stable across restarts and\r\n * matches what the browser will request from /__client-component/<id>.js.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\nimport { createHash } from 'node:crypto';\r\nimport { fileURLToPath } from 'url';\r\n\r\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface ComponentInfo {\r\n filePath: string;\r\n /** True when the file's first non-comment line is \"use client\". */\r\n isClientComponent: boolean;\r\n /** Stable hash-based ID, present only for client components. */\r\n clientComponentId?: string;\r\n /**\r\n * The name of the default-exported component function.\r\n * Handles both source format (`export default Link`) and esbuild's compiled\r\n * format (`var Link_default = Link; export { Link_default as default }`).\r\n */\r\n exportedName?: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 In-process cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n// Memoises analyze results for the lifetime of the dev server process.\r\n// In production builds the analysis runs once per build, so no cache is needed.\r\nconst componentCache = new Map<string, ComponentInfo>();\r\n\r\n// \u2500\u2500\u2500 Client boundary detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns true when a file begins with a `\"use client\"` or `'use client'`\r\n * directive (ignoring blank lines and line/block comment prefixes).\r\n *\r\n * Only the first five lines are checked \u2014 the directive must appear before\r\n * any executable code.\r\n */\r\nfunction isClientComponent(filePath: string): boolean {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n for (const line of content.split('\\n').slice(0, 5)) {\r\n const trimmed = line.trim();\r\n if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;\r\n if (/^[\"']use client[\"'];?$/.test(trimmed)) return true;\r\n break; // First substantive line is not \"use client\"\r\n }\r\n return false;\r\n}\r\n\r\n/**\r\n * Generates a deterministic, short ID for a client component.\r\n * The path is made relative to pagesDir before hashing so the ID is\r\n * portable across machines (absolute paths differ per developer).\r\n */\r\nfunction getClientComponentId(filePath: string, pagesDir: string): string {\r\n return 'cc_' + createHash('md5')\r\n .update(path.relative(pagesDir, filePath))\r\n .digest('hex')\r\n .substring(0, 8);\r\n}\r\n\r\n// \u2500\u2500\u2500 Default export name extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Extracts the name of the default-exported function from a component file.\r\n *\r\n * Handles three formats:\r\n * 1. Source: `export default function Link(\u2026)` or `export default Link`\r\n * 2. esbuild: `var Link_default = Link;` (compiled arrow-function component)\r\n * 3. Re-export: `export { Link as default }`\r\n */\r\nfunction getExportedDefaultName(filePath: string): string | undefined {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n\r\n // Format 1 \u2013 source files: `export default function Foo` or `export default Foo`\r\n let m = content.match(/export\\s+default\\s+(?:function\\s+)?(\\w+)/);\r\n if (m?.[1]) return m[1];\r\n\r\n // Format 2 \u2013 esbuild compiled arrow components: `var Foo_default = Foo`\r\n // esbuild renames the variable to `<name>_default` and keeps the original name.\r\n m = content.match(/var\\s+\\w+_default\\s*=\\s*(\\w+)/);\r\n if (m?.[1]) return m[1];\r\n\r\n // Format 3 \u2013 explicit re-export: `export { Foo as default }`\r\n m = content.match(/export\\s*\\{[^}]*\\b(\\w+)\\s+as\\s+default\\b[^}]*\\}/);\r\n if (m?.[1] && !m[1].endsWith('_default')) return m[1];\r\n\r\n return undefined;\r\n}\r\n\r\n// \u2500\u2500\u2500 Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Analyses a component file and returns cached results on subsequent calls.\r\n *\r\n * @param filePath Absolute path to the source file.\r\n * @param pagesDir Absolute path to the pages root (used for ID generation).\r\n */\r\nexport function analyzeComponent(filePath: string, pagesDir: string): ComponentInfo {\r\n if (componentCache.has(filePath)) return componentCache.get(filePath)!;\r\n\r\n const isClient = isClientComponent(filePath);\r\n const info: ComponentInfo = {\r\n filePath,\r\n isClientComponent: isClient,\r\n clientComponentId: isClient ? getClientComponentId(filePath, pagesDir) : undefined,\r\n exportedName: isClient ? getExportedDefaultName(filePath) : undefined,\r\n };\r\n\r\n componentCache.set(filePath, info);\r\n return info;\r\n}\r\n\r\n// \u2500\u2500\u2500 Import extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Parses `import \u2026 from '\u2026'` and `export \u2026 from '\u2026'` statements in a file\r\n * and returns a list of resolved absolute paths for all *local* imports.\r\n *\r\n * Non-local specifiers (npm packages) are skipped, except `nukejs` itself \u2014\r\n * which is resolved to our own index file so built-in \"use client\" components\r\n * like `<Link>` are included in the client component discovery walk.\r\n *\r\n * Extensions are tried in priority order if the specifier has none.\r\n */\r\nfunction extractImports(filePath: string): string[] {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n const dir = path.dirname(filePath);\r\n const imports: string[] = [];\r\n\r\n const importRegex =\r\n /(?:import|export)\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = importRegex.exec(content)) !== null) {\r\n const spec = match[1];\r\n\r\n // Special case: resolve the 'nukejs' package to our own source so\r\n // built-in \"use client\" exports (Link, useRouter, etc.) are discovered.\r\n if (spec === 'nukejs') {\r\n const selfDir = path.dirname(fileURLToPath(import.meta.url));\r\n for (const candidate of [\r\n path.join(selfDir, 'index.ts'),\r\n path.join(selfDir, 'index.js'),\r\n ]) {\r\n if (fs.existsSync(candidate)) { imports.push(candidate); break; }\r\n }\r\n continue;\r\n }\r\n\r\n // Skip npm packages and other non-local specifiers.\r\n if (!spec.startsWith('.') && !spec.startsWith('/')) continue;\r\n\r\n // Resolve to an absolute path, trying extensions if needed.\r\n let resolved = path.resolve(dir, spec);\r\n const EXTS = ['.tsx', '.ts', '.jsx', '.js'] as const;\r\n const isFile = (p: string) => fs.existsSync(p) && fs.statSync(p).isFile();\r\n\r\n if (!isFile(resolved)) {\r\n let found = false;\r\n\r\n // 1. Try appending an extension (./Button \u2192 ./Button.tsx)\r\n for (const ext of EXTS) {\r\n if (isFile(resolved + ext)) { resolved += ext; found = true; break; }\r\n }\r\n\r\n // 2. Try an index file inside the directory (./components \u2192 ./components/index.tsx)\r\n if (!found && fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\r\n for (const ext of EXTS) {\r\n const candidate = path.join(resolved, `index${ext}`);\r\n if (isFile(candidate)) { resolved = candidate; found = true; break; }\r\n }\r\n }\r\n\r\n if (!found) continue; // Unresolvable \u2014 skip silently\r\n }\r\n\r\n imports.push(resolved);\r\n }\r\n\r\n return imports;\r\n}\r\n\r\n// \u2500\u2500\u2500 Tree walk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively walks the import graph from `filePath`, collecting every\r\n * \"use client\" file encountered.\r\n *\r\n * The walk stops at client boundaries: a \"use client\" file is recorded and\r\n * its own imports are NOT walked (the client runtime handles their subtree).\r\n *\r\n * The `visited` set prevents infinite loops from circular imports.\r\n *\r\n * @returns Map<id, absoluteFilePath> for every client component reachable\r\n * from `filePath` (including `filePath` itself if it's a client).\r\n */\r\nexport function findClientComponentsInTree(\r\n filePath: string,\r\n pagesDir: string,\r\n visited = new Set<string>(),\r\n): Map<string, string> {\r\n const found = new Map<string, string>();\r\n if (visited.has(filePath)) return found;\r\n visited.add(filePath);\r\n\r\n const info = analyzeComponent(filePath, pagesDir);\r\n\r\n if (info.isClientComponent && info.clientComponentId) {\r\n found.set(info.clientComponentId, filePath);\r\n return found; // Stop \u2014 client boundary owns its subtree\r\n }\r\n\r\n for (const importPath of extractImports(filePath)) {\r\n for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {\r\n found.set(id, p);\r\n }\r\n }\r\n\r\n return found;\r\n}\r\n\r\n// \u2500\u2500\u2500 Cache access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Looks up the absolute file path for a client component by its ID.\r\n * Returns undefined when the ID is not in the cache.\r\n */\r\nexport function getComponentById(id: string): string | undefined {\r\n for (const [filePath, info] of componentCache) {\r\n if (info.clientComponentId === id) return filePath;\r\n }\r\n return undefined;\r\n}\r\n\r\n/** Returns the live component cache (used by bundler.ts for ID\u2192path lookup). */\r\nexport function getComponentCache(): Map<string, ComponentInfo> {\r\n return componentCache;\r\n}\r\n\r\n/**\r\n * Removes a single file's analysis entry from the cache.\r\n * Call this whenever a source file changes in dev mode so the next render\r\n * re-analyses the file (picks up added/removed \"use client\" directives and\r\n * changed import graphs).\r\n */\r\nexport function invalidateComponentCache(filePath: string): void {\r\n componentCache.delete(filePath);\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -22,15 +22,15 @@ function hmr() {
22
22
  reloadStylesheets();
23
23
  return;
24
24
  }
25
- if (msg.url === window.location.pathname) {
25
+ if (patternMatchesPathname(msg.url, window.location.pathname)) {
26
26
  log.info("[HMR] Page changed:", msg.url);
27
- navigate(window.location.pathname);
27
+ navigate(window.location.pathname + window.location.search);
28
28
  }
29
29
  return;
30
30
  }
31
31
  if (msg.type === "replace") {
32
32
  log.info("[HMR] Component changed:", msg.component);
33
- navigate(window.location.pathname);
33
+ navigate(window.location.pathname + window.location.search);
34
34
  return;
35
35
  }
36
36
  } catch (err) {
@@ -41,7 +41,20 @@ function hmr() {
41
41
  function navigate(href) {
42
42
  window.dispatchEvent(new CustomEvent("locationchange", { detail: { href, hmr: true } }));
43
43
  }
44
- function waitForReconnect(intervalMs = 500, maxAttempts = 30) {
44
+ function patternMatchesPathname(pattern, pathname) {
45
+ const normPattern = pattern.length > 1 ? pattern.replace(/\/+$/, "") : pattern;
46
+ const normPathname = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
47
+ const segments = normPattern.replace(/^\//, "").split("/");
48
+ const regexParts = segments.map((seg) => {
49
+ if (/^\[\[\.\.\..+\]\]$/.test(seg)) return "(?:/.*)?";
50
+ if (/^\[\.\.\./.test(seg)) return "(?:/.+)";
51
+ if (/^\[\[/.test(seg)) return "(?:/[^/]*)?";
52
+ if (/^\[/.test(seg)) return "/[^/]+";
53
+ return "/" + seg.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
54
+ });
55
+ return new RegExp("^" + regexParts.join("") + "$").test(normPathname);
56
+ }
57
+ function waitForReconnect(intervalMs = 3e3, maxAttempts = 10) {
45
58
  let attempts = 0;
46
59
  const id = setInterval(async () => {
47
60
  attempts++;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/hmr-bundle.ts"],
4
- "sourcesContent": ["/**\r\n * hmr-bundle.ts \u2014 HMR Client Script\r\n *\r\n * This file is compiled on-demand by middleware.ts and served to the browser\r\n * as /__hmr.js (injected into every dev-mode page as a module script).\r\n *\r\n * It opens an EventSource connection to /__hmr and reacts to three message\r\n * types from the server:\r\n *\r\n * 'reload' \u2014 A page or stylesheet changed.\r\n * url === '*' \u2192 reload stylesheets in-place (no flicker)\r\n * url === window.location.pathname \u2192 soft-navigate the current page\r\n *\r\n * 'replace' \u2014 A component/utility changed.\r\n * Re-navigate the current page so SSR picks up the new code.\r\n *\r\n * 'restart' \u2014 The server is restarting (config or middleware changed).\r\n * Close the SSE connection and poll /__hmr_ping until the\r\n * server is back, then hard-reload the page.\r\n *\r\n * The same reconnect polling is used when the SSE connection drops unexpectedly\r\n * (e.g. the dev server crashed).\r\n */\r\n\r\nimport { log } from './logger';\r\n\r\n// \u2500\u2500\u2500 Entry point \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Opens the SSE connection and starts listening for HMR events. */\r\nexport default function hmr(): void {\r\n const es = new EventSource('/__hmr');\r\n\r\n es.onopen = () => {\r\n log.info('[HMR] Connected');\r\n };\r\n\r\n es.onerror = () => {\r\n // Connection dropped without a restart message (e.g. crash or network\r\n // blip). Close cleanly and poll until the server is back.\r\n es.close();\r\n waitForReconnect();\r\n };\r\n\r\n es.onmessage = async (event) => {\r\n try {\r\n const msg = JSON.parse(event.data);\r\n\r\n if (msg.type === 'restart') {\r\n log.info('[HMR] Server restarting \u2014 waiting to reconnect...');\r\n es.close();\r\n waitForReconnect();\r\n return;\r\n }\r\n\r\n if (msg.type === 'reload') {\r\n if (msg.url === '*') {\r\n // CSS / global style change \u2014 bust stylesheet hrefs in-place.\r\n // This avoids a full page reload and its associated FOUC.\r\n reloadStylesheets();\r\n return;\r\n }\r\n // A specific page changed \u2014 only navigate if we're on that page.\r\n if (msg.url === window.location.pathname) {\r\n log.info('[HMR] Page changed:', msg.url);\r\n navigate(window.location.pathname);\r\n }\r\n return;\r\n }\r\n\r\n if (msg.type === 'replace') {\r\n // A shared component or utility changed. The current page might use\r\n // it, so we re-navigate to pick up the latest server render.\r\n log.info('[HMR] Component changed:', msg.component);\r\n navigate(window.location.pathname);\r\n return;\r\n }\r\n } catch (err) {\r\n log.error('[HMR] Message parse error:', err);\r\n }\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Soft navigation helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Triggers a soft (SPA-style) navigation via the locationchange event that\r\n * bundle.ts listens to. Adds `hmr: true` in the detail so the navigation\r\n * handler appends `?__hmr=1`, which tells SSR to skip client-component\r\n * renderToString (faster HMR round-trips).\r\n */\r\nfunction navigate(href: string): void {\r\n window.dispatchEvent(new CustomEvent('locationchange', { detail: { href, hmr: true } }));\r\n}\r\n\r\n// \u2500\u2500\u2500 Reconnect polling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Polls /__hmr_ping at `intervalMs` until the server responds with a 200\r\n * (meaning it's back up), then triggers a full page reload to pick up any\r\n * changes that happened during the downtime.\r\n *\r\n * Gives up after `maxAttempts` (default ~15 seconds at 500 ms intervals).\r\n */\r\nfunction waitForReconnect(intervalMs = 500, maxAttempts = 30): void {\r\n let attempts = 0;\r\n\r\n const id = setInterval(async () => {\r\n attempts++;\r\n try {\r\n const res = await fetch('/__hmr_ping', { cache: 'no-store' });\r\n if (res.ok) {\r\n clearInterval(id);\r\n log.info('[HMR] Server back \u2014 reloading');\r\n window.location.reload();\r\n }\r\n } catch {\r\n // Server still down \u2014 keep polling silently.\r\n }\r\n\r\n if (attempts >= maxAttempts) {\r\n clearInterval(id);\r\n log.error('[HMR] Server did not come back after restart');\r\n }\r\n }, intervalMs);\r\n}\r\n\r\n// \u2500\u2500\u2500 Stylesheet cache-buster \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Appends a `?t=<timestamp>` query to every `<link rel=\"stylesheet\">` href.\r\n * The browser treats the new URL as a different resource and re-fetches it,\r\n * updating styles without a page reload or visible flash.\r\n */\r\nfunction reloadStylesheets(): void {\r\n const links = document.querySelectorAll<HTMLLinkElement>('link[rel=\"stylesheet\"]');\r\n log.info(`[HMR] CSS changed \u2014 reloading ${links.length} stylesheet(s)`);\r\n links.forEach(link => {\r\n const url = new URL(link.href);\r\n url.searchParams.set('t', String(Date.now()));\r\n link.href = url.toString();\r\n });\r\n}\r\n\r\n// Auto-start when this module is loaded.\r\nhmr();\r\n"],
5
- "mappings": "AAwBA,SAAS,WAAW;AAKL,SAAR,MAA6B;AAClC,QAAM,KAAK,IAAI,YAAY,QAAQ;AAEnC,KAAG,SAAS,MAAM;AAChB,QAAI,KAAK,iBAAiB;AAAA,EAC5B;AAEA,KAAG,UAAU,MAAM;AAGjB,OAAG,MAAM;AACT,qBAAiB;AAAA,EACnB;AAEA,KAAG,YAAY,OAAO,UAAU;AAC9B,QAAI;AACF,YAAM,MAAM,KAAK,MAAM,MAAM,IAAI;AAEjC,UAAI,IAAI,SAAS,WAAW;AAC1B,YAAI,KAAK,wDAAmD;AAC5D,WAAG,MAAM;AACT,yBAAiB;AACjB;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,UAAU;AACzB,YAAI,IAAI,QAAQ,KAAK;AAGnB,4BAAkB;AAClB;AAAA,QACF;AAEA,YAAI,IAAI,QAAQ,OAAO,SAAS,UAAU;AACxC,cAAI,KAAK,uBAAuB,IAAI,GAAG;AACvC,mBAAS,OAAO,SAAS,QAAQ;AAAA,QACnC;AACA;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,WAAW;AAG1B,YAAI,KAAK,4BAA4B,IAAI,SAAS;AAClD,iBAAS,OAAO,SAAS,QAAQ;AACjC;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,MAAM,8BAA8B,GAAG;AAAA,IAC7C;AAAA,EACF;AACF;AAUA,SAAS,SAAS,MAAoB;AACpC,SAAO,cAAc,IAAI,YAAY,kBAAkB,EAAE,QAAQ,EAAE,MAAM,KAAK,KAAK,EAAE,CAAC,CAAC;AACzF;AAWA,SAAS,iBAAiB,aAAa,KAAK,cAAc,IAAU;AAClE,MAAI,WAAW;AAEf,QAAM,KAAK,YAAY,YAAY;AACjC;AACA,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,eAAe,EAAE,OAAO,WAAW,CAAC;AAC5D,UAAI,IAAI,IAAI;AACV,sBAAc,EAAE;AAChB,YAAI,KAAK,oCAA+B;AACxC,eAAO,SAAS,OAAO;AAAA,MACzB;AAAA,IACF,QAAQ;AAAA,IAER;AAEA,QAAI,YAAY,aAAa;AAC3B,oBAAc,EAAE;AAChB,UAAI,MAAM,8CAA8C;AAAA,IAC1D;AAAA,EACF,GAAG,UAAU;AACf;AASA,SAAS,oBAA0B;AACjC,QAAM,QAAQ,SAAS,iBAAkC,wBAAwB;AACjF,MAAI,KAAK,sCAAiC,MAAM,MAAM,gBAAgB;AACtE,QAAM,QAAQ,UAAQ;AACpB,UAAM,MAAM,IAAI,IAAI,KAAK,IAAI;AAC7B,QAAI,aAAa,IAAI,KAAK,OAAO,KAAK,IAAI,CAAC,CAAC;AAC5C,SAAK,OAAO,IAAI,SAAS;AAAA,EAC3B,CAAC;AACH;AAGA,IAAI;",
4
+ "sourcesContent": ["/**\r\n * hmr-bundle.ts \u2014 HMR Client Script\r\n *\r\n * This file is compiled on-demand by middleware.ts and served to the browser\r\n * as /__hmr.js (injected into every dev-mode page as a module script).\r\n *\r\n * It opens an EventSource connection to /__hmr and reacts to three message\r\n * types from the server:\r\n *\r\n * 'reload' \u2014 A page or stylesheet changed.\r\n * url === '*' \u2192 reload stylesheets in-place (no flicker)\r\n * url === window.location.pathname \u2192 soft-navigate the current page\r\n *\r\n * 'replace' \u2014 A component/utility changed.\r\n * Re-navigate the current page so SSR picks up the new code.\r\n *\r\n * 'restart' \u2014 The server is restarting (config or middleware changed).\r\n * Close the SSE connection and poll /__hmr_ping until the\r\n * server is back, then hard-reload the page.\r\n *\r\n * The same reconnect polling is used when the SSE connection drops unexpectedly\r\n * (e.g. the dev server crashed).\r\n */\r\n\r\nimport { log } from './logger';\r\n\r\n// \u2500\u2500\u2500 Entry point \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Opens the SSE connection and starts listening for HMR events. */\r\nexport default function hmr(): void {\r\n const es = new EventSource('/__hmr');\r\n\r\n es.onopen = () => {\r\n log.info('[HMR] Connected');\r\n };\r\n\r\n es.onerror = () => {\r\n // Connection dropped without a restart message (e.g. crash or network\r\n // blip). Close cleanly and poll until the server is back.\r\n es.close();\r\n waitForReconnect();\r\n };\r\n\r\n es.onmessage = async (event) => {\r\n try {\r\n const msg = JSON.parse(event.data);\r\n\r\n if (msg.type === 'restart') {\r\n log.info('[HMR] Server restarting \u2014 waiting to reconnect...');\r\n es.close();\r\n waitForReconnect();\r\n return;\r\n }\r\n\r\n if (msg.type === 'reload') {\r\n if (msg.url === '*') {\r\n // CSS / global style change \u2014 bust stylesheet hrefs in-place.\r\n // This avoids a full page reload and its associated FOUC.\r\n reloadStylesheets();\r\n return;\r\n }\r\n // A specific page changed \u2014 only navigate if we're on that page.\r\n if (patternMatchesPathname(msg.url, window.location.pathname)) {\r\n log.info('[HMR] Page changed:', msg.url);\r\n navigate(window.location.pathname + window.location.search);\r\n }\r\n return;\r\n }\r\n\r\n if (msg.type === 'replace') {\r\n // A shared component or utility changed. The current page might use\r\n // it, so we re-navigate to pick up the latest server render.\r\n log.info('[HMR] Component changed:', msg.component);\r\n navigate(window.location.pathname + window.location.search);\r\n return;\r\n }\r\n } catch (err) {\r\n log.error('[HMR] Message parse error:', err);\r\n }\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Soft navigation helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Triggers a soft (SPA-style) navigation via the locationchange event that\r\n * bundle.ts listens to. Adds `hmr: true` in the detail so the navigation\r\n * handler appends `?__hmr=1`, which tells SSR to skip client-component\r\n * renderToString (faster HMR round-trips).\r\n */\r\nfunction navigate(href: string): void {\r\n window.dispatchEvent(new CustomEvent('locationchange', { detail: { href, hmr: true } }));\r\n}\r\n\r\n// \u2500\u2500\u2500 Dynamic route pattern matching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns true when `pathname` matches the route `pattern` emitted by the\r\n * server. Patterns use the file-system conventions:\r\n * [param] \u2192 any single non-slash segment\r\n * [...slug] \u2192 one or more segments\r\n * [[...slug]] \u2192 zero or more segments\r\n * [[param]] \u2192 zero or one segment\r\n *\r\n * Each segment is classified before any escaping so that bracket characters\r\n * in param names are never mistaken for regex metacharacters.\r\n */\r\nfunction patternMatchesPathname(pattern: string, pathname: string): boolean {\r\n // Normalise trailing slashes so /a/ matches pattern /a and vice versa.\r\n const normPattern = pattern.length > 1 ? pattern.replace(/\\/+$/, '') : pattern;\r\n const normPathname = pathname.length > 1 ? pathname.replace(/\\/+$/, '') : pathname;\r\n const segments = normPattern.replace(/^\\//, '').split('/');\r\n const regexParts = segments.map(seg => {\r\n if (/^\\[\\[\\.\\.\\..+\\]\\]$/.test(seg)) return '(?:\\/.*)?' ; // [[...x]] optional catch-all\r\n if (/^\\[\\.\\.\\./.test(seg)) return '(?:\\/.+)' ; // [...x] required catch-all\r\n if (/^\\[\\[/.test(seg)) return '(?:\\/[^/]*)?' ;// [[x]] optional single\r\n if (/^\\[/.test(seg)) return '\\/[^/]+' ; // [x] required single\r\n return '\\/' + seg.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&'); // static \u2014 escape metacharacters\r\n });\r\n return new RegExp('^' + regexParts.join('') + '$').test(normPathname);\r\n}\r\n\r\n// \u2500\u2500\u2500 Reconnect polling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Polls /__hmr_ping at `intervalMs` until the server responds with a 200\r\n * (meaning it's back up), then triggers a full page reload to pick up any\r\n * changes that happened during the downtime.\r\n *\r\n * Gives up after `maxAttempts` (default ~15 seconds at 500 ms intervals).\r\n */\r\nfunction waitForReconnect(intervalMs = 3000, maxAttempts = 10): void {\r\n let attempts = 0;\r\n\r\n const id = setInterval(async () => {\r\n attempts++;\r\n try {\r\n const res = await fetch('/__hmr_ping', { cache: 'no-store' });\r\n if (res.ok) {\r\n clearInterval(id);\r\n log.info('[HMR] Server back \u2014 reloading');\r\n window.location.reload();\r\n }\r\n } catch {\r\n // Server still down \u2014 keep polling silently.\r\n }\r\n\r\n if (attempts >= maxAttempts) {\r\n clearInterval(id);\r\n log.error('[HMR] Server did not come back after restart');\r\n }\r\n }, intervalMs);\r\n}\r\n\r\n// \u2500\u2500\u2500 Stylesheet cache-buster \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Appends a `?t=<timestamp>` query to every `<link rel=\"stylesheet\">` href.\r\n * The browser treats the new URL as a different resource and re-fetches it,\r\n * updating styles without a page reload or visible flash.\r\n */\r\nfunction reloadStylesheets(): void {\r\n const links = document.querySelectorAll<HTMLLinkElement>('link[rel=\"stylesheet\"]');\r\n log.info(`[HMR] CSS changed \u2014 reloading ${links.length} stylesheet(s)`);\r\n links.forEach(link => {\r\n const url = new URL(link.href);\r\n url.searchParams.set('t', String(Date.now()));\r\n link.href = url.toString();\r\n });\r\n}\r\n\r\n// Auto-start when this module is loaded.\r\nhmr();"],
5
+ "mappings": "AAwBA,SAAS,WAAW;AAKL,SAAR,MAA6B;AAClC,QAAM,KAAK,IAAI,YAAY,QAAQ;AAEnC,KAAG,SAAS,MAAM;AAChB,QAAI,KAAK,iBAAiB;AAAA,EAC5B;AAEA,KAAG,UAAU,MAAM;AAGjB,OAAG,MAAM;AACT,qBAAiB;AAAA,EACnB;AAEA,KAAG,YAAY,OAAO,UAAU;AAC9B,QAAI;AACF,YAAM,MAAM,KAAK,MAAM,MAAM,IAAI;AAEjC,UAAI,IAAI,SAAS,WAAW;AAC1B,YAAI,KAAK,wDAAmD;AAC5D,WAAG,MAAM;AACT,yBAAiB;AACjB;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,UAAU;AACzB,YAAI,IAAI,QAAQ,KAAK;AAGnB,4BAAkB;AAClB;AAAA,QACF;AAEA,YAAI,uBAAuB,IAAI,KAAK,OAAO,SAAS,QAAQ,GAAG;AAC7D,cAAI,KAAK,uBAAuB,IAAI,GAAG;AACvC,mBAAS,OAAO,SAAS,WAAW,OAAO,SAAS,MAAM;AAAA,QAC5D;AACA;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,WAAW;AAG1B,YAAI,KAAK,4BAA4B,IAAI,SAAS;AAClD,iBAAS,OAAO,SAAS,WAAW,OAAO,SAAS,MAAM;AAC1D;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,MAAM,8BAA8B,GAAG;AAAA,IAC7C;AAAA,EACF;AACF;AAUA,SAAS,SAAS,MAAoB;AACpC,SAAO,cAAc,IAAI,YAAY,kBAAkB,EAAE,QAAQ,EAAE,MAAM,KAAK,KAAK,EAAE,CAAC,CAAC;AACzF;AAeA,SAAS,uBAAuB,SAAiB,UAA2B;AAE1E,QAAM,cAAe,QAAQ,SAAU,IAAI,QAAQ,QAAQ,QAAQ,EAAE,IAAK;AAC1E,QAAM,eAAe,SAAS,SAAS,IAAI,SAAS,QAAQ,QAAQ,EAAE,IAAI;AAC1E,QAAM,WAAa,YAAY,QAAQ,OAAO,EAAE,EAAE,MAAM,GAAG;AAC3D,QAAM,aAAa,SAAS,IAAI,SAAO;AACrC,QAAI,qBAAqB,KAAK,GAAG,EAAG,QAAO;AAC3C,QAAI,YAAY,KAAK,GAAG,EAAc,QAAO;AAC7C,QAAI,QAAQ,KAAK,GAAG,EAAoB,QAAO;AAC/C,QAAI,MAAM,KAAK,GAAG,EAAsB,QAAO;AAC/C,WAAO,MAAO,IAAI,QAAQ,sBAAsB,MAAM;AAAA,EACxD,CAAC;AACD,SAAO,IAAI,OAAO,MAAM,WAAW,KAAK,EAAE,IAAI,GAAG,EAAE,KAAK,YAAY;AACtE;AAWA,SAAS,iBAAiB,aAAa,KAAM,cAAc,IAAU;AACnE,MAAI,WAAW;AAEf,QAAM,KAAK,YAAY,YAAY;AACjC;AACA,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,eAAe,EAAE,OAAO,WAAW,CAAC;AAC5D,UAAI,IAAI,IAAI;AACV,sBAAc,EAAE;AAChB,YAAI,KAAK,oCAA+B;AACxC,eAAO,SAAS,OAAO;AAAA,MACzB;AAAA,IACF,QAAQ;AAAA,IAER;AAEA,QAAI,YAAY,aAAa;AAC3B,oBAAc,EAAE;AAChB,UAAI,MAAM,8CAA8C;AAAA,IAC1D;AAAA,EACF,GAAG,UAAU;AACf;AASA,SAAS,oBAA0B;AACjC,QAAM,QAAQ,SAAS,iBAAkC,wBAAwB;AACjF,MAAI,KAAK,sCAAiC,MAAM,MAAM,gBAAgB;AACtE,QAAM,QAAQ,UAAQ;AACpB,UAAM,MAAM,IAAI,IAAI,KAAK,IAAI;AAC7B,QAAI,aAAa,IAAI,KAAK,OAAO,KAAK,IAAI,CAAC,CAAC;AAC5C,SAAK,OAAO,IAAI,SAAS;AAAA,EAC3B,CAAC;AACH;AAGA,IAAI;",
6
6
  "names": []
7
7
  }
@@ -68,6 +68,13 @@ export interface ScriptTag {
68
68
  crossOrigin?: string;
69
69
  integrity?: string;
70
70
  noModule?: boolean;
71
+ /**
72
+ * Where to inject the script in the document.
73
+ * 'head' (default) — placed inside <head>, inside the <!--n-head--> block.
74
+ * 'body' — placed at the very end of <body>, inside the
75
+ * <!--n-body-scripts--> block, just before </body>.
76
+ */
77
+ position?: 'head' | 'body';
71
78
  }
72
79
  export interface StyleTag {
73
80
  content?: string;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/html-store.ts"],
4
- "sourcesContent": ["/**\r\n * html-store.ts \u2014 Per-Request HTML Head Store\r\n *\r\n * Provides a request-scoped store that server components can write to via\r\n * `useHtml()` during SSR. The accumulated values are flushed into the\r\n * rendered HTML document after the component tree is fully rendered.\r\n *\r\n * Why globalThis?\r\n * Node's module system may import this file multiple times if the page\r\n * module and the nukejs package resolve to different copies (e.g. when\r\n * running from source in dev with tsx). Using a well-known Symbol on\r\n * globalThis guarantees all copies share the same store instance.\r\n *\r\n * Request isolation:\r\n * runWithHtmlStore() creates a fresh store before rendering and clears it\r\n * in the `finally` block, so concurrent requests cannot bleed into each other.\r\n *\r\n * Title resolution:\r\n * Layouts and pages can both call useHtml({ title: \u2026 }). Layouts typically\r\n * pass a template function:\r\n *\r\n * useHtml({ title: (prev) => `${prev} | Acme` })\r\n *\r\n * Operations are collected in render order (outermost layout first, page\r\n * last) then resolved *in reverse* so the page's string value is the base\r\n * and layout template functions wrap outward.\r\n */\r\n\r\n// \u2500\u2500\u2500 Public types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** A page sets a literal string; a layout wraps with a template function. */\r\nexport type TitleValue = string | ((prev: string) => string);\r\n\r\nexport interface HtmlAttrs {\r\n lang?: string;\r\n class?: string;\r\n style?: string;\r\n dir?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface BodyAttrs {\r\n class?: string;\r\n style?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface MetaTag {\r\n name?: string;\r\n property?: string;\r\n httpEquiv?: string;\r\n charset?: string;\r\n content?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface LinkTag {\r\n rel?: string;\r\n href?: string;\r\n type?: string;\r\n media?: string;\r\n as?: string;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n hrefLang?: string;\r\n sizes?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface ScriptTag {\r\n src?: string;\r\n content?: string;\r\n type?: string;\r\n defer?: boolean;\r\n async?: boolean;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n noModule?: boolean;\r\n}\r\n\r\nexport interface StyleTag {\r\n content?: string;\r\n media?: string;\r\n}\r\n\r\nexport interface HtmlStore {\r\n /** Collected in render order; resolved in reverse so the page title wins. */\r\n titleOps: TitleValue[];\r\n /** Attributes merged onto <html>; last write wins per attribute. */\r\n htmlAttrs: HtmlAttrs;\r\n /** Attributes merged onto <body>; last write wins per attribute. */\r\n bodyAttrs: BodyAttrs;\r\n /** Accumulated in render order: layouts first, page last. */\r\n meta: MetaTag[];\r\n link: LinkTag[];\r\n script: ScriptTag[];\r\n style: StyleTag[];\r\n}\r\n\r\n// \u2500\u2500\u2500 GlobalThis storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Well-known Symbol used to share the store across duplicate module copies. */\r\nconst KEY = Symbol.for('__nukejs_html_store__');\r\n\r\nconst getGlobal = (): HtmlStore | null => (globalThis as any)[KEY] ?? null;\r\nconst setGlobal = (store: HtmlStore | null): void => { (globalThis as any)[KEY] = store; };\r\n\r\nfunction emptyStore(): HtmlStore {\r\n return {\r\n titleOps: [],\r\n htmlAttrs: {},\r\n bodyAttrs: {},\r\n meta: [],\r\n link: [],\r\n script: [],\r\n style: [],\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Runs `fn` inside a fresh HTML store and returns the collected values.\r\n *\r\n * Usage in SSR:\r\n * ```ts\r\n * const store = await runWithHtmlStore(async () => {\r\n * appHtml = await renderElementToHtml(element, ctx);\r\n * });\r\n * // store.titleOps, store.meta, etc. are now populated\r\n * ```\r\n */\r\nexport async function runWithHtmlStore(fn: () => Promise<void>): Promise<HtmlStore> {\r\n setGlobal(emptyStore());\r\n try {\r\n await fn();\r\n return { ...(getGlobal() ?? emptyStore()) } as HtmlStore;\r\n } finally {\r\n // Always clear the store, even if rendering throws, to prevent leakage\r\n // into the next request on the same event-loop tick.\r\n setGlobal(null);\r\n }\r\n}\r\n\r\n/**\r\n * Returns the current request's store, or `undefined` if called outside of\r\n * a `runWithHtmlStore` context (e.g. in the browser or in a test).\r\n */\r\nexport function getHtmlStore(): HtmlStore | undefined {\r\n return getGlobal() ?? undefined;\r\n}\r\n\r\n/**\r\n * Resolves the final page title from a list of title operations.\r\n *\r\n * Operations are walked in *reverse* so the page's value is the starting\r\n * point and layout template functions wrap it outward:\r\n *\r\n * ```\r\n * ops = [ (p) => `${p} | Acme`, 'About' ] \u2190 layout pushed first, page last\r\n * Walk in reverse:\r\n * i=1: op = 'About' \u2192 title = 'About'\r\n * i=0: op = (p) => \u2026 \u2192 title = 'About | Acme'\r\n * ```\r\n *\r\n * @param fallback Used when ops is empty (e.g. a page that didn't call useHtml).\r\n */\r\nexport function resolveTitle(ops: TitleValue[], fallback = ''): string {\r\n let title = fallback;\r\n for (let i = ops.length - 1; i >= 0; i--) {\r\n const op = ops[i];\r\n title = typeof op === 'string' ? op : op(title);\r\n }\r\n return title;\r\n}\r\n"],
5
- "mappings": "AAsGA,MAAM,MAAM,uBAAO,IAAI,uBAAuB;AAE9C,MAAM,YAAY,MAAyB,WAAmB,GAAG,KAAK;AACtE,MAAM,YAAY,CAAC,UAAkC;AAAE,EAAC,WAAmB,GAAG,IAAI;AAAO;AAEzF,SAAS,aAAwB;AAC/B,SAAO;AAAA,IACL,UAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,QAAW,CAAC;AAAA,IACZ,OAAW,CAAC;AAAA,EACd;AACF;AAeA,eAAsB,iBAAiB,IAA6C;AAClF,YAAU,WAAW,CAAC;AACtB,MAAI;AACF,UAAM,GAAG;AACT,WAAO,EAAE,GAAI,UAAU,KAAK,WAAW,EAAG;AAAA,EAC5C,UAAE;AAGA,cAAU,IAAI;AAAA,EAChB;AACF;AAMO,SAAS,eAAsC;AACpD,SAAO,UAAU,KAAK;AACxB;AAiBO,SAAS,aAAa,KAAmB,WAAW,IAAY;AACrE,MAAI,QAAQ;AACZ,WAAS,IAAI,IAAI,SAAS,GAAG,KAAK,GAAG,KAAK;AACxC,UAAM,KAAK,IAAI,CAAC;AAChB,YAAQ,OAAO,OAAO,WAAW,KAAK,GAAG,KAAK;AAAA,EAChD;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["/**\r\n * html-store.ts \u2014 Per-Request HTML Head Store\r\n *\r\n * Provides a request-scoped store that server components can write to via\r\n * `useHtml()` during SSR. The accumulated values are flushed into the\r\n * rendered HTML document after the component tree is fully rendered.\r\n *\r\n * Why globalThis?\r\n * Node's module system may import this file multiple times if the page\r\n * module and the nukejs package resolve to different copies (e.g. when\r\n * running from source in dev with tsx). Using a well-known Symbol on\r\n * globalThis guarantees all copies share the same store instance.\r\n *\r\n * Request isolation:\r\n * runWithHtmlStore() creates a fresh store before rendering and clears it\r\n * in the `finally` block, so concurrent requests cannot bleed into each other.\r\n *\r\n * Title resolution:\r\n * Layouts and pages can both call useHtml({ title: \u2026 }). Layouts typically\r\n * pass a template function:\r\n *\r\n * useHtml({ title: (prev) => `${prev} | Acme` })\r\n *\r\n * Operations are collected in render order (outermost layout first, page\r\n * last) then resolved *in reverse* so the page's string value is the base\r\n * and layout template functions wrap outward.\r\n */\r\n\r\n// \u2500\u2500\u2500 Public types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** A page sets a literal string; a layout wraps with a template function. */\r\nexport type TitleValue = string | ((prev: string) => string);\r\n\r\nexport interface HtmlAttrs {\r\n lang?: string;\r\n class?: string;\r\n style?: string;\r\n dir?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface BodyAttrs {\r\n class?: string;\r\n style?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface MetaTag {\r\n name?: string;\r\n property?: string;\r\n httpEquiv?: string;\r\n charset?: string;\r\n content?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface LinkTag {\r\n rel?: string;\r\n href?: string;\r\n type?: string;\r\n media?: string;\r\n as?: string;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n hrefLang?: string;\r\n sizes?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface ScriptTag {\r\n src?: string;\r\n content?: string;\r\n type?: string;\r\n defer?: boolean;\r\n async?: boolean;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n noModule?: boolean;\r\n /**\r\n * Where to inject the script in the document.\r\n * 'head' (default) \u2014 placed inside <head>, inside the <!--n-head--> block.\r\n * 'body' \u2014 placed at the very end of <body>, inside the\r\n * <!--n-body-scripts--> block, just before </body>.\r\n */\r\n position?: 'head' | 'body';\r\n}\r\n\r\nexport interface StyleTag {\r\n content?: string;\r\n media?: string;\r\n}\r\n\r\nexport interface HtmlStore {\r\n /** Collected in render order; resolved in reverse so the page title wins. */\r\n titleOps: TitleValue[];\r\n /** Attributes merged onto <html>; last write wins per attribute. */\r\n htmlAttrs: HtmlAttrs;\r\n /** Attributes merged onto <body>; last write wins per attribute. */\r\n bodyAttrs: BodyAttrs;\r\n /** Accumulated in render order: layouts first, page last. */\r\n meta: MetaTag[];\r\n link: LinkTag[];\r\n script: ScriptTag[];\r\n style: StyleTag[];\r\n}\r\n\r\n// \u2500\u2500\u2500 GlobalThis storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Well-known Symbol used to share the store across duplicate module copies. */\r\nconst KEY = Symbol.for('__nukejs_html_store__');\r\n\r\nconst getGlobal = (): HtmlStore | null => (globalThis as any)[KEY] ?? null;\r\nconst setGlobal = (store: HtmlStore | null): void => { (globalThis as any)[KEY] = store; };\r\n\r\nfunction emptyStore(): HtmlStore {\r\n return {\r\n titleOps: [],\r\n htmlAttrs: {},\r\n bodyAttrs: {},\r\n meta: [],\r\n link: [],\r\n script: [],\r\n style: [],\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Runs `fn` inside a fresh HTML store and returns the collected values.\r\n *\r\n * Usage in SSR:\r\n * ```ts\r\n * const store = await runWithHtmlStore(async () => {\r\n * appHtml = await renderElementToHtml(element, ctx);\r\n * });\r\n * // store.titleOps, store.meta, etc. are now populated\r\n * ```\r\n */\r\nexport async function runWithHtmlStore(fn: () => Promise<void>): Promise<HtmlStore> {\r\n setGlobal(emptyStore());\r\n try {\r\n await fn();\r\n return { ...(getGlobal() ?? emptyStore()) } as HtmlStore;\r\n } finally {\r\n // Always clear the store, even if rendering throws, to prevent leakage\r\n // into the next request on the same event-loop tick.\r\n setGlobal(null);\r\n }\r\n}\r\n\r\n/**\r\n * Returns the current request's store, or `undefined` if called outside of\r\n * a `runWithHtmlStore` context (e.g. in the browser or in a test).\r\n */\r\nexport function getHtmlStore(): HtmlStore | undefined {\r\n return getGlobal() ?? undefined;\r\n}\r\n\r\n/**\r\n * Resolves the final page title from a list of title operations.\r\n *\r\n * Operations are walked in *reverse* so the page's value is the starting\r\n * point and layout template functions wrap it outward:\r\n *\r\n * ```\r\n * ops = [ (p) => `${p} | Acme`, 'About' ] \u2190 layout pushed first, page last\r\n * Walk in reverse:\r\n * i=1: op = 'About' \u2192 title = 'About'\r\n * i=0: op = (p) => \u2026 \u2192 title = 'About | Acme'\r\n * ```\r\n *\r\n * @param fallback Used when ops is empty (e.g. a page that didn't call useHtml).\r\n */\r\nexport function resolveTitle(ops: TitleValue[], fallback = ''): string {\r\n let title = fallback;\r\n for (let i = ops.length - 1; i >= 0; i--) {\r\n const op = ops[i];\r\n title = typeof op === 'string' ? op : op(title);\r\n }\r\n return title;\r\n}"],
5
+ "mappings": "AA6GA,MAAM,MAAM,uBAAO,IAAI,uBAAuB;AAE9C,MAAM,YAAY,MAAyB,WAAmB,GAAG,KAAK;AACtE,MAAM,YAAY,CAAC,UAAkC;AAAE,EAAC,WAAmB,GAAG,IAAI;AAAO;AAEzF,SAAS,aAAwB;AAC/B,SAAO;AAAA,IACL,UAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,QAAW,CAAC;AAAA,IACZ,OAAW,CAAC;AAAA,EACd;AACF;AAeA,eAAsB,iBAAiB,IAA6C;AAClF,YAAU,WAAW,CAAC;AACtB,MAAI;AACF,UAAM,GAAG;AACT,WAAO,EAAE,GAAI,UAAU,KAAK,WAAW,EAAG;AAAA,EAC5C,UAAE;AAGA,cAAU,IAAI;AAAA,EAChB;AACF;AAMO,SAAS,eAAsC;AACpD,SAAO,UAAU,KAAK;AACxB;AAiBO,SAAS,aAAa,KAAmB,WAAW,IAAY;AACrE,MAAI,QAAQ;AACZ,WAAS,IAAI,IAAI,SAAS,GAAG,KAAK,GAAG,KAAK;AACxC,UAAM,KAAK,IAAI,CAAC;AAChB,YAAQ,OAAO,OAAO,WAAW,KAAK,GAAG,KAAK;AAAA,EAChD;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { useHtml } from './use-html';
2
2
  export type { HtmlOptions, TitleValue, HtmlAttrs, BodyAttrs, MetaTag, LinkTag, ScriptTag, StyleTag, } from './use-html';
3
- export { default as useRouter } from './as-is/useRouter';
4
- export { default as Link } from './as-is/Link';
3
+ export { default as useRouter } from './use-router';
4
+ export { default as Link } from './Link';
5
5
  export { setupLocationChangeMonitor, initRuntime } from './bundle';
6
6
  export type { RuntimeData } from './bundle';
7
7
  export { escapeHtml } from './utils';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useHtml } from "./use-html.js";
2
- import { default as default2 } from "./as-is/useRouter";
3
- import { default as default3 } from "./as-is/Link";
2
+ import { default as default2 } from "./use-router.js";
3
+ import { default as default3 } from "./Link.js";
4
4
  import { setupLocationChangeMonitor, initRuntime } from "./bundle.js";
5
5
  import { escapeHtml } from "./utils.js";
6
6
  import { ansi, c, log, setDebugLevel, getDebugLevel } from "./logger.js";
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["// \u2500\u2500\u2500 Client-side hooks & components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { useHtml } from './use-html';\r\nexport type {\r\n HtmlOptions,\r\n TitleValue,\r\n HtmlAttrs,\r\n BodyAttrs,\r\n MetaTag,\r\n LinkTag,\r\n ScriptTag,\r\n StyleTag,\r\n} from './use-html';\r\n\r\nexport { default as useRouter } from './as-is/useRouter';\r\n\r\nexport { default as Link } from './as-is/Link';\r\n\r\n// \u2500\u2500\u2500 Client runtime (browser bootstrap) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { setupLocationChangeMonitor, initRuntime } from './bundle';\r\nexport type { RuntimeData } from './bundle';\r\n\r\n// \u2500\u2500\u2500 Shared utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { escapeHtml } from './utils';\r\n\r\nexport { ansi, c, log, setDebugLevel, getDebugLevel } from './logger';\r\nexport type { DebugLevel } from './logger';\r\n"],
4
+ "sourcesContent": ["// \u2500\u2500\u2500 Client-side hooks & components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { useHtml } from './use-html';\r\nexport type {\r\n HtmlOptions,\r\n TitleValue,\r\n HtmlAttrs,\r\n BodyAttrs,\r\n MetaTag,\r\n LinkTag,\r\n ScriptTag,\r\n StyleTag,\r\n} from './use-html';\r\n\r\nexport { default as useRouter } from './use-router';\r\n\r\nexport { default as Link } from './Link';\r\n\r\n// \u2500\u2500\u2500 Client runtime (browser bootstrap) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { setupLocationChangeMonitor, initRuntime } from './bundle';\r\nexport type { RuntimeData } from './bundle';\r\n\r\n// \u2500\u2500\u2500 Shared utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\nexport { escapeHtml } from './utils';\r\n\r\nexport { ansi, c, log, setDebugLevel, getDebugLevel } from './logger';\r\nexport type { DebugLevel } from './logger';"],
5
5
  "mappings": "AACA,SAAS,eAAe;AAYxB,SAAoB,WAAXA,gBAA4B;AAErC,SAAoB,WAAXA,gBAAuB;AAGhC,SAAS,4BAA4B,mBAAmB;AAIxD,SAAS,kBAAkB;AAE3B,SAAS,MAAM,GAAG,KAAK,eAAe,qBAAqB;",
6
6
  "names": ["default"]
7
7
  }
package/dist/renderer.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import path from "path";
2
- import fs from "fs";
3
2
  import { createElement, Fragment } from "react";
4
3
  import { renderToString } from "react-dom/server";
5
4
  import { log } from "./logger.js";
@@ -52,9 +51,7 @@ async function renderFunctionComponent(type, props, ctx) {
52
51
  for (const [id, filePath] of ctx.registry.entries()) {
53
52
  const info = componentCache.get(filePath);
54
53
  if (!info?.isClientComponent) continue;
55
- const content = fs.readFileSync(filePath, "utf-8");
56
- const match = content.match(/export\s+default\s+(?:function\s+)?(\w+)/);
57
- if (!match?.[1] || type.name !== match[1]) continue;
54
+ if (!info.exportedName || type.name !== info.exportedName) continue;
58
55
  try {
59
56
  ctx.hydrated.add(id);
60
57
  const serializedProps = serializePropsForHydration(props, ctx.registry);
@@ -111,9 +108,7 @@ function serializeReactElement(element, registry) {
111
108
  for (const [id, filePath] of registry.entries()) {
112
109
  const info = componentCache.get(filePath);
113
110
  if (!info?.isClientComponent) continue;
114
- const content = fs.readFileSync(filePath, "utf-8");
115
- const match = content.match(/export\s+default\s+(?:function\s+)?(\w+)/);
116
- if (match?.[1] && type.name === match[1]) {
111
+ if (info.exportedName && type.name === info.exportedName) {
117
112
  return {
118
113
  __re: "client",
119
114
  componentId: id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/renderer.ts"],
4
- "sourcesContent": ["/**\r\n * renderer.ts \u2014 Dev-Mode Async SSR Renderer\r\n *\r\n * Implements a recursive async renderer used in `nuke dev` to convert a React\r\n * element tree into an HTML string. It is a lighter alternative to\r\n * react-dom/server.renderToString that:\r\n *\r\n * - Supports async server components (components that return Promises).\r\n * - Emits <span data-hydrate-id=\"\u2026\"> markers for \"use client\" boundaries\r\n * instead of trying to render them server-side without their browser APIs.\r\n * - Serializes props passed to client components into the marker's\r\n * data-hydrate-props attribute so the browser can reconstruct them.\r\n *\r\n * In production (nuke build), the equivalent renderer is inlined into each\r\n * page's standalone bundle by build-common.ts (makePageAdapterSource).\r\n *\r\n * RenderContext:\r\n * registry \u2014 Map<id, filePath> of all client components for this page.\r\n * Populated by component-analyzer.ts before rendering.\r\n * hydrated \u2014 Set<id> populated during render; used to tell the browser\r\n * which components to hydrate on this specific request.\r\n * skipClientSSR \u2014 When true (HMR request), client components emit an empty\r\n * marker instead of running renderToString (faster dev reload).\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\nimport { createElement, Fragment } from 'react';\r\nimport { renderToString } from 'react-dom/server';\r\nimport { log } from './logger';\r\nimport { getComponentCache } from './component-analyzer';\r\nimport { escapeHtml } from './utils';\r\n\r\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface RenderContext {\r\n /** id \u2192 absolute file path for every client component reachable from this page. */\r\n registry: Map<string, string>;\r\n /** Populated during render: IDs of client components actually encountered. */\r\n hydrated: Set<string>;\r\n /** When true, skip renderToString for client components (faster HMR). */\r\n skipClientSSR?: boolean;\r\n}\r\n\r\n// \u2500\u2500\u2500 Top-level renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively renders a React element (or primitive) to an HTML string.\r\n *\r\n * Handles:\r\n * null / undefined / boolean \u2192 ''\r\n * string / number \u2192 HTML-escaped text\r\n * array \u2192 rendered in parallel, joined\r\n * Fragment \u2192 renders children directly\r\n * HTML element string \u2192 renderHtmlElement()\r\n * function component \u2192 renderFunctionComponent()\r\n */\r\nexport async function renderElementToHtml(\r\n element: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n if (element === null || element === undefined || typeof element === 'boolean') return '';\r\n if (typeof element === 'string' || typeof element === 'number')\r\n return escapeHtml(String(element));\r\n\r\n if (Array.isArray(element)) {\r\n const parts = await Promise.all(element.map(e => renderElementToHtml(e, ctx)));\r\n return parts.join('');\r\n }\r\n\r\n if (!element.type) return '';\r\n\r\n const { type, props } = element;\r\n\r\n if (type === Fragment) return renderElementToHtml(props.children, ctx);\r\n if (typeof type === 'string') return renderHtmlElement(type, props, ctx);\r\n if (typeof type === 'function') return renderFunctionComponent(type, props, ctx);\r\n\r\n return '';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML element renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a native HTML element (e.g. `<div className=\"foo\">`).\r\n *\r\n * Attribute conversion:\r\n * className \u2192 class\r\n * htmlFor \u2192 for\r\n * style \u2192 converted from camelCase object to CSS string\r\n * boolean \u2192 omitted when false, rendered as name-only attribute when true\r\n * dangerouslySetInnerHTML \u2192 inner HTML set verbatim (no escaping)\r\n *\r\n * Void elements (img, br, input, etc.) are self-closed.\r\n */\r\nasync function renderHtmlElement(\r\n type: string,\r\n props: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n const { children, ...attributes } = (props || {}) as Record<string, any>;\r\n\r\n const attrs = Object.entries(attributes as Record<string, any>)\r\n .map(([key, value]) => {\r\n // React prop name \u2192 HTML attribute name.\r\n if (key === 'className') key = 'class';\r\n if (key === 'htmlFor') key = 'for';\r\n if (key === 'dangerouslySetInnerHTML') return ''; // handled separately below\r\n\r\n if (typeof value === 'boolean') return value ? key : '';\r\n\r\n // camelCase style object \u2192 \"prop:value;\u2026\" CSS string.\r\n if (key === 'style' && typeof value === 'object') {\r\n const styleStr = Object.entries(value)\r\n .map(([k, v]) => {\r\n const prop = k.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);\r\n // Strip characters that could break out of the attribute value.\r\n const safeVal = String(v).replace(/[<>\"'`\\\\]/g, '');\r\n return `${prop}:${safeVal}`;\r\n })\r\n .join(';');\r\n return `style=\"${styleStr}\"`;\r\n }\r\n\r\n return `${key}=\"${escapeHtml(String(value))}\"`;\r\n })\r\n .filter(Boolean)\r\n .join(' ');\r\n\r\n const attrStr = attrs ? ` ${attrs}` : '';\r\n\r\n if (props?.dangerouslySetInnerHTML) {\r\n return `<${type}${attrStr}>${props.dangerouslySetInnerHTML.__html}</${type}>`;\r\n }\r\n\r\n // Void elements cannot have children.\r\n if (['img', 'br', 'hr', 'input', 'meta', 'link'].includes(type)) {\r\n return `<${type}${attrStr} />`;\r\n }\r\n\r\n const childrenHtml = children ? await renderElementToHtml(children, ctx) : '';\r\n return `<${type}${attrStr}>${childrenHtml}</${type}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Function component renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a function (or class) component.\r\n *\r\n * Client boundary detection:\r\n * The component cache maps file paths to ComponentInfo. We match the\r\n * component's function name against the default export of each registered\r\n * client file to determine whether this component is a client boundary.\r\n *\r\n * If it is, we emit a hydration marker and optionally run renderToString\r\n * to produce the initial HTML inside the marker (skipped when skipClientSSR\r\n * is set, e.g. during HMR navigation).\r\n *\r\n * Class components:\r\n * Instantiated via `new type(props)` and their render() method called.\r\n *\r\n * Async components:\r\n * Awaited if the return value is a Promise (standard server component pattern).\r\n */\r\nasync function renderFunctionComponent(\r\n type: Function,\r\n props: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n const componentCache = getComponentCache();\r\n\r\n // Check whether this component function is a registered client component.\r\n for (const [id, filePath] of ctx.registry.entries()) {\r\n const info = componentCache.get(filePath);\r\n if (!info?.isClientComponent) continue;\r\n\r\n // Match by default export function name.\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n const match = content.match(/export\\s+default\\s+(?:function\\s+)?(\\w+)/);\r\n if (!match?.[1] || type.name !== match[1]) continue;\r\n\r\n // This is a client boundary.\r\n try {\r\n ctx.hydrated.add(id);\r\n const serializedProps = serializePropsForHydration(props, ctx.registry);\r\n log.verbose(`Client component rendered for hydration: ${id} (${path.basename(filePath)})`);\r\n\r\n // Optionally SSR the component so the initial HTML is meaningful\r\n // (improves perceived performance and avoids layout shift).\r\n const html = ctx.skipClientSSR\r\n ? ''\r\n : renderToString(createElement(type as React.ComponentType<any>, props));\r\n\r\n return `<span data-hydrate-id=\"${id}\" data-hydrate-props=\"${escapeHtml(\r\n JSON.stringify(serializedProps),\r\n )}\">${html}</span>`;\r\n } catch (err) {\r\n log.error('Error rendering client component:', err);\r\n return `<div style=\"color:red\">Error rendering client component: ${escapeHtml(String(err))}</div>`;\r\n }\r\n }\r\n\r\n // Server component \u2014 call it and recurse into the result.\r\n try {\r\n const result = type(props);\r\n const resolved = result?.then ? await result : result;\r\n return renderElementToHtml(resolved, ctx);\r\n } catch (err) {\r\n log.error('Error rendering component:', err);\r\n return `<div style=\"color:red\">Error rendering component: ${escapeHtml(String(err))}</div>`;\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Prop serialization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Converts props into a JSON-serializable form for the data-hydrate-props\r\n * attribute. React elements inside props are serialized to a tagged object\r\n * format ({ __re: 'html'|'client', \u2026 }) that the browser's reconstructElement\r\n * function (in bundle.ts) can turn back into real React elements.\r\n *\r\n * Functions are dropped (cannot be serialized).\r\n */\r\nfunction serializePropsForHydration(\r\n props: any,\r\n registry: Map<string, string>,\r\n): any {\r\n if (!props || typeof props !== 'object') return props;\r\n const out: any = {};\r\n for (const [key, value] of Object.entries(props as Record<string, any>)) {\r\n const s = serializeValue(value, registry);\r\n if (s !== undefined) out[key] = s;\r\n }\r\n return out;\r\n}\r\n\r\nfunction serializeValue(value: any, registry: Map<string, string>): any {\r\n if (value === null || value === undefined) return value;\r\n if (typeof value === 'function') return undefined; // not serializable\r\n if (typeof value !== 'object') return value;\r\n if (Array.isArray(value))\r\n return value.map(v => serializeValue(v, registry)).filter(v => v !== undefined);\r\n if ((value as any).$$typeof)\r\n return serializeReactElement(value, registry);\r\n\r\n const out: any = {};\r\n for (const [k, v] of Object.entries(value as Record<string, any>)) {\r\n const s = serializeValue(v, registry);\r\n if (s !== undefined) out[k] = s;\r\n }\r\n return out;\r\n}\r\n\r\n/**\r\n * Serializes a React element to its wire format:\r\n * Native element \u2192 { __re: 'html', tag, props }\r\n * Client component \u2192 { __re: 'client', componentId, props }\r\n * Server component \u2192 undefined (cannot be serialized)\r\n */\r\nfunction serializeReactElement(element: any, registry: Map<string, string>): any {\r\n const { type, props } = element;\r\n\r\n if (typeof type === 'string') {\r\n return { __re: 'html', tag: type, props: serializePropsForHydration(props, registry) };\r\n }\r\n\r\n if (typeof type === 'function') {\r\n const componentCache = getComponentCache();\r\n for (const [id, filePath] of registry.entries()) {\r\n const info = componentCache.get(filePath);\r\n if (!info?.isClientComponent) continue;\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n const match = content.match(/export\\s+default\\s+(?:function\\s+)?(\\w+)/);\r\n if (match?.[1] && type.name === match[1]) {\r\n return {\r\n __re: 'client',\r\n componentId: id,\r\n props: serializePropsForHydration(props, registry),\r\n };\r\n }\r\n }\r\n }\r\n\r\n return undefined; // Server component \u2014 not serializable\r\n}\r\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * renderer.ts \u2014 Dev-Mode Async SSR Renderer\r\n *\r\n * Implements a recursive async renderer used in `nuke dev` to convert a React\r\n * element tree into an HTML string. It is a lighter alternative to\r\n * react-dom/server.renderToString that:\r\n *\r\n * - Supports async server components (components that return Promises).\r\n * - Emits <span data-hydrate-id=\"\u2026\"> markers for \"use client\" boundaries\r\n * instead of trying to render them server-side without their browser APIs.\r\n * - Serializes props passed to client components into the marker's\r\n * data-hydrate-props attribute so the browser can reconstruct them.\r\n *\r\n * In production (nuke build), the equivalent renderer is inlined into each\r\n * page's standalone bundle by build-common.ts (makePageAdapterSource).\r\n *\r\n * RenderContext:\r\n * registry \u2014 Map<id, filePath> of all client components for this page.\r\n * Populated by component-analyzer.ts before rendering.\r\n * hydrated \u2014 Set<id> populated during render; used to tell the browser\r\n * which components to hydrate on this specific request.\r\n * skipClientSSR \u2014 When true (HMR request), client components emit an empty\r\n * marker instead of running renderToString (faster dev reload).\r\n */\r\n\r\nimport path from 'path';\r\nimport { createElement, Fragment } from 'react';\r\nimport { renderToString } from 'react-dom/server';\r\nimport { log } from './logger';\r\nimport { getComponentCache } from './component-analyzer';\r\nimport { escapeHtml } from './utils';\r\n\r\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface RenderContext {\r\n /** id \u2192 absolute file path for every client component reachable from this page. */\r\n registry: Map<string, string>;\r\n /** Populated during render: IDs of client components actually encountered. */\r\n hydrated: Set<string>;\r\n /** When true, skip renderToString for client components (faster HMR). */\r\n skipClientSSR?: boolean;\r\n}\r\n\r\n// \u2500\u2500\u2500 Top-level renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively renders a React element (or primitive) to an HTML string.\r\n *\r\n * Handles:\r\n * null / undefined / boolean \u2192 ''\r\n * string / number \u2192 HTML-escaped text\r\n * array \u2192 rendered in parallel, joined\r\n * Fragment \u2192 renders children directly\r\n * HTML element string \u2192 renderHtmlElement()\r\n * function component \u2192 renderFunctionComponent()\r\n */\r\nexport async function renderElementToHtml(\r\n element: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n if (element === null || element === undefined || typeof element === 'boolean') return '';\r\n if (typeof element === 'string' || typeof element === 'number')\r\n return escapeHtml(String(element));\r\n\r\n if (Array.isArray(element)) {\r\n const parts = await Promise.all(element.map(e => renderElementToHtml(e, ctx)));\r\n return parts.join('');\r\n }\r\n\r\n if (!element.type) return '';\r\n\r\n const { type, props } = element;\r\n\r\n if (type === Fragment) return renderElementToHtml(props.children, ctx);\r\n if (typeof type === 'string') return renderHtmlElement(type, props, ctx);\r\n if (typeof type === 'function') return renderFunctionComponent(type, props, ctx);\r\n\r\n return '';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML element renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a native HTML element (e.g. `<div className=\"foo\">`).\r\n *\r\n * Attribute conversion:\r\n * className \u2192 class\r\n * htmlFor \u2192 for\r\n * style \u2192 converted from camelCase object to CSS string\r\n * boolean \u2192 omitted when false, rendered as name-only attribute when true\r\n * dangerouslySetInnerHTML \u2192 inner HTML set verbatim (no escaping)\r\n *\r\n * Void elements (img, br, input, etc.) are self-closed.\r\n */\r\nasync function renderHtmlElement(\r\n type: string,\r\n props: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n const { children, ...attributes } = (props || {}) as Record<string, any>;\r\n\r\n const attrs = Object.entries(attributes as Record<string, any>)\r\n .map(([key, value]) => {\r\n // React prop name \u2192 HTML attribute name.\r\n if (key === 'className') key = 'class';\r\n if (key === 'htmlFor') key = 'for';\r\n if (key === 'dangerouslySetInnerHTML') return ''; // handled separately below\r\n\r\n if (typeof value === 'boolean') return value ? key : '';\r\n\r\n // camelCase style object \u2192 \"prop:value;\u2026\" CSS string.\r\n if (key === 'style' && typeof value === 'object') {\r\n const styleStr = Object.entries(value)\r\n .map(([k, v]) => {\r\n const prop = k.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);\r\n // Strip characters that could break out of the attribute value.\r\n const safeVal = String(v).replace(/[<>\"'`\\\\]/g, '');\r\n return `${prop}:${safeVal}`;\r\n })\r\n .join(';');\r\n return `style=\"${styleStr}\"`;\r\n }\r\n\r\n return `${key}=\"${escapeHtml(String(value))}\"`;\r\n })\r\n .filter(Boolean)\r\n .join(' ');\r\n\r\n const attrStr = attrs ? ` ${attrs}` : '';\r\n\r\n if (props?.dangerouslySetInnerHTML) {\r\n return `<${type}${attrStr}>${props.dangerouslySetInnerHTML.__html}</${type}>`;\r\n }\r\n\r\n // Void elements cannot have children.\r\n if (['img', 'br', 'hr', 'input', 'meta', 'link'].includes(type)) {\r\n return `<${type}${attrStr} />`;\r\n }\r\n\r\n const childrenHtml = children ? await renderElementToHtml(children, ctx) : '';\r\n return `<${type}${attrStr}>${childrenHtml}</${type}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Function component renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a function (or class) component.\r\n *\r\n * Client boundary detection:\r\n * The component cache maps file paths to ComponentInfo. We match the\r\n * component's function name against the default export of each registered\r\n * client file to determine whether this component is a client boundary.\r\n *\r\n * If it is, we emit a hydration marker and optionally run renderToString\r\n * to produce the initial HTML inside the marker (skipped when skipClientSSR\r\n * is set, e.g. during HMR navigation).\r\n *\r\n * Class components:\r\n * Instantiated via `new type(props)` and their render() method called.\r\n *\r\n * Async components:\r\n * Awaited if the return value is a Promise (standard server component pattern).\r\n */\r\nasync function renderFunctionComponent(\r\n type: Function,\r\n props: any,\r\n ctx: RenderContext,\r\n): Promise<string> {\r\n const componentCache = getComponentCache();\r\n\r\n // Check whether this component function is a registered client component.\r\n for (const [id, filePath] of ctx.registry.entries()) {\r\n const info = componentCache.get(filePath);\r\n if (!info?.isClientComponent) continue;\r\n\r\n // Match by default export function name (cached \u2014 handles both source and\r\n // esbuild-compiled formats; see component-analyzer.getExportedDefaultName).\r\n if (!info.exportedName || type.name !== info.exportedName) continue;\r\n\r\n // This is a client boundary.\r\n try {\r\n ctx.hydrated.add(id);\r\n const serializedProps = serializePropsForHydration(props, ctx.registry);\r\n log.verbose(`Client component rendered for hydration: ${id} (${path.basename(filePath)})`);\r\n\r\n // Optionally SSR the component so the initial HTML is meaningful\r\n // (improves perceived performance and avoids layout shift).\r\n const html = ctx.skipClientSSR\r\n ? ''\r\n : renderToString(createElement(type as React.ComponentType<any>, props));\r\n\r\n return `<span data-hydrate-id=\"${id}\" data-hydrate-props=\"${escapeHtml(\r\n JSON.stringify(serializedProps),\r\n )}\">${html}</span>`;\r\n } catch (err) {\r\n log.error('Error rendering client component:', err);\r\n return `<div style=\"color:red\">Error rendering client component: ${escapeHtml(String(err))}</div>`;\r\n }\r\n }\r\n\r\n // Server component \u2014 call it and recurse into the result.\r\n try {\r\n const result = type(props);\r\n const resolved = result?.then ? await result : result;\r\n return renderElementToHtml(resolved, ctx);\r\n } catch (err) {\r\n log.error('Error rendering component:', err);\r\n return `<div style=\"color:red\">Error rendering component: ${escapeHtml(String(err))}</div>`;\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Prop serialization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Converts props into a JSON-serializable form for the data-hydrate-props\r\n * attribute. React elements inside props are serialized to a tagged object\r\n * format ({ __re: 'html'|'client', \u2026 }) that the browser's reconstructElement\r\n * function (in bundle.ts) can turn back into real React elements.\r\n *\r\n * Functions are dropped (cannot be serialized).\r\n */\r\nfunction serializePropsForHydration(\r\n props: any,\r\n registry: Map<string, string>,\r\n): any {\r\n if (!props || typeof props !== 'object') return props;\r\n const out: any = {};\r\n for (const [key, value] of Object.entries(props as Record<string, any>)) {\r\n const s = serializeValue(value, registry);\r\n if (s !== undefined) out[key] = s;\r\n }\r\n return out;\r\n}\r\n\r\nfunction serializeValue(value: any, registry: Map<string, string>): any {\r\n if (value === null || value === undefined) return value;\r\n if (typeof value === 'function') return undefined; // not serializable\r\n if (typeof value !== 'object') return value;\r\n if (Array.isArray(value))\r\n return value.map(v => serializeValue(v, registry)).filter(v => v !== undefined);\r\n if ((value as any).$$typeof)\r\n return serializeReactElement(value, registry);\r\n\r\n const out: any = {};\r\n for (const [k, v] of Object.entries(value as Record<string, any>)) {\r\n const s = serializeValue(v, registry);\r\n if (s !== undefined) out[k] = s;\r\n }\r\n return out;\r\n}\r\n\r\n/**\r\n * Serializes a React element to its wire format:\r\n * Native element \u2192 { __re: 'html', tag, props }\r\n * Client component \u2192 { __re: 'client', componentId, props }\r\n * Server component \u2192 undefined (cannot be serialized)\r\n */\r\nfunction serializeReactElement(element: any, registry: Map<string, string>): any {\r\n const { type, props } = element;\r\n\r\n if (typeof type === 'string') {\r\n return { __re: 'html', tag: type, props: serializePropsForHydration(props, registry) };\r\n }\r\n\r\n if (typeof type === 'function') {\r\n const componentCache = getComponentCache();\r\n for (const [id, filePath] of registry.entries()) {\r\n const info = componentCache.get(filePath);\r\n if (!info?.isClientComponent) continue;\r\n if (info.exportedName && type.name === info.exportedName) {\r\n return {\r\n __re: 'client',\r\n componentId: id,\r\n props: serializePropsForHydration(props, registry),\r\n };\r\n }\r\n }\r\n }\r\n\r\n return undefined; // Server component \u2014 not serializable\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/router.d.ts CHANGED
@@ -4,17 +4,15 @@
4
4
  * Maps incoming URL paths to handler files using Next.js-compatible conventions:
5
5
  *
6
6
  * server/users/index.ts → /users
7
- * server/users/[id].ts → /users/:id (dynamic segment)
8
- * server/users/[[id]].ts → /users or /users/42 (optional single)
9
- * server/blog/[...slug].ts → /blog/* (required catch-all)
10
- * server/files/[[...path]].ts → /files or /files/* (optional catch-all)
7
+ * server/users/[id].ts → /users/:id (dynamic segment)
8
+ * server/blog/[...slug].ts → /blog/* (catch-all)
9
+ * server/files/[[...path]].ts → /files or /files/* (optional catch-all)
11
10
  *
12
- * Route specificity (higher score wins):
13
- * static segment +5 (e.g. 'about')
14
- * [dynamic] +4 (e.g. '[id]')
15
- * [[optional]] +3 (e.g. '[[id]]')
16
- * [...catchAll] +2 (e.g. '[...slug]')
17
- * [[...optCatchAll]] +1 (e.g. '[[...path]]')
11
+ * Route specificity (higher = wins over lower):
12
+ * static segment +4 (e.g. 'about')
13
+ * dynamic segment +3 (e.g. '[id]')
14
+ * catch-all +2 (e.g. '[...slug]')
15
+ * optional catch-all +1 (e.g. '[[...path]]')
18
16
  *
19
17
  * Path traversal protection:
20
18
  * matchRoute() rejects URL segments that contain '..' or '.' and verifies
@@ -34,24 +32,27 @@
34
32
  export declare function findAllRoutes(dir: string, baseDir?: string): string[];
35
33
  /**
36
34
  * Attempts to match `urlSegments` against a route that may contain dynamic
37
- * segments ([param]), optional single segments ([[param]]), catch-alls
38
- * ([...slug]), and optional catch-alls ([[...path]]).
35
+ * segments ([param]), catch-alls ([...slug]), and optional catch-alls ([[...path]]).
39
36
  *
40
37
  * Returns the captured params on success, or null if the route does not match.
41
38
  *
42
39
  * Param value types:
43
- * [param] → string (required)
44
- * [[param]] → string (optional, '' when absent)
45
- * [...slug] → string[] (required, ≥1 segment)
46
- * [[...path]] → string[] (optional, may be empty)
40
+ * [param] → string
41
+ * [...slug] → string[] (at least one segment required)
42
+ * [[...path]] → string[] (zero or more segments)
47
43
  */
48
44
  export declare function matchDynamicRoute(urlSegments: string[], routePath: string): {
49
45
  params: Record<string, string | string[]>;
50
46
  } | null;
51
47
  /**
52
48
  * Computes a specificity score for a route path.
53
- * Used to sort candidate routes so more specific routes shadow less specific ones.
54
- * Higher score = more specific.
49
+ * Used to sort candidate routes so more specific routes shadow catch-alls.
50
+ *
51
+ * Higher score = more specific:
52
+ * static segment 4
53
+ * [dynamic] 3
54
+ * [...catchAll] 2
55
+ * [[...optCatchAll]] 1
55
56
  */
56
57
  export declare function getRouteSpecificity(routePath: string): number;
57
58
  export interface RouteMatch {
@@ -82,7 +83,7 @@ export declare function matchRoute(urlPath: string, baseDir: string, extension?:
82
83
  * app/pages/layout.tsx ← root layout
83
84
  * app/pages/blog/layout.tsx ← blog section layout
84
85
  *
85
- * Outermost-first order matches how wrapWithLayouts() nests them:
86
+ * The outermost-first order matches how wrapWithLayouts() nests them:
86
87
  * the last layout in the array is the innermost wrapper.
87
88
  */
88
89
  export declare function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[];
package/dist/router.js CHANGED
@@ -30,7 +30,11 @@ function matchDynamicRoute(urlSegments, routePath) {
30
30
  }
31
31
  const optDynamic = seg.match(/^\[\[([^.][^\]]*)\]\]$/);
32
32
  if (optDynamic) {
33
- params[optDynamic[1]] = ui < urlSegments.length ? urlSegments[ui++] : "";
33
+ if (ui < urlSegments.length) {
34
+ params[optDynamic[1]] = urlSegments[ui++];
35
+ } else {
36
+ params[optDynamic[1]] = "";
37
+ }
34
38
  ri++;
35
39
  continue;
36
40
  }
@@ -68,21 +72,25 @@ function isWithinBase(baseDir, filePath) {
68
72
  return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
69
73
  }
70
74
  function matchRoute(urlPath, baseDir, extension = ".tsx") {
71
- const rawSegments = urlPath === "/" ? [] : urlPath.slice(1).split("/");
75
+ const normPath = urlPath.length > 1 ? urlPath.replace(/\/+$/, "") : urlPath;
76
+ const rawSegments = normPath === "/" ? [] : normPath.slice(1).split("/");
72
77
  if (rawSegments.some((s) => s === ".." || s === ".")) return null;
73
78
  const segments = rawSegments.length === 0 ? ["index"] : rawSegments;
74
79
  const exactPath = path.join(baseDir, ...segments) + extension;
75
- if (isWithinBase(baseDir, exactPath) && path.basename(exactPath, extension) !== "layout" && fs.existsSync(exactPath)) {
80
+ const exactStem = path.basename(exactPath, extension);
81
+ if (!isWithinBase(baseDir, exactPath)) return null;
82
+ if (exactStem !== "layout" && fs.existsSync(exactPath)) {
76
83
  return { filePath: exactPath, params: {}, routePattern: segments.join("/") };
77
84
  }
78
85
  const sortedRoutes = findAllRoutes(baseDir).sort(
79
86
  (a, b) => getRouteSpecificity(b) - getRouteSpecificity(a)
80
87
  );
81
88
  for (const route of sortedRoutes) {
82
- const match = matchDynamicRoute(segments, route);
89
+ const match = matchDynamicRoute(rawSegments, route);
83
90
  if (!match) continue;
84
91
  const filePath = path.join(baseDir, route) + extension;
85
- if (isWithinBase(baseDir, filePath) && fs.existsSync(filePath)) {
92
+ if (!isWithinBase(baseDir, filePath)) continue;
93
+ if (fs.existsSync(filePath)) {
86
94
  return { filePath, params: match.params, routePattern: route };
87
95
  }
88
96
  }
@@ -94,7 +102,7 @@ function findLayoutsForRoute(routeFilePath, pagesDir) {
94
102
  if (fs.existsSync(rootLayout)) layouts.push(rootLayout);
95
103
  const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));
96
104
  if (!relativePath || relativePath === ".") return layouts;
97
- const segments = relativePath.split(path.sep).filter(Boolean);
105
+ const segments = relativePath.split(path.sep).filter((s) => s !== ".");
98
106
  for (let i = 1; i <= segments.length; i++) {
99
107
  const layoutPath = path.join(pagesDir, ...segments.slice(0, i), "layout.tsx");
100
108
  if (fs.existsSync(layoutPath)) layouts.push(layoutPath);