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/router.ts"],
4
- "sourcesContent": ["/**\n * router.ts \u2014 File-System Based URL Router\n *\n * Maps incoming URL paths to handler files using Next.js-compatible conventions:\n *\n * server/users/index.ts \u2192 /users\n * server/users/[id].ts \u2192 /users/:id (dynamic segment)\n * server/users/[[id]].ts \u2192 /users or /users/42 (optional single)\n * server/blog/[...slug].ts \u2192 /blog/* (required catch-all)\n * server/files/[[...path]].ts \u2192 /files or /files/* (optional catch-all)\n *\n * Route specificity (higher score wins):\n * static segment +5 (e.g. 'about')\n * [dynamic] +4 (e.g. '[id]')\n * [[optional]] +3 (e.g. '[[id]]')\n * [...catchAll] +2 (e.g. '[...slug]')\n * [[...optCatchAll]] +1 (e.g. '[[...path]]')\n *\n * Path traversal protection:\n * matchRoute() rejects URL segments that contain '..' or '.' and verifies\n * that the resolved file path stays inside the base directory before\n * checking whether the file exists.\n */\n\nimport path from 'path';\nimport fs from 'fs';\n\n// \u2500\u2500\u2500 Route file discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 collects all routable .ts/.tsx files in `dir`, returning paths\n * relative to `baseDir` without the file extension.\n *\n * layout.tsx files are excluded \u2014 they wrap pages but are never routes\n * themselves. This mirrors the filter in collectServerPages() so dev-mode\n * route matching behaves identically to the production build.\n *\n * Example output: ['index', 'users/index', 'users/[id]', 'blog/[...slug]']\n */\nexport function findAllRoutes(dir: string, baseDir: string = dir): string[] {\n if (!fs.existsSync(dir)) return [];\n\n const routes: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n routes.push(...findAllRoutes(fullPath, baseDir));\n } else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {\n const stem = entry.name.replace(/\\.(tsx|ts)$/, '');\n if (stem === 'layout') continue;\n routes.push(path.relative(baseDir, fullPath).replace(/\\.(tsx|ts)$/, ''));\n }\n }\n return routes;\n}\n\n// \u2500\u2500\u2500 Dynamic segment 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Attempts to match `urlSegments` against a route that may contain dynamic\n * segments ([param]), optional single segments ([[param]]), catch-alls\n * ([...slug]), and optional catch-alls ([[...path]]).\n *\n * Returns the captured params on success, or null if the route does not match.\n *\n * Param value types:\n * [param] \u2192 string (required)\n * [[param]] \u2192 string (optional, '' when absent)\n * [...slug] \u2192 string[] (required, \u22651 segment)\n * [[...path]] \u2192 string[] (optional, may be empty)\n */\nexport function matchDynamicRoute(\n urlSegments: string[],\n routePath: string,\n): { params: Record<string, string | string[]> } | null {\n const routeSegments = routePath.split(path.sep);\n\n // 'index' at the end means the route handles the parent directory URL.\n if (routeSegments.at(-1) === 'index') routeSegments.pop();\n\n const params: Record<string, string | string[]> = {};\n let ri = 0; // route segment index\n let ui = 0; // URL segment index\n\n while (ri < routeSegments.length) {\n const seg = routeSegments[ri];\n\n // [[...name]] \u2014 optional catch-all: consumes zero or more remaining segments.\n const optCatchAll = seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/);\n if (optCatchAll) {\n params[optCatchAll[1]] = urlSegments.slice(ui);\n return { params };\n }\n\n // [[name]] \u2014 optional single: consumes zero or one segment.\n const optDynamic = seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/);\n if (optDynamic) {\n params[optDynamic[1]] = ui < urlSegments.length ? urlSegments[ui++] : '';\n ri++;\n continue;\n }\n\n // [...name] \u2014 required catch-all: must consume at least one segment.\n const catchAll = seg.match(/^\\[\\.\\.\\.(.+)\\]$/);\n if (catchAll) {\n const remaining = urlSegments.slice(ui);\n if (!remaining.length) return null;\n params[catchAll[1]] = remaining;\n return { params };\n }\n\n // [name] \u2014 required single: consumes exactly one segment.\n const dynamic = seg.match(/^\\[(.+)\\]$/);\n if (dynamic) {\n if (ui >= urlSegments.length) return null;\n params[dynamic[1]] = urlSegments[ui++];\n ri++;\n continue;\n }\n\n // Static \u2014 must match exactly.\n if (ui >= urlSegments.length || seg !== urlSegments[ui]) return null;\n ui++; ri++;\n }\n\n // All route segments consumed \u2014 URL must also be fully consumed.\n return ui < urlSegments.length ? null : { params };\n}\n\n// \u2500\u2500\u2500 Specificity scoring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Computes a specificity score for a route path.\n * Used to sort candidate routes so more specific routes shadow less specific ones.\n * Higher score = more specific.\n */\nexport function getRouteSpecificity(routePath: string): number {\n return routePath.split(path.sep).reduce((score, seg) => {\n if (seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/)) return score + 1; // [[...a]] optional catch-all\n if (seg.match(/^\\[\\.\\.\\.(.+)\\]$/)) return score + 2; // [...a] required catch-all\n if (seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/)) return score + 3; // [[a]] optional single\n if (seg.match(/^\\[(.+)\\]$/)) return score + 4; // [a] required single\n return score + 5; // static segment\n }, 0);\n}\n\n// \u2500\u2500\u2500 Route match result \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 RouteMatch {\n filePath: string;\n params: Record<string, string | string[]>;\n routePattern: string;\n}\n\n// \u2500\u2500\u2500 Path traversal guard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 only when `filePath` is a descendant of `baseDir`.\n * Used to prevent URL path traversal attacks (e.g. /../../etc/passwd).\n */\nfunction isWithinBase(baseDir: string, filePath: string): boolean {\n const rel = path.relative(baseDir, filePath);\n return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);\n}\n\n// \u2500\u2500\u2500 Route 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Resolves a URL path to a route file inside `baseDir`.\n *\n * Steps:\n * 1. Reject '..' or '.' path segments (path traversal guard).\n * 2. Try an exact file match (e.g. /about \u2192 baseDir/about.tsx).\n * layout.tsx is explicitly excluded from exact matching.\n * 3. Sort all discovered routes by specificity (most specific first).\n * 4. Return the first dynamic route that matches.\n *\n * @param urlPath The URL path to match (e.g. '/users/42').\n * @param baseDir Absolute path to the directory containing route files.\n * @param extension File extension to look for ('.tsx' or '.ts').\n */\nexport function matchRoute(\n urlPath: string,\n baseDir: string,\n extension = '.tsx',\n): RouteMatch | null {\n const rawSegments = urlPath === '/' ? [] : urlPath.slice(1).split('/');\n if (rawSegments.some(s => s === '..' || s === '.')) return null;\n\n const segments = rawSegments.length === 0 ? ['index'] : rawSegments;\n\n // 1. Exact match: /about \u2192 about.tsx \u2014 never resolve to a layout file.\n const exactPath = path.join(baseDir, ...segments) + extension;\n if (\n isWithinBase(baseDir, exactPath) &&\n path.basename(exactPath, extension) !== 'layout' &&\n fs.existsSync(exactPath)\n ) {\n return { filePath: exactPath, params: {}, routePattern: segments.join('/') };\n }\n\n // 2. Dynamic match \u2014 try routes sorted by specificity.\n const sortedRoutes = findAllRoutes(baseDir).sort(\n (a, b) => getRouteSpecificity(b) - getRouteSpecificity(a),\n );\n\n for (const route of sortedRoutes) {\n const match = matchDynamicRoute(segments, route);\n if (!match) continue;\n const filePath = path.join(baseDir, route) + extension;\n if (isWithinBase(baseDir, filePath) && fs.existsSync(filePath)) {\n return { filePath, params: match.params, routePattern: route };\n }\n }\n\n return null;\n}\n\n// \u2500\u2500\u2500 Layout discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 every layout.tsx file that wraps a given route file, in\n * outermost-first order (root layout first, nearest layout last).\n *\n * Layout chain example for app/pages/blog/[slug]/page.tsx:\n * app/pages/layout.tsx \u2190 root layout\n * app/pages/blog/layout.tsx \u2190 blog section layout\n *\n * Outermost-first order matches how wrapWithLayouts() nests them:\n * the last layout in the array is the innermost wrapper.\n */\nexport function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[] {\n const layouts: string[] = [];\n\n const rootLayout = path.join(pagesDir, 'layout.tsx');\n if (fs.existsSync(rootLayout)) layouts.push(rootLayout);\n\n const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));\n if (!relativePath || relativePath === '.') return layouts;\n\n const segments = relativePath.split(path.sep).filter(Boolean);\n for (let i = 1; i <= segments.length; i++) {\n const layoutPath = path.join(pagesDir, ...segments.slice(0, i), 'layout.tsx');\n if (fs.existsSync(layoutPath)) layouts.push(layoutPath);\n }\n\n return layouts;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * router.ts \u2014 File-System Based URL Router\r\n *\r\n * Maps incoming URL paths to handler files using Next.js-compatible conventions:\r\n *\r\n * server/users/index.ts \u2192 /users\r\n * server/users/[id].ts \u2192 /users/:id (dynamic segment)\r\n * server/blog/[...slug].ts \u2192 /blog/* (catch-all)\r\n * server/files/[[...path]].ts \u2192 /files or /files/* (optional catch-all)\r\n *\r\n * Route specificity (higher = wins over lower):\r\n * static segment +4 (e.g. 'about')\r\n * dynamic segment +3 (e.g. '[id]')\r\n * catch-all +2 (e.g. '[...slug]')\r\n * optional catch-all +1 (e.g. '[[...path]]')\r\n *\r\n * Path traversal protection:\r\n * matchRoute() rejects URL segments that contain '..' or '.' and verifies\r\n * that the resolved file path stays inside the base directory before\r\n * checking whether the file exists.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\n\r\n// \u2500\u2500\u2500 Route file discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 collects all routable .ts/.tsx files in `dir`, returning paths\r\n * relative to `baseDir` without the file extension.\r\n *\r\n * layout.tsx files are excluded \u2014 they wrap pages but are never routes\r\n * themselves. This mirrors the filter in collectServerPages() so dev-mode\r\n * route matching behaves identically to the production build.\r\n *\r\n * Example output: ['index', 'users/index', 'users/[id]', 'blog/[...slug]']\r\n */\r\nexport function findAllRoutes(dir: string, baseDir: string = dir): string[] {\r\n if (!fs.existsSync(dir)) return [];\r\n\r\n const routes: string[] = [];\r\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\r\n const fullPath = path.join(dir, entry.name);\r\n if (entry.isDirectory()) {\r\n routes.push(...findAllRoutes(fullPath, baseDir));\r\n } else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {\r\n const stem = entry.name.replace(/\\.(tsx|ts)$/, '');\r\n if (stem === 'layout') continue;\r\n routes.push(path.relative(baseDir, fullPath).replace(/\\.(tsx|ts)$/, ''));\r\n }\r\n }\r\n return routes;\r\n}\r\n\r\n// \u2500\u2500\u2500 Dynamic segment 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Attempts to match `urlSegments` against a route that may contain dynamic\r\n * segments ([param]), catch-alls ([...slug]), and optional catch-alls ([[...path]]).\r\n *\r\n * Returns the captured params on success, or null if the route does not match.\r\n *\r\n * Param value types:\r\n * [param] \u2192 string\r\n * [...slug] \u2192 string[] (at least one segment required)\r\n * [[...path]] \u2192 string[] (zero or more segments)\r\n */\r\nexport function matchDynamicRoute(\r\n urlSegments: string[],\r\n routePath: string,\r\n): { params: Record<string, string | string[]> } | null {\r\n const routeSegments = routePath.split(path.sep);\r\n\r\n // 'index' at the end of a route path means the route handles the parent directory URL.\r\n if (routeSegments.at(-1) === 'index') routeSegments.pop();\r\n\r\n const params: Record<string, string | string[]> = {};\r\n let ri = 0; // route segment index\r\n let ui = 0; // URL segment index\r\n\r\n while (ri < routeSegments.length) {\r\n const seg = routeSegments[ri];\r\n\r\n // [[...name]] \u2014 optional catch-all: consumes zero or more remaining URL segments.\r\n const optCatchAll = seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/);\r\n if (optCatchAll) {\r\n params[optCatchAll[1]] = urlSegments.slice(ui);\r\n return { params };\r\n }\r\n\r\n // [[name]] \u2014 optional single segment: consumes zero or one URL segment.\r\n const optDynamic = seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/);\r\n if (optDynamic) {\r\n if (ui < urlSegments.length) {\r\n params[optDynamic[1]] = urlSegments[ui++];\r\n } else {\r\n params[optDynamic[1]] = '';\r\n }\r\n ri++;\r\n continue;\r\n }\r\n\r\n // [...name] \u2014 required catch-all: must consume at least one URL segment.\r\n const catchAll = seg.match(/^\\[\\.\\.\\.(.+)\\]$/);\r\n if (catchAll) {\r\n const remaining = urlSegments.slice(ui);\r\n if (!remaining.length) return null;\r\n params[catchAll[1]] = remaining;\r\n return { params };\r\n }\r\n\r\n // [name] \u2014 single dynamic segment: consumes exactly one URL segment.\r\n const dynamic = seg.match(/^\\[(.+)\\]$/);\r\n if (dynamic) {\r\n if (ui >= urlSegments.length) return null;\r\n params[dynamic[1]] = urlSegments[ui++];\r\n ri++;\r\n continue;\r\n }\r\n\r\n // Static segment \u2014 must match exactly.\r\n if (ui >= urlSegments.length || seg !== urlSegments[ui]) return null;\r\n ui++; ri++;\r\n }\r\n\r\n // All route segments consumed \u2014 URL must be fully consumed too.\r\n return ui < urlSegments.length ? null : { params };\r\n}\r\n\r\n// \u2500\u2500\u2500 Specificity scoring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Computes a specificity score for a route path.\r\n * Used to sort candidate routes so more specific routes shadow catch-alls.\r\n *\r\n * Higher score = more specific:\r\n * static segment 4\r\n * [dynamic] 3\r\n * [...catchAll] 2\r\n * [[...optCatchAll]] 1\r\n */\r\nexport function getRouteSpecificity(routePath: string): number {\r\n return routePath.split(path.sep).reduce((score, seg) => {\r\n if (seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/)) return score + 1; // [[...a]] optional catch-all\r\n if (seg.match(/^\\[\\.\\.\\.(.+)\\]$/)) return score + 2; // [...a] required catch-all\r\n if (seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/)) return score + 3; // [[a]] optional single\r\n if (seg.match(/^\\[(.+)\\]$/)) return score + 4; // [a] required single\r\n return score + 5; // static segment\r\n }, 0);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route match result \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 RouteMatch {\r\n filePath: string;\r\n params: Record<string, string | string[]>;\r\n routePattern: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 Path traversal guard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 only when `filePath` is a descendant of `baseDir`.\r\n * Used to prevent URL path traversal attacks (e.g. /../../etc/passwd).\r\n */\r\nfunction isWithinBase(baseDir: string, filePath: string): boolean {\r\n const rel = path.relative(baseDir, filePath);\r\n return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Resolves a URL path to a route file inside `baseDir`.\r\n *\r\n * Steps:\r\n * 1. Reject '..' or '.' path segments (path traversal guard).\r\n * 2. Try an exact file match (e.g. /about \u2192 baseDir/about.tsx).\r\n * layout.tsx is explicitly excluded from exact matching.\r\n * 3. Sort all discovered routes by specificity (most specific first).\r\n * 4. Return the first dynamic route that matches.\r\n *\r\n * @param urlPath The URL path to match (e.g. '/users/42').\r\n * @param baseDir Absolute path to the directory containing route files.\r\n * @param extension File extension to look for ('.tsx' or '.ts').\r\n */\r\nexport function matchRoute(\r\n urlPath: string,\r\n baseDir: string,\r\n extension = '.tsx',\r\n): RouteMatch | null {\r\n // Normalise trailing slashes: /test/ \u2192 /test, but keep root / as-is.\r\n const normPath = urlPath.length > 1 ? urlPath.replace(/\\/+$/, '') : urlPath;\r\n // Split into segments, rejecting any that attempt path traversal.\r\n const rawSegments = normPath === '/' ? [] : normPath.slice(1).split('/');\r\n if (rawSegments.some(s => s === '..' || s === '.')) return null;\r\n\r\n // For the root URL, look for an index file.\r\n const segments = rawSegments.length === 0 ? ['index'] : rawSegments;\r\n\r\n // 1. Exact match: /about \u2192 about.tsx \u2014 never resolve to a layout file.\r\n const exactPath = path.join(baseDir, ...segments) + extension;\r\n const exactStem = path.basename(exactPath, extension);\r\n if (!isWithinBase(baseDir, exactPath)) return null;\r\n if (exactStem !== 'layout' && fs.existsSync(exactPath)) {\r\n return { filePath: exactPath, params: {}, routePattern: segments.join('/') };\r\n }\r\n\r\n // 2. Dynamic match \u2014 use rawSegments (not ['index']) so that [param] routes\r\n // do not accidentally match '/' by consuming the synthetic 'index' segment.\r\n const sortedRoutes = findAllRoutes(baseDir).sort(\r\n (a, b) => getRouteSpecificity(b) - getRouteSpecificity(a),\r\n );\r\n\r\n for (const route of sortedRoutes) {\r\n const match = matchDynamicRoute(rawSegments, route);\r\n if (!match) continue;\r\n const filePath = path.join(baseDir, route) + extension;\r\n if (!isWithinBase(baseDir, filePath)) continue;\r\n if (fs.existsSync(filePath)) {\r\n return { filePath, params: match.params, routePattern: route };\r\n }\r\n }\r\n\r\n return null;\r\n}\r\n\r\n// \u2500\u2500\u2500 Layout discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 every layout.tsx file that wraps a given route file, in\r\n * outermost-first order (root layout first, nearest layout last).\r\n *\r\n * Layout chain example for app/pages/blog/[slug]/page.tsx:\r\n * app/pages/layout.tsx \u2190 root layout\r\n * app/pages/blog/layout.tsx \u2190 blog section layout\r\n *\r\n * The outermost-first order matches how wrapWithLayouts() nests them:\r\n * the last layout in the array is the innermost wrapper.\r\n */\r\nexport function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[] {\r\n const layouts: string[] = [];\r\n\r\n // Root layout wraps everything.\r\n const rootLayout = path.join(pagesDir, 'layout.tsx');\r\n if (fs.existsSync(rootLayout)) layouts.push(rootLayout);\r\n\r\n // Walk the directory hierarchy from pagesDir to the file's parent.\r\n const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));\r\n if (!relativePath || relativePath === '.') return layouts;\r\n\r\n const segments = relativePath.split(path.sep).filter(s => s !== '.');\r\n for (let i = 1; i <= segments.length; i++) {\r\n const layoutPath = path.join(pagesDir, ...segments.slice(0, i), 'layout.tsx');\r\n if (fs.existsSync(layoutPath)) layouts.push(layoutPath);\r\n }\r\n\r\n return layouts;\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/ssr.js CHANGED
@@ -74,15 +74,21 @@ function renderStyleTag(tag) {
74
74
  return ` <style${media}>${tag.content ?? ""}</style>`;
75
75
  }
76
76
  function renderManagedHeadTags(store) {
77
+ const headScripts = store.script.filter((s) => (s.position ?? "head") === "head");
77
78
  const tags = [
78
79
  ...store.meta.map(renderMetaTag),
79
80
  ...store.link.map(renderLinkTag),
80
81
  ...store.style.map(renderStyleTag),
81
- ...store.script.map(renderScriptTag)
82
+ ...headScripts.map(renderScriptTag)
82
83
  ];
83
84
  if (tags.length === 0) return [];
84
85
  return [" <!--n-head-->", ...tags, " <!--/n-head-->"];
85
86
  }
87
+ function renderManagedBodyScripts(store) {
88
+ const bodyScripts = store.script.filter((s) => s.position === "body");
89
+ if (bodyScripts.length === 0) return [];
90
+ return [" <!--n-body-scripts-->", ...bodyScripts.map(renderScriptTag), " <!--/n-body-scripts-->"];
91
+ }
86
92
  async function serverSideRender(url, res, pagesDir, isDev = false) {
87
93
  const skipClientSSR = url.includes("__hmr=1");
88
94
  const cleanUrl = url.split("?")[0];
@@ -95,13 +101,22 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
95
101
  }
96
102
  const { filePath, params, routePattern } = routeMatch;
97
103
  log.verbose(`SSR ${cleanUrl} -> ${path.relative(process.cwd(), filePath)}`);
104
+ const searchParams = new URL(url, "http://localhost").searchParams;
105
+ const queryParams = {};
106
+ searchParams.forEach((_, k) => {
107
+ if (!(k in params)) {
108
+ const all = searchParams.getAll(k);
109
+ queryParams[k] = all.length > 1 ? all : all[0];
110
+ }
111
+ });
112
+ const mergedParams = { ...queryParams, ...params };
98
113
  const layoutPaths = findLayoutsForRoute(filePath, pagesDir);
99
114
  const { default: PageComponent } = await tsImport(
100
115
  pathToFileURL(filePath).href,
101
116
  { parentURL: import.meta.url }
102
117
  );
103
118
  const wrappedElement = await wrapWithLayouts(
104
- createElement(PageComponent, params),
119
+ createElement(PageComponent, mergedParams),
105
120
  layoutPaths
106
121
  );
107
122
  const registry = /* @__PURE__ */ new Map();
@@ -119,7 +134,7 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
119
134
  const store = await runWithHtmlStore(async () => {
120
135
  appHtml = await renderElementToHtml(wrappedElement, ctx);
121
136
  });
122
- const pageTitle = resolveTitle(store.titleOps, "Nuke");
137
+ const pageTitle = resolveTitle(store.titleOps, "NukeJS");
123
138
  const headLines = [
124
139
  ' <meta charset="utf-8" />',
125
140
  ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
@@ -133,6 +148,8 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
133
148
  params,
134
149
  debug: toClientDebugLevel(getDebugLevel())
135
150
  }).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
151
+ const bodyScriptLines = renderManagedBodyScripts(store);
152
+ const bodyScriptsHtml = bodyScriptLines.length > 0 ? "\n" + bodyScriptLines.join("\n") + "\n" : "";
136
153
  const html = `<!DOCTYPE html>
137
154
  ${openTag("html", store.htmlAttrs)}
138
155
  <head>
@@ -162,7 +179,7 @@ ${openTag("body", store.bodyAttrs)}
162
179
  </script>
163
180
 
164
181
  ${isDev ? '<script type="module" src="/__hmr.js"></script>' : ""}
165
- </body>
182
+ ${bodyScriptsHtml}</body>
166
183
  </html>`;
167
184
  res.setHeader("Content-Type", "text/html");
168
185
  res.end(html);
package/dist/ssr.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/ssr.ts"],
4
- "sourcesContent": ["/**\r\n * ssr.ts \u2014 Server-Side Rendering Pipeline (Dev Mode)\r\n *\r\n * Handles the full page render cycle for `nuke dev`:\r\n *\r\n * 1. Match the incoming URL to a page file in app/pages using the file-system\r\n * router.\r\n * 2. Discover the layout chain (layout.tsx files from root to the page dir).\r\n * 3. Dynamically import the page and layout modules (always fresh via tsImport).\r\n * 4. Walk the import tree to discover all \"use client\" components.\r\n * 5. Render the wrapped element tree with the async renderer.\r\n * 6. Flush the html-store (title, meta, link, script, style tags).\r\n * 7. Assemble and send the full HTML document including:\r\n * - The rendered app HTML\r\n * - An importmap pointing react/nukejs to the bundled versions\r\n * - A __n_data JSON blob with hydration IDs and runtime config\r\n * - An inline bootstrap <script> that calls initRuntime()\r\n * - (dev only) A /__hmr.js script for Hot Module Replacement\r\n *\r\n * In production, a pre-built standalone handler (generated by build-common.ts)\r\n * handles each page without dynamic imports or file-system access.\r\n *\r\n * HMR fast path:\r\n * When the request URL contains `__hmr=1` (added by the HMR client during\r\n * soft navigation), the renderer skips client-component renderToString\r\n * (ctx.skipClientSSR = true). This speeds up HMR reloads because the\r\n * client already has the DOM in place and only needs fresh server markup.\r\n *\r\n * Head tag sentinels:\r\n * Every useHtml()-generated <meta>, <link>, <style>, and <script> tag is\r\n * wrapped in <!--n-head-->\u2026<!--/n-head--> comment sentinels. The client\r\n * runtime uses these to diff and sync head tags on SPA navigation without\r\n * touching permanent tags (charset, viewport, importmap, runtime script).\r\n * Pages with no useHtml head tags emit no sentinels.\r\n */\r\n\r\nimport path from 'path';\r\nimport { createElement } from 'react';\r\nimport { pathToFileURL } from 'url';\r\nimport { tsImport } from 'tsx/esm/api';\r\nimport type { ServerResponse } from 'http';\r\nimport { log, getDebugLevel, type DebugLevel } from './logger';\r\nimport { matchRoute, findLayoutsForRoute } from './router';\r\nimport { findClientComponentsInTree } from './component-analyzer';\r\nimport { renderElementToHtml, type RenderContext } from './renderer';\r\nimport {\r\n runWithHtmlStore,\r\n resolveTitle,\r\n type HtmlStore,\r\n type HtmlAttrs,\r\n type BodyAttrs,\r\n type MetaTag,\r\n type LinkTag,\r\n type ScriptTag,\r\n type StyleTag,\r\n} from './html-store';\r\n\r\n// \u2500\u2500\u2500 Layout wrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Wraps a page element in its layout chain, outermost-first.\r\n * Each layout receives `{ children: innerElement }` as props.\r\n *\r\n * tsImport creates a fresh isolated module namespace per call so edits to\r\n * layouts are reflected immediately without a server restart.\r\n */\r\nasync function wrapWithLayouts(pageElement: any, layoutPaths: string[]): Promise<any> {\r\n let element = pageElement;\r\n // Iterate in reverse so the outermost layout wraps last (becomes the root).\r\n for (let i = layoutPaths.length - 1; i >= 0; i--) {\r\n const { default: LayoutComponent } = await tsImport(\r\n pathToFileURL(layoutPaths[i]).href,\r\n { parentURL: import.meta.url },\r\n );\r\n element = createElement(LayoutComponent, { children: element });\r\n }\r\n return element;\r\n}\r\n\r\n// \u2500\u2500\u2500 Debug level conversion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 the server-side DebugLevel to the string format expected by the\r\n * browser's makeLogger() function in bundle.ts.\r\n */\r\nfunction toClientDebugLevel(level: DebugLevel): string {\r\n if (level === true) return 'verbose';\r\n if (level === 'info') return 'info';\r\n if (level === 'error') return 'error';\r\n return 'silent';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML serialization helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nfunction escapeAttr(str: string): string {\r\n return str.replace(/&/g, '&amp;').replace(/\"/g, '&quot;');\r\n}\r\n\r\n/** Serializes an attribute map to `key=\"value\" \u2026`, omitting undefined/false values. */\r\nfunction renderAttrs(attrs: Record<string, string | boolean | undefined>): string {\r\n return Object.entries(attrs)\r\n .filter(([, v]) => v !== undefined && v !== false)\r\n .map(([k, v]) => v === true ? k : `${k}=\"${escapeAttr(String(v))}\"`)\r\n .join(' ');\r\n}\r\n\r\n/** Returns `<tag attrs>` or `<tag>` depending on whether attrs are non-empty. */\r\nfunction openTag(tag: string, attrs: Record<string, string | undefined>): string {\r\n const str = renderAttrs(attrs);\r\n return str ? `<${tag} ${str}>` : `<${tag}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Head tag renderers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nfunction metaKey(k: string): string {\r\n return k === 'httpEquiv' ? 'http-equiv' : k;\r\n}\r\n\r\nfunction linkKey(k: string): string {\r\n if (k === 'hrefLang') return 'hreflang';\r\n if (k === 'crossOrigin') return 'crossorigin';\r\n return k;\r\n}\r\n\r\nfunction renderMetaTag(tag: MetaTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[metaKey(k)] = v;\r\n return ` <meta ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderLinkTag(tag: LinkTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[linkKey(k)] = v;\r\n return ` <link ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderScriptTag(tag: ScriptTag): string {\r\n const attrs: Record<string, string | boolean | undefined> = {\r\n src: tag.src,\r\n type: tag.type,\r\n crossorigin: tag.crossOrigin,\r\n integrity: tag.integrity,\r\n defer: tag.defer,\r\n async: tag.async,\r\n nomodule: tag.noModule,\r\n };\r\n const attrStr = renderAttrs(attrs);\r\n const open = attrStr ? `<script ${attrStr}>` : '<script>';\r\n return ` ${open}${tag.src ? '' : (tag.content ?? '')}</script>`;\r\n}\r\n\r\nfunction renderStyleTag(tag: StyleTag): string {\r\n const media = tag.media ? ` media=\"${escapeAttr(tag.media)}\"` : '';\r\n return ` <style${media}>${tag.content ?? ''}</style>`;\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced head tags wrapped in <!--n-head--> sentinels.\r\n * Returns an empty array when none of the stores have any tags (no sentinels\r\n * emitted for pages that don't call useHtml).\r\n */\r\nfunction renderManagedHeadTags(store: HtmlStore): string[] {\r\n const tags = [\r\n ...store.meta.map(renderMetaTag),\r\n ...store.link.map(renderLinkTag),\r\n ...store.style.map(renderStyleTag),\r\n ...store.script.map(renderScriptTag),\r\n ];\r\n if (tags.length === 0) return [];\r\n return [' <!--n-head-->', ...tags, ' <!--/n-head-->'];\r\n}\r\n\r\n// \u2500\u2500\u2500 Main SSR handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 page for the given URL and writes the full HTML response.\r\n *\r\n * @param url The raw request URL (may include query string).\r\n * @param res Node ServerResponse to write to.\r\n * @param pagesDir Absolute path to the app/pages directory.\r\n * @param isDev When true, injects the HMR client script into the page.\r\n */\r\nexport async function serverSideRender(\r\n url: string,\r\n res: ServerResponse,\r\n pagesDir: string,\r\n isDev = false,\r\n): Promise<void> {\r\n const skipClientSSR = url.includes('__hmr=1');\r\n const cleanUrl = url.split('?')[0];\r\n\r\n // \u2500\u2500 Route resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const routeMatch = matchRoute(cleanUrl, pagesDir);\r\n if (!routeMatch) {\r\n log.verbose(`No route found for: ${url}`);\r\n res.statusCode = 404;\r\n res.end('Page not found');\r\n return;\r\n }\r\n\r\n const { filePath, params, routePattern } = routeMatch;\r\n log.verbose(`SSR ${cleanUrl} -> ${path.relative(process.cwd(), filePath)}`);\r\n\r\n // \u2500\u2500 Module import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // tsImport bypasses Node's ESM module cache entirely so edits are reflected\r\n // immediately on every request in dev.\r\n const layoutPaths = findLayoutsForRoute(filePath, pagesDir);\r\n const { default: PageComponent } = await tsImport(\r\n pathToFileURL(filePath).href,\r\n { parentURL: import.meta.url },\r\n );\r\n const wrappedElement = await wrapWithLayouts(\r\n createElement(PageComponent, params),\r\n layoutPaths,\r\n );\r\n\r\n // \u2500\u2500 Client component discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Walk the import tree for both the page and its layout chain.\r\n const registry = new Map<string, string>();\r\n for (const [id, p] of findClientComponentsInTree(filePath, pagesDir))\r\n registry.set(id, p);\r\n for (const layoutPath of layoutPaths)\r\n for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))\r\n registry.set(id, p);\r\n\r\n log.verbose(\r\n `Page ${routePattern}: found ${registry.size} client component(s)`,\r\n `[${[...registry.keys()].join(', ')}]`,\r\n );\r\n\r\n // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const ctx: RenderContext = { registry, hydrated: new Set(), skipClientSSR };\r\n\r\n let appHtml = '';\r\n const store: HtmlStore = await runWithHtmlStore(async () => {\r\n appHtml = await renderElementToHtml(wrappedElement, ctx);\r\n });\r\n\r\n // \u2500\u2500 Head assembly \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const pageTitle = resolveTitle(store.titleOps, 'Nuke');\r\n\r\n const headLines: string[] = [\r\n ' <meta charset=\"utf-8\" />',\r\n ' <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />',\r\n ` <title>${escapeAttr(pageTitle)}</title>`,\r\n ...renderManagedHeadTags(store),\r\n ];\r\n\r\n // \u2500\u2500 Runtime data blob \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Escape </script> sequences so the JSON cannot break out of the script tag.\r\n const runtimeData = JSON.stringify({\r\n hydrateIds: [...ctx.hydrated],\r\n allIds: [...registry.keys()],\r\n url,\r\n params,\r\n debug: toClientDebugLevel(getDebugLevel()),\r\n })\r\n .replace(/</g, '\\\\u003c')\r\n .replace(/>/g, '\\\\u003e')\r\n .replace(/&/g, '\\\\u0026');\r\n\r\n // \u2500\u2500 Full document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const html = `<!DOCTYPE html>\r\n${openTag('html', store.htmlAttrs)}\r\n<head>\r\n${headLines.join('\\n')}\r\n</head>\r\n${openTag('body', store.bodyAttrs)}\r\n <div id=\"app\">${appHtml}</div>\r\n\r\n <script id=\"__n_data\" type=\"application/json\">${runtimeData}</script>\r\n\r\n <script type=\"importmap\">\r\n{\r\n \"imports\": {\r\n \"react\": \"/__react.js\",\r\n \"react-dom/client\": \"/__react.js\",\r\n \"react/jsx-runtime\": \"/__react.js\",\r\n \"nukejs\": \"/__n.js\"\r\n }\r\n}\r\n </script>\r\n\r\n <script type=\"module\">\r\n await import('react');\r\n const { initRuntime } = await import('nukejs');\r\n const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n initRuntime(data);\r\n </script>\r\n\r\n ${isDev ? '<script type=\"module\" src=\"/__hmr.js\"></script>' : ''}\r\n</body>\r\n</html>`;\r\n\r\n res.setHeader('Content-Type', 'text/html');\r\n res.end(html);\r\n}"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * ssr.ts \u2014 Server-Side Rendering Pipeline (Dev Mode)\r\n *\r\n * Handles the full page render cycle for `nuke dev`:\r\n *\r\n * 1. Match the incoming URL to a page file in app/pages using the file-system\r\n * router.\r\n * 2. Discover the layout chain (layout.tsx files from root to the page dir).\r\n * 3. Dynamically import the page and layout modules (always fresh via tsImport).\r\n * 4. Walk the import tree to discover all \"use client\" components.\r\n * 5. Render the wrapped element tree with the async renderer.\r\n * 6. Flush the html-store (title, meta, link, script, style tags).\r\n * 7. Assemble and send the full HTML document including:\r\n * - The rendered app HTML\r\n * - An importmap pointing react/nukejs to the bundled versions\r\n * - A __n_data JSON blob with hydration IDs and runtime config\r\n * - An inline bootstrap <script> that calls initRuntime()\r\n * - (dev only) A /__hmr.js script for Hot Module Replacement\r\n *\r\n * In production, a pre-built standalone handler (generated by build-common.ts)\r\n * handles each page without dynamic imports or file-system access.\r\n *\r\n * HMR fast path:\r\n * When the request URL contains `__hmr=1` (added by the HMR client during\r\n * soft navigation), the renderer skips client-component renderToString\r\n * (ctx.skipClientSSR = true). This speeds up HMR reloads because the\r\n * client already has the DOM in place and only needs fresh server markup.\r\n *\r\n * Head tag sentinels:\r\n * Every useHtml()-generated <meta>, <link>, <style>, and <script> tag is\r\n * wrapped in <!--n-head-->\u2026<!--/n-head--> comment sentinels. The client\r\n * runtime uses these to diff and sync head tags on SPA navigation without\r\n * touching permanent tags (charset, viewport, importmap, runtime script).\r\n * Pages with no useHtml head tags emit no sentinels.\r\n */\r\n\r\nimport path from 'path';\r\nimport { createElement } from 'react';\r\nimport { pathToFileURL } from 'url';\r\nimport { tsImport } from 'tsx/esm/api';\r\nimport type { ServerResponse } from 'http';\r\nimport { log, getDebugLevel, type DebugLevel } from './logger';\r\nimport { matchRoute, findLayoutsForRoute } from './router';\r\nimport { findClientComponentsInTree } from './component-analyzer';\r\nimport { renderElementToHtml, type RenderContext } from './renderer';\r\nimport {\r\n runWithHtmlStore,\r\n resolveTitle,\r\n type HtmlStore,\r\n type HtmlAttrs,\r\n type BodyAttrs,\r\n type MetaTag,\r\n type LinkTag,\r\n type ScriptTag,\r\n type StyleTag,\r\n} from './html-store';\r\n\r\n// \u2500\u2500\u2500 Layout wrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Wraps a page element in its layout chain, outermost-first.\r\n * Each layout receives `{ children: innerElement }` as props.\r\n *\r\n * tsImport creates a fresh isolated module namespace per call so edits to\r\n * layouts are reflected immediately without a server restart.\r\n */\r\nasync function wrapWithLayouts(pageElement: any, layoutPaths: string[]): Promise<any> {\r\n let element = pageElement;\r\n // Iterate in reverse so the outermost layout wraps last (becomes the root).\r\n for (let i = layoutPaths.length - 1; i >= 0; i--) {\r\n const { default: LayoutComponent } = await tsImport(\r\n pathToFileURL(layoutPaths[i]).href,\r\n { parentURL: import.meta.url },\r\n );\r\n element = createElement(LayoutComponent, { children: element });\r\n }\r\n return element;\r\n}\r\n\r\n// \u2500\u2500\u2500 Debug level conversion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 the server-side DebugLevel to the string format expected by the\r\n * browser's makeLogger() function in bundle.ts.\r\n */\r\nfunction toClientDebugLevel(level: DebugLevel): string {\r\n if (level === true) return 'verbose';\r\n if (level === 'info') return 'info';\r\n if (level === 'error') return 'error';\r\n return 'silent';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML serialization helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nfunction escapeAttr(str: string): string {\r\n return str.replace(/&/g, '&amp;').replace(/\"/g, '&quot;');\r\n}\r\n\r\n/** Serializes an attribute map to `key=\"value\" \u2026`, omitting undefined/false values. */\r\nfunction renderAttrs(attrs: Record<string, string | boolean | undefined>): string {\r\n return Object.entries(attrs)\r\n .filter(([, v]) => v !== undefined && v !== false)\r\n .map(([k, v]) => v === true ? k : `${k}=\"${escapeAttr(String(v))}\"`)\r\n .join(' ');\r\n}\r\n\r\n/** Returns `<tag attrs>` or `<tag>` depending on whether attrs are non-empty. */\r\nfunction openTag(tag: string, attrs: Record<string, string | undefined>): string {\r\n const str = renderAttrs(attrs);\r\n return str ? `<${tag} ${str}>` : `<${tag}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Head tag renderers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nfunction metaKey(k: string): string {\r\n return k === 'httpEquiv' ? 'http-equiv' : k;\r\n}\r\n\r\nfunction linkKey(k: string): string {\r\n if (k === 'hrefLang') return 'hreflang';\r\n if (k === 'crossOrigin') return 'crossorigin';\r\n return k;\r\n}\r\n\r\nfunction renderMetaTag(tag: MetaTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[metaKey(k)] = v;\r\n return ` <meta ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderLinkTag(tag: LinkTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[linkKey(k)] = v;\r\n return ` <link ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderScriptTag(tag: ScriptTag): string {\r\n const attrs: Record<string, string | boolean | undefined> = {\r\n src: tag.src,\r\n type: tag.type,\r\n crossorigin: tag.crossOrigin,\r\n integrity: tag.integrity,\r\n defer: tag.defer,\r\n async: tag.async,\r\n nomodule: tag.noModule,\r\n };\r\n const attrStr = renderAttrs(attrs);\r\n const open = attrStr ? `<script ${attrStr}>` : '<script>';\r\n return ` ${open}${tag.src ? '' : (tag.content ?? '')}</script>`;\r\n}\r\n\r\nfunction renderStyleTag(tag: StyleTag): string {\r\n const media = tag.media ? ` media=\"${escapeAttr(tag.media)}\"` : '';\r\n return ` <style${media}>${tag.content ?? ''}</style>`;\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced head tags wrapped in <!--n-head--> sentinels.\r\n * Scripts with position='body' are excluded here \u2014 they go in renderManagedBodyScripts().\r\n * Returns an empty array when none of the stores have any tags (no sentinels\r\n * emitted for pages that don't call useHtml).\r\n */\r\nfunction renderManagedHeadTags(store: HtmlStore): string[] {\r\n const headScripts = store.script.filter(s => (s.position ?? 'head') === 'head');\r\n const tags = [\r\n ...store.meta.map(renderMetaTag),\r\n ...store.link.map(renderLinkTag),\r\n ...store.style.map(renderStyleTag),\r\n ...headScripts.map(renderScriptTag),\r\n ];\r\n if (tags.length === 0) return [];\r\n return [' <!--n-head-->', ...tags, ' <!--/n-head-->'];\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced scripts with position='body', wrapped in\r\n * <!--n-body-scripts-->\u2026<!--/n-body-scripts--> sentinels.\r\n * Injected just before </body> so scripts execute after page content is in the DOM.\r\n * Returns an empty array when there are no body-position scripts.\r\n */\r\nfunction renderManagedBodyScripts(store: HtmlStore): string[] {\r\n const bodyScripts = store.script.filter(s => s.position === 'body');\r\n if (bodyScripts.length === 0) return [];\r\n return [' <!--n-body-scripts-->', ...bodyScripts.map(renderScriptTag), ' <!--/n-body-scripts-->'];\r\n}\r\n\r\n// \u2500\u2500\u2500 Main SSR handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 page for the given URL and writes the full HTML response.\r\n *\r\n * @param url The raw request URL (may include query string).\r\n * @param res Node ServerResponse to write to.\r\n * @param pagesDir Absolute path to the app/pages directory.\r\n * @param isDev When true, injects the HMR client script into the page.\r\n */\r\nexport async function serverSideRender(\r\n url: string,\r\n res: ServerResponse,\r\n pagesDir: string,\r\n isDev = false,\r\n): Promise<void> {\r\n const skipClientSSR = url.includes('__hmr=1');\r\n const cleanUrl = url.split('?')[0];\r\n\r\n // \u2500\u2500 Route resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const routeMatch = matchRoute(cleanUrl, pagesDir);\r\n if (!routeMatch) {\r\n log.verbose(`No route found for: ${url}`);\r\n res.statusCode = 404;\r\n res.end('Page not found');\r\n return;\r\n }\r\n\r\n const { filePath, params, routePattern } = routeMatch;\r\n log.verbose(`SSR ${cleanUrl} -> ${path.relative(process.cwd(), filePath)}`);\r\n\r\n // Merge query string params into props so page components can access them\r\n // the same way in dev and production. Route params take precedence so a\r\n // ?slug=x in the query string cannot shadow a [slug] dynamic segment.\r\n const searchParams = new URL(url, 'http://localhost').searchParams;\r\n const queryParams: Record<string, string | string[]> = {};\r\n searchParams.forEach((_, k) => {\r\n if (!(k in params)) {\r\n const all = searchParams.getAll(k);\r\n queryParams[k] = all.length > 1 ? all : all[0];\r\n }\r\n });\r\n const mergedParams = { ...queryParams, ...params };\r\n\r\n // \u2500\u2500 Module import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // tsImport bypasses Node's ESM module cache entirely so edits are reflected\r\n // immediately on every request in dev.\r\n const layoutPaths = findLayoutsForRoute(filePath, pagesDir);\r\n const { default: PageComponent } = await tsImport(\r\n pathToFileURL(filePath).href,\r\n { parentURL: import.meta.url },\r\n );\r\n const wrappedElement = await wrapWithLayouts(\r\n createElement(PageComponent, mergedParams),\r\n layoutPaths,\r\n );\r\n\r\n // \u2500\u2500 Client component discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Walk the import tree for both the page and its layout chain.\r\n const registry = new Map<string, string>();\r\n for (const [id, p] of findClientComponentsInTree(filePath, pagesDir))\r\n registry.set(id, p);\r\n for (const layoutPath of layoutPaths)\r\n for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))\r\n registry.set(id, p);\r\n\r\n log.verbose(\r\n `Page ${routePattern}: found ${registry.size} client component(s)`,\r\n `[${[...registry.keys()].join(', ')}]`,\r\n );\r\n\r\n // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const ctx: RenderContext = { registry, hydrated: new Set(), skipClientSSR };\r\n\r\n let appHtml = '';\r\n const store: HtmlStore = await runWithHtmlStore(async () => {\r\n appHtml = await renderElementToHtml(wrappedElement, ctx);\r\n });\r\n\r\n // \u2500\u2500 Head assembly \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const pageTitle = resolveTitle(store.titleOps, 'NukeJS');\r\n\r\n const headLines: string[] = [\r\n ' <meta charset=\"utf-8\" />',\r\n ' <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />',\r\n ` <title>${escapeAttr(pageTitle)}</title>`,\r\n ...renderManagedHeadTags(store),\r\n ];\r\n\r\n // \u2500\u2500 Runtime data blob \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Escape </script> sequences so the JSON cannot break out of the script tag.\r\n const runtimeData = JSON.stringify({\r\n hydrateIds: [...ctx.hydrated],\r\n allIds: [...registry.keys()],\r\n url,\r\n params,\r\n debug: toClientDebugLevel(getDebugLevel()),\r\n })\r\n .replace(/</g, '\\\\u003c')\r\n .replace(/>/g, '\\\\u003e')\r\n .replace(/&/g, '\\\\u0026');\r\n\r\n // \u2500\u2500 Body scripts (position='body') \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const bodyScriptLines = renderManagedBodyScripts(store);\r\n const bodyScriptsHtml = bodyScriptLines.length > 0\r\n ? '\\n' + bodyScriptLines.join('\\n') + '\\n'\r\n : '';\r\n\r\n // \u2500\u2500 Full document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 const html = `<!DOCTYPE html>\r\n${openTag('html', store.htmlAttrs)}\r\n<head>\r\n${headLines.join('\\n')}\r\n</head>\r\n${openTag('body', store.bodyAttrs)}\r\n <div id=\"app\">${appHtml}</div>\r\n\r\n <script id=\"__n_data\" type=\"application/json\">${runtimeData}</script>\r\n\r\n <script type=\"importmap\">\r\n{\r\n \"imports\": {\r\n \"react\": \"/__react.js\",\r\n \"react-dom/client\": \"/__react.js\",\r\n \"react/jsx-runtime\": \"/__react.js\",\r\n \"nukejs\": \"/__n.js\"\r\n }\r\n}\r\n </script>\r\n\r\n <script type=\"module\">\r\n await import('react');\r\n const { initRuntime } = await import('nukejs');\r\n const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n initRuntime(data);\r\n </script>\r\n\r\n ${isDev ? '<script type=\"module\" src=\"/__hmr.js\"></script>' : ''}\r\n${bodyScriptsHtml}</body>\r\n</html>`;\r\n\r\n res.setHeader('Content-Type', 'text/html');\r\n res.end(html);\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/use-html.js CHANGED
@@ -79,7 +79,11 @@ function clientUseHtml(options) {
79
79
  if (tag.integrity) el.integrity = tag.integrity;
80
80
  if (tag.content) el.textContent = tag.content;
81
81
  el.dataset.usehtml = id;
82
- document.head.appendChild(el);
82
+ if (tag.position === "body") {
83
+ document.body.appendChild(el);
84
+ } else {
85
+ document.head.appendChild(el);
86
+ }
83
87
  return el;
84
88
  });
85
89
  return () => nodes.forEach((n) => n.remove());
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/use-html.ts"],
4
- "sourcesContent": ["/**\r\n * use-html.ts \u2014 useHtml() Hook\r\n *\r\n * A universal hook that lets React components control the HTML document's\r\n * <head>, <html> attributes, and <body> attributes from within JSX \u2014 on both\r\n * the server (SSR) and the client (hydration / SPA navigation).\r\n *\r\n * Server behaviour:\r\n * Writes directly into the per-request html-store. The store is flushed\r\n * into the HTML document after the component tree is fully rendered.\r\n * useHtml() is called synchronously during rendering so no actual React\r\n * hook is used \u2014 it's just a function that pokes the globalThis store.\r\n *\r\n * Client behaviour:\r\n * Uses useEffect() to apply changes to the live document and clean them up\r\n * when the component unmounts (navigation, unmount). Each effect is keyed\r\n * to its options object via JSON.stringify so React re-runs it when the\r\n * options change.\r\n *\r\n * Layout title templates:\r\n * Layouts typically set title as a function so they can append a site suffix:\r\n *\r\n * ```tsx\r\n * // Root layout\r\n * useHtml({ title: (prev) => `${prev} | Acme` });\r\n *\r\n * // A page\r\n * useHtml({ title: 'About' });\r\n * // \u2192 'About | Acme'\r\n * ```\r\n *\r\n * Example usage:\r\n * ```tsx\r\n * useHtml({\r\n * title: 'Blog Post',\r\n * meta: [{ name: 'description', content: 'A great post' }],\r\n * link: [{ rel: 'canonical', href: 'https://example.com/post' }],\r\n * });\r\n * ```\r\n */\r\n\r\nimport { useEffect } from 'react';\r\nimport { getHtmlStore } from './html-store';\r\nimport type {\r\n TitleValue,\r\n HtmlAttrs,\r\n BodyAttrs,\r\n MetaTag,\r\n LinkTag,\r\n ScriptTag,\r\n StyleTag,\r\n} from './html-store';\r\n\r\n// Re-export types so consumers can import them from 'nukejs' directly.\r\nexport type { TitleValue, HtmlAttrs, BodyAttrs, MetaTag, LinkTag, ScriptTag, StyleTag };\r\n\r\n// \u2500\u2500\u2500 Options type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 HtmlOptions {\r\n /**\r\n * Page title.\r\n * string \u2192 sets the title directly (page wins over layout).\r\n * function \u2192 receives the inner title; use in layouts to append a suffix:\r\n * `(prev) => \\`${prev} | MySite\\``\r\n */\r\n title?: TitleValue;\r\n /** Attributes merged onto <html>. Per-attribute last-write-wins. */\r\n htmlAttrs?: HtmlAttrs;\r\n /** Attributes merged onto <body>. Per-attribute last-write-wins. */\r\n bodyAttrs?: BodyAttrs;\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 Universal hook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Applies HTML document customisations from a React component.\r\n * Automatically detects whether it is running on the server or the client.\r\n */\r\nexport function useHtml(options: HtmlOptions): void {\r\n if (typeof document === 'undefined') {\r\n // Running on the server (SSR) \u2014 write synchronously to the request store.\r\n serverUseHtml(options);\r\n } else {\r\n // Running in the browser \u2014 use React effects.\r\n clientUseHtml(options);\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Server implementation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Writes options directly into the active per-request html-store.\r\n * Called synchronously during SSR; no React hooks are used.\r\n *\r\n * Title operations are *pushed* (not replaced) so both layout and page values\r\n * are preserved for resolveTitle() to process in the correct order.\r\n */\r\nfunction serverUseHtml(options: HtmlOptions): void {\r\n const store = getHtmlStore();\r\n if (!store) return; // Called outside of a runWithHtmlStore context \u2014 ignore.\r\n\r\n if (options.title !== undefined) store.titleOps.push(options.title);\r\n if (options.htmlAttrs) Object.assign(store.htmlAttrs, options.htmlAttrs);\r\n if (options.bodyAttrs) Object.assign(store.bodyAttrs, options.bodyAttrs);\r\n if (options.meta?.length) store.meta.push(...options.meta);\r\n if (options.link?.length) store.link.push(...options.link);\r\n if (options.script?.length) store.script.push(...options.script);\r\n if (options.style?.length) store.style.push(...options.style);\r\n}\r\n\r\n// \u2500\u2500\u2500 Client implementation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** Monotonically incrementing counter for generating unique dataset IDs. */\r\nlet _uid = 0;\r\nconst uid = () => `uh${++_uid}`;\r\n\r\n/**\r\n * Applies options to the live document using React effects.\r\n * Each effect type is independent so a change to `title` does not re-run the\r\n * `meta` effect and vice versa.\r\n *\r\n * Cleanup functions restore the previous state so unmounting a component that\r\n * called useHtml() reverses its changes (important for SPA navigation).\r\n */\r\nfunction clientUseHtml(options: HtmlOptions): void {\r\n // \u2500\u2500 title \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (options.title === undefined) return;\r\n const prev = document.title;\r\n document.title = typeof options.title === 'function'\r\n ? options.title(prev)\r\n : options.title;\r\n return () => { document.title = prev; };\r\n }, [typeof options.title === 'function' // eslint-disable-line react-hooks/exhaustive-deps\r\n ? options.title.toString()\r\n : options.title]);\r\n\r\n // \u2500\u2500 <html> attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.htmlAttrs) return;\r\n return applyAttrs(document.documentElement, options.htmlAttrs);\r\n }, [JSON.stringify(options.htmlAttrs)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <body> attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.bodyAttrs) return;\r\n return applyAttrs(document.body, options.bodyAttrs);\r\n }, [JSON.stringify(options.bodyAttrs)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <meta> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.meta?.length) return;\r\n const id = uid();\r\n const nodes = options.meta.map((tag) => {\r\n const el = document.createElement('meta');\r\n for (const [k, v] of Object.entries(tag)) {\r\n if (v !== undefined) el.setAttribute(domAttr(k), v);\r\n }\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.meta)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <link> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.link?.length) return;\r\n const id = uid();\r\n const nodes = options.link.map((tag) => {\r\n const el = document.createElement('link');\r\n for (const [k, v] of Object.entries(tag)) {\r\n if (v !== undefined) el.setAttribute(domAttr(k), v);\r\n }\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.link)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <script> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.script?.length) return;\r\n const id = uid();\r\n const nodes = options.script.map((tag) => {\r\n const el = document.createElement('script');\r\n if (tag.src) el.src = tag.src;\r\n if (tag.type) el.type = tag.type;\r\n if (tag.defer) el.defer = true;\r\n if (tag.async) el.async = true;\r\n if (tag.noModule) el.setAttribute('nomodule', '');\r\n if (tag.crossOrigin) el.crossOrigin = tag.crossOrigin;\r\n if (tag.integrity) el.integrity = tag.integrity;\r\n if (tag.content) el.textContent = tag.content;\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.script)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <style> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.style?.length) return;\r\n const id = uid();\r\n const nodes = options.style.map((tag) => {\r\n const el = document.createElement('style');\r\n if (tag.media) el.media = tag.media;\r\n if (tag.content) el.textContent = tag.content;\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.style)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n}\r\n\r\n// \u2500\u2500\u2500 Attribute helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Applies an attribute map to a DOM element, storing the previous values so\r\n * the returned cleanup function can restore them on unmount.\r\n */\r\nfunction applyAttrs(\r\n el: Element,\r\n attrs: Record<string, string | undefined>,\r\n): () => void {\r\n const prev: Record<string, string | null> = {};\r\n for (const [k, v] of Object.entries(attrs)) {\r\n if (v === undefined) continue;\r\n const attr = domAttr(k);\r\n prev[attr] = el.getAttribute(attr);\r\n el.setAttribute(attr, v);\r\n }\r\n return () => {\r\n for (const [attr, was] of Object.entries(prev)) {\r\n if (was === null) el.removeAttribute(attr);\r\n else el.setAttribute(attr, was);\r\n }\r\n };\r\n}\r\n\r\n/**\r\n * Converts camelCase React prop names to their HTML attribute equivalents.\r\n * httpEquiv \u2192 http-equiv\r\n * hrefLang \u2192 hreflang\r\n * crossOrigin \u2192 crossorigin\r\n */\r\nfunction domAttr(key: string): string {\r\n if (key === 'httpEquiv') return 'http-equiv';\r\n if (key === 'hrefLang') return 'hreflang';\r\n if (key === 'crossOrigin') return 'crossorigin';\r\n return key;\r\n}\r\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * use-html.ts \u2014 useHtml() Hook\r\n *\r\n * A universal hook that lets React components control the HTML document's\r\n * <head>, <html> attributes, and <body> attributes from within JSX \u2014 on both\r\n * the server (SSR) and the client (hydration / SPA navigation).\r\n *\r\n * Server behaviour:\r\n * Writes directly into the per-request html-store. The store is flushed\r\n * into the HTML document after the component tree is fully rendered.\r\n * useHtml() is called synchronously during rendering so no actual React\r\n * hook is used \u2014 it's just a function that pokes the globalThis store.\r\n *\r\n * Client behaviour:\r\n * Uses useEffect() to apply changes to the live document and clean them up\r\n * when the component unmounts (navigation, unmount). Each effect is keyed\r\n * to its options object via JSON.stringify so React re-runs it when the\r\n * options change.\r\n *\r\n * Layout title templates:\r\n * Layouts typically set title as a function so they can append a site suffix:\r\n *\r\n * ```tsx\r\n * // Root layout\r\n * useHtml({ title: (prev) => `${prev} | Acme` });\r\n *\r\n * // A page\r\n * useHtml({ title: 'About' });\r\n * // \u2192 'About | Acme'\r\n * ```\r\n *\r\n * Example usage:\r\n * ```tsx\r\n * useHtml({\r\n * title: 'Blog Post',\r\n * meta: [{ name: 'description', content: 'A great post' }],\r\n * link: [{ rel: 'canonical', href: 'https://example.com/post' }],\r\n * });\r\n * ```\r\n */\r\n\r\nimport { useEffect } from 'react';\r\nimport { getHtmlStore } from './html-store';\r\nimport type {\r\n TitleValue,\r\n HtmlAttrs,\r\n BodyAttrs,\r\n MetaTag,\r\n LinkTag,\r\n ScriptTag,\r\n StyleTag,\r\n} from './html-store';\r\n\r\n// Re-export types so consumers can import them from 'nukejs' directly.\r\nexport type { TitleValue, HtmlAttrs, BodyAttrs, MetaTag, LinkTag, ScriptTag, StyleTag };\r\n\r\n// \u2500\u2500\u2500 Options type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 HtmlOptions {\r\n /**\r\n * Page title.\r\n * string \u2192 sets the title directly (page wins over layout).\r\n * function \u2192 receives the inner title; use in layouts to append a suffix:\r\n * `(prev) => \\`${prev} | MySite\\``\r\n */\r\n title?: TitleValue;\r\n /** Attributes merged onto <html>. Per-attribute last-write-wins. */\r\n htmlAttrs?: HtmlAttrs;\r\n /** Attributes merged onto <body>. Per-attribute last-write-wins. */\r\n bodyAttrs?: BodyAttrs;\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 Universal hook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Applies HTML document customisations from a React component.\r\n * Automatically detects whether it is running on the server or the client.\r\n */\r\nexport function useHtml(options: HtmlOptions): void {\r\n if (typeof document === 'undefined') {\r\n // Running on the server (SSR) \u2014 write synchronously to the request store.\r\n serverUseHtml(options);\r\n } else {\r\n // Running in the browser \u2014 use React effects.\r\n clientUseHtml(options);\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Server implementation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Writes options directly into the active per-request html-store.\r\n * Called synchronously during SSR; no React hooks are used.\r\n *\r\n * Title operations are *pushed* (not replaced) so both layout and page values\r\n * are preserved for resolveTitle() to process in the correct order.\r\n */\r\nfunction serverUseHtml(options: HtmlOptions): void {\r\n const store = getHtmlStore();\r\n if (!store) return; // Called outside of a runWithHtmlStore context \u2014 ignore.\r\n\r\n if (options.title !== undefined) store.titleOps.push(options.title);\r\n if (options.htmlAttrs) Object.assign(store.htmlAttrs, options.htmlAttrs);\r\n if (options.bodyAttrs) Object.assign(store.bodyAttrs, options.bodyAttrs);\r\n if (options.meta?.length) store.meta.push(...options.meta);\r\n if (options.link?.length) store.link.push(...options.link);\r\n if (options.script?.length) store.script.push(...options.script);\r\n if (options.style?.length) store.style.push(...options.style);\r\n}\r\n\r\n// \u2500\u2500\u2500 Client implementation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** Monotonically incrementing counter for generating unique dataset IDs. */\r\nlet _uid = 0;\r\nconst uid = () => `uh${++_uid}`;\r\n\r\n/**\r\n * Applies options to the live document using React effects.\r\n * Each effect type is independent so a change to `title` does not re-run the\r\n * `meta` effect and vice versa.\r\n *\r\n * Cleanup functions restore the previous state so unmounting a component that\r\n * called useHtml() reverses its changes (important for SPA navigation).\r\n */\r\nfunction clientUseHtml(options: HtmlOptions): void {\r\n // \u2500\u2500 title \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (options.title === undefined) return;\r\n const prev = document.title;\r\n document.title = typeof options.title === 'function'\r\n ? options.title(prev)\r\n : options.title;\r\n return () => { document.title = prev; };\r\n }, [typeof options.title === 'function' // eslint-disable-line react-hooks/exhaustive-deps\r\n ? options.title.toString()\r\n : options.title]);\r\n\r\n // \u2500\u2500 <html> attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.htmlAttrs) return;\r\n return applyAttrs(document.documentElement, options.htmlAttrs);\r\n }, [JSON.stringify(options.htmlAttrs)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <body> attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.bodyAttrs) return;\r\n return applyAttrs(document.body, options.bodyAttrs);\r\n }, [JSON.stringify(options.bodyAttrs)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <meta> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.meta?.length) return;\r\n const id = uid();\r\n const nodes = options.meta.map((tag) => {\r\n const el = document.createElement('meta');\r\n for (const [k, v] of Object.entries(tag)) {\r\n if (v !== undefined) el.setAttribute(domAttr(k), v);\r\n }\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.meta)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <link> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.link?.length) return;\r\n const id = uid();\r\n const nodes = options.link.map((tag) => {\r\n const el = document.createElement('link');\r\n for (const [k, v] of Object.entries(tag)) {\r\n if (v !== undefined) el.setAttribute(domAttr(k), v);\r\n }\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.link)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <script> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.script?.length) return;\r\n const id = uid();\r\n const nodes = options.script.map((tag) => {\r\n const el = document.createElement('script');\r\n if (tag.src) el.src = tag.src;\r\n if (tag.type) el.type = tag.type;\r\n if (tag.defer) el.defer = true;\r\n if (tag.async) el.async = true;\r\n if (tag.noModule) el.setAttribute('nomodule', '');\r\n if (tag.crossOrigin) el.crossOrigin = tag.crossOrigin;\r\n if (tag.integrity) el.integrity = tag.integrity;\r\n if (tag.content) el.textContent = tag.content;\r\n el.dataset.usehtml = id;\r\n // Respect position: 'body' scripts are appended at the end of <body>.\r\n if (tag.position === 'body') {\r\n document.body.appendChild(el);\r\n } else {\r\n document.head.appendChild(el);\r\n }\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.script)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n\r\n // \u2500\u2500 <style> tags \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks\r\n if (!options.style?.length) return;\r\n const id = uid();\r\n const nodes = options.style.map((tag) => {\r\n const el = document.createElement('style');\r\n if (tag.media) el.media = tag.media;\r\n if (tag.content) el.textContent = tag.content;\r\n el.dataset.usehtml = id;\r\n document.head.appendChild(el);\r\n return el;\r\n });\r\n return () => nodes.forEach(n => n.remove());\r\n }, [JSON.stringify(options.style)]); // eslint-disable-line react-hooks/exhaustive-deps\r\n}\r\n\r\n// \u2500\u2500\u2500 Attribute helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Applies an attribute map to a DOM element, storing the previous values so\r\n * the returned cleanup function can restore them on unmount.\r\n */\r\nfunction applyAttrs(\r\n el: Element,\r\n attrs: Record<string, string | undefined>,\r\n): () => void {\r\n const prev: Record<string, string | null> = {};\r\n for (const [k, v] of Object.entries(attrs)) {\r\n if (v === undefined) continue;\r\n const attr = domAttr(k);\r\n prev[attr] = el.getAttribute(attr);\r\n el.setAttribute(attr, v);\r\n }\r\n return () => {\r\n for (const [attr, was] of Object.entries(prev)) {\r\n if (was === null) el.removeAttribute(attr);\r\n else el.setAttribute(attr, was);\r\n }\r\n };\r\n}\r\n\r\n/**\r\n * Converts camelCase React prop names to their HTML attribute equivalents.\r\n * httpEquiv \u2192 http-equiv\r\n * hrefLang \u2192 hreflang\r\n * crossOrigin \u2192 crossorigin\r\n */\r\nfunction domAttr(key: string): string {\r\n if (key === 'httpEquiv') return 'http-equiv';\r\n if (key === 'hrefLang') return 'hreflang';\r\n if (key === 'crossOrigin') return 'crossorigin';\r\n return key;\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -25,4 +25,4 @@ function useRouter() {
25
25
  export {
26
26
  useRouter as default
27
27
  };
28
- //# sourceMappingURL=useRouter.js.map
28
+ //# sourceMappingURL=use-router.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../../src/as-is/useRouter.ts"],
4
- "sourcesContent": ["import { useCallback, useEffect, useState } from \"react\";\r\n\r\ntype Router = {\r\n path: string;\r\n push: (url: string) => void;\r\n replace: (url: string) => void;\r\n};\r\n\r\nexport default function useRouter(): Router {\r\n try {\r\n const [path, setPath] = useState(() => window.location.pathname);\r\n\r\n useEffect(() => {\r\n const handleLocationChange = () => setPath(window.location.pathname);\r\n window.addEventListener(\"locationchange\", handleLocationChange);\r\n return () => window.removeEventListener(\"locationchange\", handleLocationChange);\r\n }, []);\r\n\r\n const push = useCallback((url: string) => {\r\n window.history.pushState({}, \"\", url);\r\n setPath(url);\r\n }, []);\r\n\r\n const replace = useCallback((url: string) => {\r\n window.history.replaceState({}, \"\", url);\r\n setPath(url);\r\n }, []);\r\n\r\n return { path, push, replace };\r\n } catch {\r\n return { push: () => {}, replace: () => {}, path: \"\" };\r\n }\r\n}\r\n"],
3
+ "sources": ["../src/use-router.ts"],
4
+ "sourcesContent": ["import { useCallback, useEffect, useState } from \"react\";\r\n\r\ntype Router = {\r\n path: string;\r\n push: (url: string) => void;\r\n replace: (url: string) => void;\r\n};\r\n\r\nexport default function useRouter(): Router {\r\n try {\r\n const [path, setPath] = useState(() => window.location.pathname);\r\n\r\n useEffect(() => {\r\n const handleLocationChange = () => setPath(window.location.pathname);\r\n window.addEventListener(\"locationchange\", handleLocationChange);\r\n return () => window.removeEventListener(\"locationchange\", handleLocationChange);\r\n }, []);\r\n\r\n const push = useCallback((url: string) => {\r\n window.history.pushState({}, \"\", url);\r\n setPath(url);\r\n }, []);\r\n\r\n const replace = useCallback((url: string) => {\r\n window.history.replaceState({}, \"\", url);\r\n setPath(url);\r\n }, []);\r\n\r\n return { path, push, replace };\r\n } catch {\r\n return { push: () => {}, replace: () => {}, path: \"\" };\r\n }\r\n}"],
5
5
  "mappings": "AAAA,SAAS,aAAa,WAAW,gBAAgB;AAQlC,SAAR,YAAqC;AACxC,MAAI;AACA,UAAM,CAAC,MAAM,OAAO,IAAI,SAAS,MAAM,OAAO,SAAS,QAAQ;AAE/D,cAAU,MAAM;AACZ,YAAM,uBAAuB,MAAM,QAAQ,OAAO,SAAS,QAAQ;AACnE,aAAO,iBAAiB,kBAAkB,oBAAoB;AAC9D,aAAO,MAAM,OAAO,oBAAoB,kBAAkB,oBAAoB;AAAA,IAClF,GAAG,CAAC,CAAC;AAEL,UAAM,OAAO,YAAY,CAAC,QAAgB;AACtC,aAAO,QAAQ,UAAU,CAAC,GAAG,IAAI,GAAG;AACpC,cAAQ,GAAG;AAAA,IACf,GAAG,CAAC,CAAC;AAEL,UAAM,UAAU,YAAY,CAAC,QAAgB;AACzC,aAAO,QAAQ,aAAa,CAAC,GAAG,IAAI,GAAG;AACvC,cAAQ,GAAG;AAAA,IACf,GAAG,CAAC,CAAC;AAEL,WAAO,EAAE,MAAM,MAAM,QAAQ;AAAA,EACjC,QAAQ;AACJ,WAAO,EAAE,MAAM,MAAM;AAAA,IAAC,GAAG,SAAS,MAAM;AAAA,IAAC,GAAG,MAAM,GAAG;AAAA,EACzD;AACJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukejs",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../src/as-is/Link.tsx"],
4
- "sourcesContent": ["\"use client\"\r\n\r\nconst Link = ({ href, children, className }: {\r\n href: string;\r\n children: React.ReactNode;\r\n className?: string;\r\n}) => {\r\n const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {\r\n e.preventDefault();\r\n window.history.pushState({}, '', href);\r\n };\r\n\r\n return (\r\n <a href={href} onClick={handleClick} className={className}>\r\n {children}\r\n </a>\r\n );\r\n};\r\n\r\nexport default Link;\r\n"],
5
- "mappings": ";AAaQ;AAXR,MAAM,OAAO,CAAC,EAAE,MAAM,UAAU,UAAU,MAIpC;AACF,QAAM,cAAc,CAAC,MAA2C;AAC5D,MAAE,eAAe;AACjB,WAAO,QAAQ,UAAU,CAAC,GAAG,IAAI,IAAI;AAAA,EACzC;AAEA,SACI,oBAAC,OAAE,MAAY,SAAS,aAAa,WAChC,UACL;AAER;AAEA,IAAO,eAAQ;",
6
- "names": []
7
- }
@@ -1,20 +0,0 @@
1
- "use client"
2
-
3
- const Link = ({ href, children, className }: {
4
- href: string;
5
- children: React.ReactNode;
6
- className?: string;
7
- }) => {
8
- const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
9
- e.preventDefault();
10
- window.history.pushState({}, '', href);
11
- };
12
-
13
- return (
14
- <a href={href} onClick={handleClick} className={className}>
15
- {children}
16
- </a>
17
- );
18
- };
19
-
20
- export default Link;
@@ -1,33 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
-
3
- type Router = {
4
- path: string;
5
- push: (url: string) => void;
6
- replace: (url: string) => void;
7
- };
8
-
9
- export default function useRouter(): Router {
10
- try {
11
- const [path, setPath] = useState(() => window.location.pathname);
12
-
13
- useEffect(() => {
14
- const handleLocationChange = () => setPath(window.location.pathname);
15
- window.addEventListener("locationchange", handleLocationChange);
16
- return () => window.removeEventListener("locationchange", handleLocationChange);
17
- }, []);
18
-
19
- const push = useCallback((url: string) => {
20
- window.history.pushState({}, "", url);
21
- setPath(url);
22
- }, []);
23
-
24
- const replace = useCallback((url: string) => {
25
- window.history.replaceState({}, "", url);
26
- setPath(url);
27
- }, []);
28
-
29
- return { path, push, replace };
30
- } catch {
31
- return { push: () => {}, replace: () => {}, path: "" };
32
- }
33
- }
File without changes
File without changes