nukejs 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/dist/as-is/Link.js +14 -0
- package/dist/as-is/Link.js.map +7 -0
- package/dist/as-is/useRouter.js +28 -0
- package/dist/as-is/useRouter.js.map +7 -0
- package/dist/build-common.d.ts +52 -78
- package/dist/build-common.js +158 -190
- package/dist/build-common.js.map +2 -2
- package/dist/build-node.d.ts +14 -0
- package/dist/build-node.js +52 -56
- package/dist/build-node.js.map +2 -2
- package/dist/build-vercel.d.ts +18 -0
- package/dist/build-vercel.js +78 -67
- package/dist/build-vercel.js.map +2 -2
- package/dist/builder.d.ts +16 -0
- package/dist/builder.js +54 -54
- package/dist/builder.js.map +3 -3
- package/dist/bundle.d.ts +12 -2
- package/dist/bundle.js +62 -14
- package/dist/bundle.js.map +2 -2
- package/dist/component-analyzer.d.ts +7 -10
- package/dist/component-analyzer.js +33 -20
- package/dist/component-analyzer.js.map +2 -2
- package/dist/router.d.ts +26 -22
- package/dist/router.js +14 -7
- package/dist/router.js.map +2 -2
- package/dist/ssr.d.ts +10 -4
- package/dist/ssr.js +12 -7
- package/dist/ssr.js.map +2 -2
- package/package.json +1 -1
package/dist/bundle.js
CHANGED
|
@@ -77,7 +77,7 @@ async function loadModules(ids, log, bust = "") {
|
|
|
77
77
|
return mods;
|
|
78
78
|
}
|
|
79
79
|
const activeRoots = [];
|
|
80
|
-
async function mountNodes(mods, log
|
|
80
|
+
async function mountNodes(mods, log) {
|
|
81
81
|
const { hydrateRoot, createRoot } = await import("react-dom/client");
|
|
82
82
|
const React = await import("react");
|
|
83
83
|
const nodes = document.querySelectorAll("[data-hydrate-id]");
|
|
@@ -98,8 +98,14 @@ async function mountNodes(mods, log, isNavigation) {
|
|
|
98
98
|
}
|
|
99
99
|
try {
|
|
100
100
|
const element = React.default.createElement(Comp, await reconstructProps(rawProps, mods));
|
|
101
|
-
|
|
102
|
-
if (
|
|
101
|
+
let root;
|
|
102
|
+
if (node.innerHTML.trim()) {
|
|
103
|
+
root = hydrateRoot(node, element);
|
|
104
|
+
} else {
|
|
105
|
+
const r = createRoot(node);
|
|
106
|
+
r.render(element);
|
|
107
|
+
root = r;
|
|
108
|
+
}
|
|
103
109
|
activeRoots.push(root);
|
|
104
110
|
log.verbose("\u2713 Mounted:", id);
|
|
105
111
|
} catch (err) {
|
|
@@ -107,13 +113,54 @@ async function mountNodes(mods, log, isNavigation) {
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
}
|
|
116
|
+
function headBlock(head) {
|
|
117
|
+
const nodes = [];
|
|
118
|
+
let closeComment = null;
|
|
119
|
+
let inside = false;
|
|
120
|
+
for (const child of Array.from(head.childNodes)) {
|
|
121
|
+
if (child.nodeType === Node.COMMENT_NODE) {
|
|
122
|
+
const text = child.data.trim();
|
|
123
|
+
if (text === "n-head") {
|
|
124
|
+
inside = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (text === "/n-head") {
|
|
128
|
+
closeComment = child;
|
|
129
|
+
inside = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (inside && child.nodeType === Node.ELEMENT_NODE)
|
|
134
|
+
nodes.push(child);
|
|
135
|
+
}
|
|
136
|
+
return { nodes, closeComment };
|
|
137
|
+
}
|
|
138
|
+
function fingerprint(el) {
|
|
139
|
+
return el.tagName + "|" + Array.from(el.attributes).sort((a, b) => a.name.localeCompare(b.name)).map((a) => `${a.name}=${a.value}`).join("&");
|
|
140
|
+
}
|
|
141
|
+
function syncHeadTags(doc) {
|
|
142
|
+
const live = headBlock(document.head);
|
|
143
|
+
const next = headBlock(doc.head);
|
|
144
|
+
const liveMap = /* @__PURE__ */ new Map();
|
|
145
|
+
for (const el of live.nodes) liveMap.set(fingerprint(el), el);
|
|
146
|
+
const nextMap = /* @__PURE__ */ new Map();
|
|
147
|
+
for (const el of next.nodes) nextMap.set(fingerprint(el), el);
|
|
148
|
+
let anchor = live.closeComment;
|
|
149
|
+
if (!anchor) {
|
|
150
|
+
document.head.appendChild(document.createComment("n-head"));
|
|
151
|
+
anchor = document.createComment("/n-head");
|
|
152
|
+
document.head.appendChild(anchor);
|
|
153
|
+
}
|
|
154
|
+
for (const [fp, el] of nextMap)
|
|
155
|
+
if (!liveMap.has(fp)) document.head.insertBefore(el, anchor);
|
|
156
|
+
for (const [fp, el] of liveMap)
|
|
157
|
+
if (!nextMap.has(fp)) el.remove();
|
|
158
|
+
}
|
|
110
159
|
function syncAttrs(live, next) {
|
|
111
|
-
for (const { name, value } of Array.from(next.attributes))
|
|
160
|
+
for (const { name, value } of Array.from(next.attributes))
|
|
112
161
|
live.setAttribute(name, value);
|
|
113
|
-
}
|
|
114
|
-
for (const { name } of Array.from(live.attributes)) {
|
|
162
|
+
for (const { name } of Array.from(live.attributes))
|
|
115
163
|
if (!next.hasAttribute(name)) live.removeAttribute(name);
|
|
116
|
-
}
|
|
117
164
|
}
|
|
118
165
|
function setupNavigation(log) {
|
|
119
166
|
window.addEventListener("locationchange", async ({ detail: { href, hmr } }) => {
|
|
@@ -129,19 +176,20 @@ function setupNavigation(log) {
|
|
|
129
176
|
const newApp = doc.getElementById("app");
|
|
130
177
|
const currApp = document.getElementById("app");
|
|
131
178
|
if (!newApp || !currApp) return;
|
|
132
|
-
|
|
179
|
+
syncHeadTags(doc);
|
|
180
|
+
syncAttrs(document.documentElement, doc.documentElement);
|
|
181
|
+
syncAttrs(document.body, doc.body);
|
|
133
182
|
currApp.innerHTML = newApp.innerHTML;
|
|
183
|
+
const newTitle = doc.querySelector("title");
|
|
184
|
+
if (newTitle) document.title = newTitle.textContent ?? "";
|
|
134
185
|
const newDataEl = doc.getElementById("__n_data");
|
|
135
186
|
const currDataEl = document.getElementById("__n_data");
|
|
136
187
|
if (newDataEl && currDataEl) currDataEl.textContent = newDataEl.textContent;
|
|
137
|
-
|
|
138
|
-
if (newTitle) document.title = newTitle.textContent ?? "";
|
|
139
|
-
syncAttrs(document.documentElement, doc.documentElement);
|
|
140
|
-
syncAttrs(document.body, doc.body);
|
|
188
|
+
activeRoots.splice(0).forEach((r) => r.unmount());
|
|
141
189
|
const navData = JSON.parse(currDataEl?.textContent ?? "{}");
|
|
142
190
|
log.info("\u{1F504} Route \u2192", href, "\u2014 mounting", navData.hydrateIds?.length ?? 0, "component(s)");
|
|
143
191
|
const mods = await loadModules(navData.allIds ?? [], log, String(Date.now()));
|
|
144
|
-
await mountNodes(mods, log
|
|
192
|
+
await mountNodes(mods, log);
|
|
145
193
|
window.scrollTo(0, 0);
|
|
146
194
|
log.info("\u{1F389} Navigation complete:", href);
|
|
147
195
|
} catch (err) {
|
|
@@ -155,7 +203,7 @@ async function initRuntime(data) {
|
|
|
155
203
|
log.info("\u{1F680} Partial hydration:", data.hydrateIds.length, "root component(s)");
|
|
156
204
|
setupNavigation(log);
|
|
157
205
|
const mods = await loadModules(data.allIds, log);
|
|
158
|
-
await mountNodes(mods, log
|
|
206
|
+
await mountNodes(mods, log);
|
|
159
207
|
log.info("\u{1F389} Done!");
|
|
160
208
|
setupLocationChangeMonitor();
|
|
161
209
|
}
|
package/dist/bundle.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/bundle.ts"],
|
|
4
|
-
"sourcesContent": ["/**\r\n * bundle.ts \u2014 NukeJS Client Runtime\r\n *\r\n * This file is compiled by esbuild into /__n.js and served to every page.\r\n * It provides:\r\n *\r\n * initRuntime(data) \u2014 called once per page load to hydrate\r\n * \"use client\" components and wire up SPA nav\r\n * setupLocationChangeMonitor() \u2014 patches history.pushState/replaceState so\r\n * SPA navigation fires a 'locationchange' event\r\n *\r\n * Hydration model (partial hydration):\r\n * - The server renders the full page to HTML, wrapping each client component\r\n * in a <span data-hydrate-id=\"cc_\u2026\" data-hydrate-props=\"\u2026\"> marker.\r\n * - initRuntime loads the matching JS bundle for each marker and calls\r\n * hydrateRoot() on it, letting React take over just that subtree.\r\n * - Props serialized by the server may include nested React elements\r\n * (serialized as { __re: 'html'|'client', \u2026 }), which are reconstructed\r\n * back into React.createElement calls before mounting.\r\n *\r\n * SPA navigation:\r\n * - Link clicks / programmatic navigation dispatch a 'locationchange' event.\r\n * - The handler fetches the target URL as HTML, diffs the #app container,\r\n * unmounts the old React roots, and re-hydrates the new ones.\r\n * - HMR navigations add ?__hmr=1 so the server skips client-SSR (faster).\r\n */\r\n\r\n// \u2500\u2500\u2500 History patch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Patches history.pushState and history.replaceState to fire a custom\r\n * 'locationchange' event on window. Also listens to 'popstate' for\r\n * back/forward navigation.\r\n *\r\n * This must be called after initRuntime sets up the navigation listener so\r\n * there's no race between the event firing and the listener being registered.\r\n */\r\nexport function setupLocationChangeMonitor(): void {\r\n const originalPushState = window.history.pushState.bind(window.history);\r\n const originalReplaceState = window.history.replaceState.bind(window.history);\r\n\r\n const dispatch = (href?: any) =>\r\n window.dispatchEvent(new CustomEvent('locationchange', { detail: { href } }));\r\n\r\n window.history.pushState = function (...args) {\r\n originalPushState(...args);\r\n dispatch(args[2]); // args[2] is the URL\r\n };\r\n\r\n window.history.replaceState = function (...args) {\r\n originalReplaceState(...args);\r\n dispatch(args[2]);\r\n };\r\n\r\n // Back/forward navigation via the browser's native UI.\r\n window.addEventListener('popstate', () => dispatch(window.location.pathname));\r\n}\r\n\r\n// \u2500\u2500\u2500 Logger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ntype ClientDebugLevel = 'silent' | 'error' | 'info' | 'verbose';\r\n\r\n/**\r\n * Returns a thin logger whose methods are no-ops unless `level` allows them.\r\n * The server embeds the active debug level in the __n_data JSON blob so the\r\n * client respects the same setting as the server.\r\n */\r\nfunction makeLogger(level: ClientDebugLevel) {\r\n return {\r\n verbose: (...a: any[]) => { if (level === 'verbose') console.log(...a); },\r\n info: (...a: any[]) => { if (level === 'verbose' || level === 'info') console.log(...a); },\r\n warn: (...a: any[]) => { if (level === 'verbose' || level === 'info') console.warn(...a); },\r\n error: (...a: any[]) => { if (level !== 'silent') console.error(...a); },\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Serialized node types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** The wire format for React elements embedded in hydration props. */\r\ntype SerializedNode =\r\n | null\r\n | undefined\r\n | string\r\n | number\r\n | boolean\r\n | SerializedNode[]\r\n | { __re: 'html'; tag: string; props: Record<string, any> } // native DOM element\r\n | { __re: 'client'; componentId: string; props: Record<string, any> } // client component\r\n | Record<string, any>; // plain object\r\n\r\ntype ModuleMap = Map<string, any>; // componentId \u2192 default export\r\n\r\n// \u2500\u2500\u2500 Prop reconstruction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 turns the server's serialized node tree back into real React\r\n * elements so they can be passed as props to hydrated components.\r\n *\r\n * The server serializes JSX passed as props (e.g. `<Button icon={<Icon />}>`)\r\n * into a JSON-safe format. This function reverses that process.\r\n */\r\nasync function reconstructElement(node: SerializedNode, mods: ModuleMap): Promise<any> {\r\n if (node === null || node === undefined) return node;\r\n if (typeof node !== 'object') return node; // primitive \u2014 pass through\r\n\r\n if (Array.isArray(node)) {\r\n const items = await Promise.all(node.map(n => reconstructElement(n, mods)));\r\n // Add index-based keys to any React elements in the array so React doesn't\r\n // warn about \"Each child in a list should have a unique key prop\".\r\n const React = await import('react');\r\n return items.map((el, i) =>\r\n el && typeof el === 'object' && el.$$typeof\r\n ? React.default.cloneElement(el, { key: el.key ?? i })\r\n : el,\r\n );\r\n }\r\n\r\n // Client component \u2014 look up the loaded module by ID.\r\n if ((node as any).__re === 'client') {\r\n const n = node as { __re: 'client'; componentId: string; props: Record<string, any> };\r\n const Comp = mods.get(n.componentId);\r\n if (!Comp) return null;\r\n const React = await import('react');\r\n return React.default.createElement(Comp, await reconstructProps(n.props, mods));\r\n }\r\n\r\n // Native HTML element (e.g. <div>, <span>).\r\n if ((node as any).__re === 'html') {\r\n const n = node as { __re: 'html'; tag: string; props: Record<string, any> };\r\n const React = await import('react');\r\n return React.default.createElement(n.tag, await reconstructProps(n.props, mods));\r\n }\r\n\r\n // Plain object \u2014 reconstruct each value.\r\n return node;\r\n}\r\n\r\n/** Reconstructs every value in a props object, handling nested serialized nodes. */\r\nasync function reconstructProps(\r\n props: Record<string, any> | null | undefined,\r\n mods: ModuleMap,\r\n): Promise<Record<string, any>> {\r\n if (!props || typeof props !== 'object' || Array.isArray(props))\r\n return reconstructElement(props as any, mods);\r\n\r\n const out: Record<string, any> = {};\r\n for (const [k, v] of Object.entries(props))\r\n out[k] = await reconstructElement(v, mods);\r\n return out;\r\n}\r\n\r\n// \u2500\u2500\u2500 Module loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Dynamically imports each client component bundle from /__client-component/.\r\n * All fetches are issued in parallel; failures are logged but do not abort\r\n * the rest of the hydration pass.\r\n *\r\n * @param bust Optional cache-busting suffix appended as `?t=<bust>`.\r\n * Used during HMR navigation to bypass the module cache.\r\n */\r\nasync function loadModules(\r\n ids: string[],\r\n log: ReturnType<typeof makeLogger>,\r\n bust = '',\r\n): Promise<ModuleMap> {\r\n const mods: ModuleMap = new Map();\r\n await Promise.all(\r\n ids.map(async (id) => {\r\n try {\r\n const url = `/__client-component/${id}.js` + (bust ? `?t=${bust}` : '');\r\n const m = await import(url);\r\n mods.set(id, m.default);\r\n log.verbose('\u2713 Loaded:', id);\r\n } catch (err) {\r\n log.error('\u2717 Load failed:', id, err);\r\n }\r\n }),\r\n );\r\n return mods;\r\n}\r\n\r\n// \u2500\u2500\u2500 Root mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** All active React roots \u2014 tracked so they can be unmounted before navigation. */\r\ntype ReactRoot = { unmount(): void };\r\nconst activeRoots: ReactRoot[] = [];\r\n\r\n/**\r\n * Finds every `[data-hydrate-id]` span in the document and either:\r\n * - hydrateRoot() \u2014 on initial page load (server HTML already present)\r\n * - createRoot() \u2014 after SPA navigation (innerHTML was set by us)\r\n *\r\n * Nested markers (a client component inside another client component) are\r\n * skipped here because the parent's React tree will handle its children.\r\n */\r\nasync function mountNodes(\r\n mods: ModuleMap,\r\n log: ReturnType<typeof makeLogger>,\r\n isNavigation: boolean,\r\n): Promise<void> {\r\n const { hydrateRoot, createRoot } = await import('react-dom/client');\r\n const React = await import('react');\r\n\r\n const nodes = document.querySelectorAll<HTMLElement>('[data-hydrate-id]');\r\n log.verbose('Found', nodes.length, 'hydration point(s)');\r\n\r\n for (const node of nodes) {\r\n // Skip nested markers \u2014 the outer component owns its children.\r\n if (node.parentElement?.closest('[data-hydrate-id]')) continue;\r\n\r\n const id = node.getAttribute('data-hydrate-id')!;\r\n const Comp = mods.get(id);\r\n if (!Comp) { log.warn('No module for', id); continue; }\r\n\r\n // Deserialize props from the data attribute (JSON set by the server).\r\n let rawProps: Record<string, any> = {};\r\n try {\r\n rawProps = JSON.parse(node.getAttribute('data-hydrate-props') || '{}');\r\n } catch (e) {\r\n log.error('Props parse error for', id, e);\r\n }\r\n\r\n try {\r\n const element = React.default.createElement(Comp, await reconstructProps(rawProps, mods));\r\n\r\n // hydrateRoot reconciles React's virtual DOM against the server-rendered\r\n // HTML without fully re-rendering. createRoot replaces the content\r\n // entirely \u2014 safe after navigation because we set innerHTML ourselves.\r\n const root = isNavigation ? createRoot(node) : hydrateRoot(node, element);\r\n if (isNavigation) (root as any).render(element);\r\n activeRoots.push(root);\r\n log.verbose('\u2713 Mounted:', id);\r\n } catch (err) {\r\n log.error('\u2717 Mount failed:', id, err);\r\n }\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 SPA navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Listens for 'locationchange' events (fired by setupLocationChangeMonitor\r\n * or by Link clicks) and performs a soft navigation:\r\n *\r\n * 1. Fetch the target URL as HTML (adds ?__hmr=1 during HMR updates so the\r\n * server skips client-side SSR for a faster response).\r\n * 2. Parse the response with DOMParser.\r\n * 3. Replace #app innerHTML and __n_data.\r\n * 4. Unmount old React roots, then re-hydrate new ones.\r\n * 5. Scroll to top.\r\n *\r\n * Falls back to a full page reload if anything goes wrong.\r\n */\r\n/**\r\n * Syncs attributes from a parsed element onto the live document element.\r\n * Adds/updates attributes present in `next`, and removes any that were set\r\n * on `live` but are absent in `next` (so stale bodyAttrs/htmlAttrs are cleared).\r\n */\r\nfunction syncAttrs(live: Element, next: Element): void {\r\n // Apply / update attributes from the new document.\r\n for (const { name, value } of Array.from(next.attributes)) {\r\n live.setAttribute(name, value);\r\n }\r\n // Remove attributes that no longer exist in the new document.\r\n for (const { name } of Array.from(live.attributes)) {\r\n if (!next.hasAttribute(name)) live.removeAttribute(name);\r\n }\r\n}\r\n\r\nfunction setupNavigation(log: ReturnType<typeof makeLogger>): void {\r\n window.addEventListener('locationchange', async ({ detail: { href, hmr } }: any) => {\r\n try {\r\n // Append ?__hmr=1 for HMR-triggered reloads so SSR skips the slower\r\n // client-component renderToString path.\r\n const fetchUrl = hmr\r\n ? href + (href.includes('?') ? '&' : '?') + '__hmr=1'\r\n : href;\r\n\r\n const response = await fetch(fetchUrl, { headers: { Accept: 'text/html' } });\r\n if (!response.ok) {\r\n log.error('Navigation fetch failed:', response.status);\r\n return;\r\n }\r\n\r\n const parser = new DOMParser();\r\n const doc = parser.parseFromString(await response.text(), 'text/html');\r\n const newApp = doc.getElementById('app');\r\n const currApp = document.getElementById('app');\r\n if (!newApp || !currApp) return;\r\n\r\n // Tear down existing React trees before mutating the DOM \u2014 avoids React\r\n // warnings about unmounting from a detached node.\r\n activeRoots.splice(0).forEach(r => r.unmount());\r\n\r\n // Swap content in-place (avoids a full document.write / navigation).\r\n currApp.innerHTML = newApp.innerHTML;\r\n\r\n // Update the runtime data blob so subsequent navigations use the new page's\r\n // client component IDs.\r\n const newDataEl = doc.getElementById('__n_data');\r\n const currDataEl = document.getElementById('__n_data');\r\n if (newDataEl && currDataEl) currDataEl.textContent = newDataEl.textContent;\r\n\r\n // Update <title>.\r\n const newTitle = doc.querySelector('title');\r\n if (newTitle) document.title = newTitle.textContent ?? '';\r\n\r\n // Sync <html> attributes (e.g. lang, class, style from useHtml({ htmlAttrs })).\r\n syncAttrs(document.documentElement, doc.documentElement);\r\n\r\n // Sync <body> attributes (e.g. style, class from useHtml({ bodyAttrs })).\r\n syncAttrs(document.body, doc.body);\r\n\r\n const navData = JSON.parse(currDataEl?.textContent ?? '{}') as RuntimeData;\r\n log.info('\uD83D\uDD04 Route \u2192', href, '\u2014 mounting', navData.hydrateIds?.length ?? 0, 'component(s)');\r\n\r\n // Load bundles with a cache-buster timestamp so stale modules are evicted.\r\n const mods = await loadModules(navData.allIds ?? [], log, String(Date.now()));\r\n await mountNodes(mods, log, true);\r\n\r\n window.scrollTo(0, 0);\r\n log.info('\uD83C\uDF89 Navigation complete:', href);\r\n } catch (err) {\r\n log.error('Navigation error, falling back to full reload:', err);\r\n window.location.href = href;\r\n }\r\n });\r\n}\r\n\r\n// \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Shape of the JSON blob embedded as #__n_data in every SSR page. */\r\nexport interface RuntimeData {\r\n /** IDs of client components actually rendered on this page (subset of allIds). */\r\n hydrateIds: string[];\r\n /** All client component IDs reachable from this page, including layouts.\r\n * Pre-loaded so SPA navigations to related pages feel instant. */\r\n allIds: string[];\r\n url: string;\r\n params: Record<string, any>;\r\n debug: ClientDebugLevel;\r\n}\r\n\r\n/**\r\n * Bootstraps the NukeJS client runtime.\r\n *\r\n * Called once per page load from the inline <script type=\"module\"> injected\r\n * by the SSR renderer:\r\n *\r\n * ```js\r\n * const { initRuntime } = await import('nukejs');\r\n * const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n * await initRuntime(data);\r\n * ```\r\n *\r\n * Order of operations:\r\n * 1. Create the logger at the configured debug level.\r\n * 2. Wire up SPA navigation listener.\r\n * 3. Load all client component bundles in parallel.\r\n * 4. Hydrate every [data-hydrate-id] node.\r\n * 5. Patch history.pushState/replaceState so Link clicks trigger navigation.\r\n */\r\nexport async function initRuntime(data: RuntimeData): Promise<void> {\r\n const log = makeLogger(data.debug ?? 'silent');\r\n\r\n log.info('\uD83D\uDE80 Partial hydration:', data.hydrateIds.length, 'root component(s)');\r\n\r\n // Set up navigation first so any 'locationchange' fired during hydration\r\n // is captured.\r\n setupNavigation(log);\r\n\r\n // Load all component bundles (not just hydrateIds) so SPA navigations can\r\n // mount components for other pages without an extra network round-trip.\r\n const mods = await loadModules(data.allIds, log);\r\n await mountNodes(mods, log, false);\r\n\r\n log.info('\uD83C\uDF89 Done!');\r\n\r\n // Patch history last so pushState calls during hydration (e.g. redirect\r\n // side-effects) don't trigger a navigation before roots are ready.\r\n setupLocationChangeMonitor();\r\n}"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\r\n * bundle.ts \u2014 NukeJS Client Runtime\r\n *\r\n * This file is compiled by esbuild into /__n.js and served to every page.\r\n * It provides:\r\n *\r\n * initRuntime(data) \u2014 called once per page load to hydrate\r\n * \"use client\" components and wire up SPA nav\r\n * setupLocationChangeMonitor() \u2014 patches history.pushState/replaceState so\r\n * SPA navigation fires a 'locationchange' event\r\n *\r\n * Hydration model (partial hydration):\r\n * - The server renders the full page to HTML, wrapping each client component\r\n * in a <span data-hydrate-id=\"cc_\u2026\" data-hydrate-props=\"\u2026\"> marker.\r\n * - initRuntime loads the matching JS bundle for each marker and calls\r\n * hydrateRoot() on it, letting React take over just that subtree.\r\n * - Props serialized by the server may include nested React elements\r\n * (serialized as { __re: 'html'|'client', \u2026 }), which are reconstructed\r\n * back into React.createElement calls before mounting.\r\n *\r\n * SPA navigation:\r\n * - Link clicks / programmatic navigation dispatch a 'locationchange' event.\r\n * - The handler fetches the target URL as HTML, diffs the #app container,\r\n * unmounts the old React roots, and re-hydrates the new ones.\r\n * - HMR navigations add ?__hmr=1 so the server skips client-SSR (faster).\r\n *\r\n * Head tag management:\r\n * - The SSR renderer wraps every useHtml()-generated <meta>, <link>, <style>,\r\n * and <script> tag in <!--n-head-->\u2026<!--/n-head--> sentinel comments.\r\n * - On each navigation the client diffs the live sentinel block against the\r\n * incoming one by fingerprint, adding new tags and removing gone ones.\r\n * Tags shared between pages (e.g. a layout stylesheet) are left untouched\r\n * so there is no removal/re-insertion flash.\r\n * - New tags are always inserted before <!--/n-head--> so they stay inside\r\n * the tracked block and remain visible to the diff on subsequent navigations.\r\n */\r\n\r\n// \u2500\u2500\u2500 History patch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Patches history.pushState and history.replaceState to fire a custom\r\n * 'locationchange' event on window. Also listens to 'popstate' for\r\n * back/forward navigation.\r\n *\r\n * Called after initRuntime sets up the navigation listener so there is no\r\n * race between the event firing and the listener being registered.\r\n */\r\nexport function setupLocationChangeMonitor(): void {\r\n const originalPushState = window.history.pushState.bind(window.history);\r\n const originalReplaceState = window.history.replaceState.bind(window.history);\r\n\r\n const dispatch = (href?: any) =>\r\n window.dispatchEvent(new CustomEvent('locationchange', { detail: { href } }));\r\n\r\n window.history.pushState = function (...args) {\r\n originalPushState(...args);\r\n dispatch(args[2]); // args[2] is the URL\r\n };\r\n\r\n window.history.replaceState = function (...args) {\r\n originalReplaceState(...args);\r\n dispatch(args[2]);\r\n };\r\n\r\n // Back/forward navigation via the browser's native UI.\r\n window.addEventListener('popstate', () => dispatch(window.location.pathname));\r\n}\r\n\r\n// \u2500\u2500\u2500 Logger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\ntype ClientDebugLevel = 'silent' | 'error' | 'info' | 'verbose';\r\n\r\n/**\r\n * Returns a thin logger whose methods are no-ops unless `level` allows them.\r\n * The server embeds the active debug level in the __n_data JSON blob so the\r\n * client respects the same setting as the server.\r\n */\r\nfunction makeLogger(level: ClientDebugLevel) {\r\n return {\r\n verbose: (...a: any[]) => { if (level === 'verbose') console.log(...a); },\r\n info: (...a: any[]) => { if (level === 'verbose' || level === 'info') console.log(...a); },\r\n warn: (...a: any[]) => { if (level === 'verbose' || level === 'info') console.warn(...a); },\r\n error: (...a: any[]) => { if (level !== 'silent') console.error(...a); },\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Serialized node types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** The wire format for React elements embedded in hydration props. */\r\ntype SerializedNode =\r\n | null\r\n | undefined\r\n | string\r\n | number\r\n | boolean\r\n | SerializedNode[]\r\n | { __re: 'html'; tag: string; props: Record<string, any> }\r\n | { __re: 'client'; componentId: string; props: Record<string, any> }\r\n | Record<string, any>;\r\n\r\ntype ModuleMap = Map<string, any>; // componentId \u2192 default export\r\n\r\n// \u2500\u2500\u2500 Prop reconstruction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 turns the server's serialized node tree back into real React\r\n * elements so they can be passed as props to hydrated components.\r\n *\r\n * The server serializes JSX passed as props (e.g. `<Button icon={<Icon />}>`)\r\n * into a JSON-safe format. This function reverses that process.\r\n */\r\nasync function reconstructElement(node: SerializedNode, mods: ModuleMap): Promise<any> {\r\n if (node === null || node === undefined) return node;\r\n if (typeof node !== 'object') return node; // primitive \u2014 pass through\r\n\r\n if (Array.isArray(node)) {\r\n const items = await Promise.all(node.map(n => reconstructElement(n, mods)));\r\n // Add index-based keys to React elements in the array to avoid the\r\n // \"Each child in a list should have a unique key prop\" warning.\r\n const React = await import('react');\r\n return items.map((el, i) =>\r\n el && typeof el === 'object' && el.$$typeof\r\n ? React.default.cloneElement(el, { key: el.key ?? i })\r\n : el,\r\n );\r\n }\r\n\r\n // Client component \u2014 look up the loaded module by ID.\r\n if ((node as any).__re === 'client') {\r\n const n = node as { __re: 'client'; componentId: string; props: Record<string, any> };\r\n const Comp = mods.get(n.componentId);\r\n if (!Comp) return null;\r\n const React = await import('react');\r\n return React.default.createElement(Comp, await reconstructProps(n.props, mods));\r\n }\r\n\r\n // Native HTML element (e.g. <div>, <span>).\r\n if ((node as any).__re === 'html') {\r\n const n = node as { __re: 'html'; tag: string; props: Record<string, any> };\r\n const React = await import('react');\r\n return React.default.createElement(n.tag, await reconstructProps(n.props, mods));\r\n }\r\n\r\n // Plain object \u2014 pass through as-is.\r\n return node;\r\n}\r\n\r\n/** Reconstructs every value in a props object, handling nested serialized nodes. */\r\nasync function reconstructProps(\r\n props: Record<string, any> | null | undefined,\r\n mods: ModuleMap,\r\n): Promise<Record<string, any>> {\r\n if (!props || typeof props !== 'object' || Array.isArray(props))\r\n return reconstructElement(props as any, mods);\r\n\r\n const out: Record<string, any> = {};\r\n for (const [k, v] of Object.entries(props))\r\n out[k] = await reconstructElement(v, mods);\r\n return out;\r\n}\r\n\r\n// \u2500\u2500\u2500 Module loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Dynamically imports each client component bundle from /__client-component/.\r\n * All fetches are issued in parallel; failures are logged but do not abort\r\n * the rest of the hydration pass.\r\n *\r\n * @param bust Optional cache-busting suffix appended as `?t=<bust>`.\r\n * Used during HMR navigation to bypass the module cache.\r\n */\r\nasync function loadModules(\r\n ids: string[],\r\n log: ReturnType<typeof makeLogger>,\r\n bust = '',\r\n): Promise<ModuleMap> {\r\n const mods: ModuleMap = new Map();\r\n await Promise.all(\r\n ids.map(async (id) => {\r\n try {\r\n const url = `/__client-component/${id}.js` + (bust ? `?t=${bust}` : '');\r\n const m = await import(url);\r\n mods.set(id, m.default);\r\n log.verbose('\u2713 Loaded:', id);\r\n } catch (err) {\r\n log.error('\u2717 Load failed:', id, err);\r\n }\r\n }),\r\n );\r\n return mods;\r\n}\r\n\r\n// \u2500\u2500\u2500 Root mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** All active React roots \u2014 tracked so they can be unmounted before navigation. */\r\ntype ReactRoot = { unmount(): void };\r\nconst activeRoots: ReactRoot[] = [];\r\n\r\n/**\r\n * Finds every `[data-hydrate-id]` span in the document and calls hydrateRoot()\r\n * on it. hydrateRoot reconciles React's virtual DOM against the existing server\r\n * HTML without discarding it, which avoids a visible flash on both initial load\r\n * and SPA navigation (where we set innerHTML to fresh SSR output before calling\r\n * mountNodes).\r\n *\r\n * Nested markers are skipped \u2014 the parent's React tree owns its children.\r\n */\r\nasync function mountNodes(\r\n mods: ModuleMap,\r\n log: ReturnType<typeof makeLogger>,\r\n): Promise<void> {\r\n const { hydrateRoot, createRoot } = await import('react-dom/client');\r\n const React = await import('react');\r\n\r\n const nodes = document.querySelectorAll<HTMLElement>('[data-hydrate-id]');\r\n log.verbose('Found', nodes.length, 'hydration point(s)');\r\n\r\n for (const node of nodes) {\r\n // Skip nested markers \u2014 the outer component owns its children.\r\n if (node.parentElement?.closest('[data-hydrate-id]')) continue;\r\n\r\n const id = node.getAttribute('data-hydrate-id')!;\r\n const Comp = mods.get(id);\r\n if (!Comp) { log.warn('No module for', id); continue; }\r\n\r\n let rawProps: Record<string, any> = {};\r\n try {\r\n rawProps = JSON.parse(node.getAttribute('data-hydrate-props') || '{}');\r\n } catch (e) {\r\n log.error('Props parse error for', id, e);\r\n }\r\n\r\n try {\r\n const element = React.default.createElement(Comp, await reconstructProps(rawProps, mods));\r\n\r\n // hydrateRoot reconciles against existing server HTML (initial page load).\r\n // createRoot renders fresh when the span is empty (HMR path \u2014 server sent\r\n // skipClientSSR=true so the span has no pre-rendered content to reconcile).\r\n let root: ReactRoot;\r\n if (node.innerHTML.trim()) {\r\n root = hydrateRoot(node, element);\r\n } else {\r\n const r = createRoot(node);\r\n r.render(element);\r\n root = r;\r\n }\r\n\r\n activeRoots.push(root);\r\n log.verbose('\u2713 Mounted:', id);\r\n } catch (err) {\r\n log.error('\u2717 Mount failed:', id, err);\r\n }\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Head tag sync \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Walks a <head> element and returns every Element node that lives between\r\n * the <!--n-head--> and <!--/n-head--> sentinel comments, plus the closing\r\n * comment node itself (used as the insertion anchor).\r\n *\r\n * The SSR renderer emits these sentinels around every useHtml()-generated tag\r\n * so the client can manage exactly that set without touching permanent tags\r\n * (charset, viewport, importmap, runtime <script>).\r\n */\r\nfunction headBlock(head: HTMLHeadElement): { nodes: Element[]; closeComment: Comment | null } {\r\n const nodes: Element[] = [];\r\n let closeComment: Comment | null = null;\r\n let inside = false;\r\n\r\n for (const child of Array.from(head.childNodes)) {\r\n if (child.nodeType === Node.COMMENT_NODE) {\r\n const text = (child as Comment).data.trim();\r\n if (text === 'n-head') { inside = true; continue; }\r\n if (text === '/n-head') { closeComment = child as Comment; inside = false; continue; }\r\n }\r\n if (inside && child.nodeType === Node.ELEMENT_NODE)\r\n nodes.push(child as Element);\r\n }\r\n\r\n return { nodes, closeComment };\r\n}\r\n\r\n/** Stable key for an Element: tag name + sorted attribute list (name=value pairs). */\r\nfunction fingerprint(el: Element): string {\r\n return el.tagName + '|' + Array.from(el.attributes)\r\n .sort((a, b) => a.name.localeCompare(b.name))\r\n .map(a => `${a.name}=${a.value}`)\r\n .join('&');\r\n}\r\n\r\n/**\r\n * Diffs the live <!--n-head--> block against the incoming document's block and\r\n * applies the minimal set of DOM mutations:\r\n *\r\n * - Tags present in `next` but not in `live` \u2192 inserted before <!--/n-head-->\r\n * so they remain inside the tracked block on future navigations.\r\n * - Tags present in `live` but not in `next` \u2192 removed.\r\n * - Tags present in both \u2192 left untouched (no removal/re-insertion flash).\r\n *\r\n * If the live head has no sentinel block yet (e.g. initial page had no useHtml\r\n * tags), both sentinel comments are created on the fly.\r\n */\r\nfunction syncHeadTags(doc: Document): void {\r\n const live = headBlock(document.head);\r\n const next = headBlock(doc.head);\r\n\r\n const liveMap = new Map<string, Element>();\r\n for (const el of live.nodes) liveMap.set(fingerprint(el), el);\r\n\r\n const nextMap = new Map<string, Element>();\r\n for (const el of next.nodes) nextMap.set(fingerprint(el), el);\r\n\r\n // Ensure we have an anchor to insert before.\r\n let anchor = live.closeComment;\r\n if (!anchor) {\r\n document.head.appendChild(document.createComment('n-head'));\r\n anchor = document.createComment('/n-head');\r\n document.head.appendChild(anchor);\r\n }\r\n\r\n for (const [fp, el] of nextMap)\r\n if (!liveMap.has(fp)) document.head.insertBefore(el, anchor);\r\n\r\n for (const [fp, el] of liveMap)\r\n if (!nextMap.has(fp)) el.remove();\r\n}\r\n\r\n// \u2500\u2500\u2500 SPA navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Syncs attributes from a parsed element onto the live document element.\r\n * Adds/updates attributes present in `next` and removes any that were set\r\n * on `live` but are absent in `next` (clears stale htmlAttrs/bodyAttrs).\r\n */\r\nfunction syncAttrs(live: Element, next: Element): void {\r\n for (const { name, value } of Array.from(next.attributes))\r\n live.setAttribute(name, value);\r\n for (const { name } of Array.from(live.attributes))\r\n if (!next.hasAttribute(name)) live.removeAttribute(name);\r\n}\r\n\r\n/**\r\n * Listens for 'locationchange' events and performs a soft navigation:\r\n *\r\n * 1. Fetch the target URL as HTML (?__hmr=1 skips client-SSR for HMR speed).\r\n * 2. Parse the response with DOMParser.\r\n * 3. Apply all visual DOM changes first (head tags, html/body attrs, #app\r\n * innerHTML, title, __n_data) so the new content is painted before React\r\n * cleanup effects run \u2014 prevents a useHtml restore from briefly undoing\r\n * the new document state.\r\n * 4. Unmount old React roots (runs cleanup effects against the already-updated DOM).\r\n * 5. Re-hydrate new client component markers.\r\n * 6. Scroll to top.\r\n *\r\n * Falls back to a full page reload if anything goes wrong.\r\n */\r\nfunction setupNavigation(log: ReturnType<typeof makeLogger>): void {\r\n window.addEventListener('locationchange', async ({ detail: { href, hmr } }: any) => {\r\n try {\r\n const fetchUrl = hmr\r\n ? href + (href.includes('?') ? '&' : '?') + '__hmr=1'\r\n : href;\r\n\r\n const response = await fetch(fetchUrl, { headers: { Accept: 'text/html' } });\r\n if (!response.ok) {\r\n log.error('Navigation fetch failed:', response.status);\r\n return;\r\n }\r\n\r\n const parser = new DOMParser();\r\n const doc = parser.parseFromString(await response.text(), 'text/html');\r\n const newApp = doc.getElementById('app');\r\n const currApp = document.getElementById('app');\r\n if (!newApp || !currApp) return;\r\n\r\n // \u2500\u2500 Visual update \u2014 all DOM mutations before React teardown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // Styles must be in place before new content appears to avoid an unstyled\r\n // flash. Unmounting runs useEffect cleanups (including useHtml restores)\r\n // which would temporarily revert document state if done first.\r\n\r\n // 1. Head tags \u2014 diff-based sync preserves shared layout tags untouched.\r\n syncHeadTags(doc);\r\n\r\n // 2. <html> and <body> attributes (lang, class, style, etc.).\r\n syncAttrs(document.documentElement, doc.documentElement);\r\n syncAttrs(document.body, doc.body);\r\n\r\n // 3. Page content.\r\n currApp.innerHTML = newApp.innerHTML;\r\n\r\n // 4. <title>.\r\n const newTitle = doc.querySelector('title');\r\n if (newTitle) document.title = newTitle.textContent ?? '';\r\n\r\n // 5. Runtime data blob \u2014 must come after innerHTML swap so the new\r\n // __n_data element is part of the live document.\r\n const newDataEl = doc.getElementById('__n_data');\r\n const currDataEl = document.getElementById('__n_data');\r\n if (newDataEl && currDataEl) currDataEl.textContent = newDataEl.textContent;\r\n\r\n // \u2500\u2500 React teardown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 // Unmount after the visual update. Cleanup effects now run against an\r\n // already-updated document, so there is nothing left to visually undo.\r\n activeRoots.splice(0).forEach(r => r.unmount());\r\n\r\n // \u2500\u2500 Re-hydration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 navData = JSON.parse(currDataEl?.textContent ?? '{}') as RuntimeData;\r\n log.info('\uD83D\uDD04 Route \u2192', href, '\u2014 mounting', navData.hydrateIds?.length ?? 0, 'component(s)');\r\n\r\n const mods = await loadModules(navData.allIds ?? [], log, String(Date.now()));\r\n await mountNodes(mods, log);\r\n\r\n window.scrollTo(0, 0);\r\n log.info('\uD83C\uDF89 Navigation complete:', href);\r\n } catch (err) {\r\n log.error('Navigation error, falling back to full reload:', err);\r\n window.location.href = href;\r\n }\r\n });\r\n}\r\n\r\n// \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Shape of the JSON blob embedded as #__n_data in every SSR page. */\r\nexport interface RuntimeData {\r\n /** IDs of client components actually rendered on this page (subset of allIds). */\r\n hydrateIds: string[];\r\n /** All client component IDs reachable from this page, including layouts.\r\n * Pre-loaded so SPA navigations to related pages feel instant. */\r\n allIds: string[];\r\n url: string;\r\n params: Record<string, any>;\r\n debug: ClientDebugLevel;\r\n}\r\n\r\n/**\r\n * Bootstraps the NukeJS client runtime.\r\n *\r\n * Called once per page load from the inline <script type=\"module\"> injected\r\n * by the SSR renderer:\r\n *\r\n * ```js\r\n * const { initRuntime } = await import('nukejs');\r\n * const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n * await initRuntime(data);\r\n * ```\r\n *\r\n * Order of operations:\r\n * 1. Create the logger at the configured debug level.\r\n * 2. Wire up SPA navigation listener.\r\n * 3. Load all client component bundles in parallel.\r\n * 4. Hydrate every [data-hydrate-id] node.\r\n * 5. Patch history.pushState/replaceState so Link clicks trigger navigation.\r\n */\r\nexport async function initRuntime(data: RuntimeData): Promise<void> {\r\n const log = makeLogger(data.debug ?? 'silent');\r\n\r\n log.info('\uD83D\uDE80 Partial hydration:', data.hydrateIds.length, 'root component(s)');\r\n\r\n // Set up navigation first so any 'locationchange' fired during hydration\r\n // is captured (e.g. a redirect side-effect inside a component).\r\n setupNavigation(log);\r\n\r\n // Load all component bundles (not just hydrateIds) so SPA navigations to\r\n // related pages can mount their components without an extra network round-trip.\r\n const mods = await loadModules(data.allIds, log);\r\n await mountNodes(mods, log);\r\n\r\n log.info('\uD83C\uDF89 Done!');\r\n\r\n // Patch history last so pushState calls during hydration don't trigger a\r\n // navigation before roots are ready.\r\n setupLocationChangeMonitor();\r\n}"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* reconstruct them after loading the bundle.
|
|
12
12
|
*
|
|
13
13
|
* How it works:
|
|
14
|
-
* - analyzeComponent()
|
|
15
|
-
*
|
|
16
|
-
* - extractImports()
|
|
17
|
-
*
|
|
14
|
+
* - analyzeComponent() checks whether a file starts with "use client"
|
|
15
|
+
* and assigns a stable content-hash ID if it does.
|
|
16
|
+
* - extractImports() parses `import … from '…'` statements with a
|
|
17
|
+
* regex and resolves relative/absolute paths.
|
|
18
18
|
* - findClientComponentsInTree() recursively walks the import graph, stopping
|
|
19
|
-
*
|
|
19
|
+
* at client boundaries (they own their subtree).
|
|
20
20
|
*
|
|
21
21
|
* Results are memoised in `componentCache` (process-lifetime) so repeated SSR
|
|
22
22
|
* renders don't re-read and re-hash files they've already seen.
|
|
@@ -44,7 +44,7 @@ export declare function analyzeComponent(filePath: string, pagesDir: string): Co
|
|
|
44
44
|
* Recursively walks the import graph from `filePath`, collecting every
|
|
45
45
|
* "use client" file encountered.
|
|
46
46
|
*
|
|
47
|
-
* The walk stops at client boundaries: a "use client" file is
|
|
47
|
+
* The walk stops at client boundaries: a "use client" file is recorded and
|
|
48
48
|
* its own imports are NOT walked (the client runtime handles their subtree).
|
|
49
49
|
*
|
|
50
50
|
* The `visited` set prevents infinite loops from circular imports.
|
|
@@ -55,10 +55,7 @@ export declare function analyzeComponent(filePath: string, pagesDir: string): Co
|
|
|
55
55
|
export declare function findClientComponentsInTree(filePath: string, pagesDir: string, visited?: Set<string>): Map<string, string>;
|
|
56
56
|
/**
|
|
57
57
|
* Looks up the absolute file path for a client component by its ID.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* Returns undefined when the ID is not in the cache (page not yet visited
|
|
61
|
-
* in dev, or stale ID after a file change).
|
|
58
|
+
* Returns undefined when the ID is not in the cache.
|
|
62
59
|
*/
|
|
63
60
|
export declare function getComponentById(id: string): string | undefined;
|
|
64
61
|
/** Returns the live component cache (used by bundler.ts for ID→path lookup). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
const componentCache = /* @__PURE__ */ new Map();
|
|
6
6
|
function isClientComponent(filePath) {
|
|
@@ -14,8 +14,7 @@ function isClientComponent(filePath) {
|
|
|
14
14
|
return false;
|
|
15
15
|
}
|
|
16
16
|
function getClientComponentId(filePath, pagesDir) {
|
|
17
|
-
|
|
18
|
-
return `cc_${hash}`;
|
|
17
|
+
return "cc_" + createHash("md5").update(path.relative(pagesDir, filePath)).digest("hex").substring(0, 8);
|
|
19
18
|
}
|
|
20
19
|
function analyzeComponent(filePath, pagesDir) {
|
|
21
20
|
if (componentCache.has(filePath)) return componentCache.get(filePath);
|
|
@@ -32,11 +31,11 @@ function extractImports(filePath) {
|
|
|
32
31
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
33
32
|
const dir = path.dirname(filePath);
|
|
34
33
|
const imports = [];
|
|
35
|
-
const importRegex = /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
34
|
+
const importRegex = /(?:import|export)\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
36
35
|
let match;
|
|
37
36
|
while ((match = importRegex.exec(content)) !== null) {
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
37
|
+
const spec = match[1];
|
|
38
|
+
if (spec === "nukejs") {
|
|
40
39
|
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
41
40
|
for (const candidate of [
|
|
42
41
|
path.join(selfDir, "index.ts"),
|
|
@@ -49,36 +48,50 @@ function extractImports(filePath) {
|
|
|
49
48
|
}
|
|
50
49
|
continue;
|
|
51
50
|
}
|
|
52
|
-
if (!
|
|
53
|
-
let resolved = path.resolve(dir,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) continue;
|
|
52
|
+
let resolved = path.resolve(dir, spec);
|
|
53
|
+
const EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
54
|
+
const isFile = (p) => fs.existsSync(p) && fs.statSync(p).isFile();
|
|
55
|
+
if (!isFile(resolved)) {
|
|
56
|
+
let found = false;
|
|
57
|
+
for (const ext of EXTS) {
|
|
58
|
+
if (isFile(resolved + ext)) {
|
|
59
|
+
resolved += ext;
|
|
60
|
+
found = true;
|
|
59
61
|
break;
|
|
60
62
|
}
|
|
61
63
|
}
|
|
64
|
+
if (!found && fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
65
|
+
for (const ext of EXTS) {
|
|
66
|
+
const candidate = path.join(resolved, `index${ext}`);
|
|
67
|
+
if (isFile(candidate)) {
|
|
68
|
+
resolved = candidate;
|
|
69
|
+
found = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!found) continue;
|
|
62
75
|
}
|
|
63
|
-
|
|
76
|
+
imports.push(resolved);
|
|
64
77
|
}
|
|
65
78
|
return imports;
|
|
66
79
|
}
|
|
67
80
|
function findClientComponentsInTree(filePath, pagesDir, visited = /* @__PURE__ */ new Set()) {
|
|
68
|
-
const
|
|
69
|
-
if (visited.has(filePath)) return
|
|
81
|
+
const found = /* @__PURE__ */ new Map();
|
|
82
|
+
if (visited.has(filePath)) return found;
|
|
70
83
|
visited.add(filePath);
|
|
71
84
|
const info = analyzeComponent(filePath, pagesDir);
|
|
72
85
|
if (info.isClientComponent && info.clientComponentId) {
|
|
73
|
-
|
|
74
|
-
return
|
|
86
|
+
found.set(info.clientComponentId, filePath);
|
|
87
|
+
return found;
|
|
75
88
|
}
|
|
76
89
|
for (const importPath of extractImports(filePath)) {
|
|
77
90
|
for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {
|
|
78
|
-
|
|
91
|
+
found.set(id, p);
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
|
-
return
|
|
94
|
+
return found;
|
|
82
95
|
}
|
|
83
96
|
function getComponentById(id) {
|
|
84
97
|
for (const [filePath, info] of componentCache) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/component-analyzer.ts"],
|
|
4
|
-
"sourcesContent": ["/**\r\n * component-analyzer.ts \u2014 Static Import Analyzer & Client Component Registry\r\n *\r\n * This module solves a core problem in NukeJS's partial hydration model:\r\n * the server needs to know *at render time* which components in a page's\r\n * import tree are \"use client\" boundaries so it can:\r\n *\r\n * 1. Emit <span data-hydrate-id=\"\u2026\"> markers instead of rendering them.\r\n * 2. Inject the matching bundle URLs into the page's runtime data blob.\r\n * 3. Serialize the props passed to those components so the browser can\r\n * reconstruct them after loading the bundle.\r\n *\r\n * How it works:\r\n * - analyzeComponent() checks whether a file starts with \"use client\"\r\n * and assigns a stable content-hash ID if it does.\r\n * - extractImports() parses `import \u2026 from '\u2026'` statements with a\r\n * regex and resolves relative/absolute paths.\r\n * - findClientComponentsInTree() recursively walks the import graph, stopping\r\n * at client boundaries (they own their subtree).\r\n *\r\n * Results are memoised in `componentCache` (process-lifetime) so repeated SSR\r\n * renders don't re-read and re-hash files they've already seen.\r\n *\r\n * ID scheme:\r\n * The ID for a client component is `cc_` + the first 8 hex chars of the MD5\r\n * hash of its path relative to pagesDir. This is stable across restarts and\r\n * matches what the browser will request from /__client-component/<id>.js.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\nimport crypto from 'crypto';\r\nimport { fileURLToPath } from 'url';\r\n\r\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface ComponentInfo {\r\n filePath: string;\r\n /** True when the file's first non-comment line is \"use client\". */\r\n isClientComponent: boolean;\r\n /** Stable hash-based ID, present only for client components. */\r\n clientComponentId?: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 In-process cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n// Memoises analyze results for the lifetime of the dev server process.\r\n// In production builds the analysis runs once per build, so no cache is needed.\r\nconst componentCache = new Map<string, ComponentInfo>();\r\n\r\n// \u2500\u2500\u2500 Client boundary detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns true when a file begins with a `\"use client\"` or `'use client'`\r\n * directive (ignoring blank lines and line/block comment prefixes).\r\n *\r\n * Only the first five lines are checked \u2014 the directive must appear before\r\n * any executable code.\r\n */\r\nfunction isClientComponent(filePath: string): boolean {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n for (const line of content.split('\\n').slice(0, 5)) {\r\n const trimmed = line.trim();\r\n if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;\r\n if (/^[\"']use client[\"'];?$/.test(trimmed)) return true;\r\n break; // First substantive line is not \"use client\"\r\n }\r\n return false;\r\n}\r\n\r\n/**\r\n * Generates a deterministic, short ID for a client component.\r\n * The path is made relative to pagesDir before hashing so the ID is\r\n * portable across machines (absolute paths differ per developer).\r\n */\r\nfunction getClientComponentId(filePath: string, pagesDir: string): string {\r\n const hash = crypto\r\n .createHash('md5')\r\n .update(path.relative(pagesDir, filePath))\r\n .digest('hex')\r\n .substring(0, 8);\r\n return `cc_${hash}`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Analyses a component file and returns cached results on subsequent calls.\r\n *\r\n * @param filePath Absolute path to the source file.\r\n * @param pagesDir Absolute path to the pages root (used for ID generation).\r\n */\r\nexport function analyzeComponent(filePath: string, pagesDir: string): ComponentInfo {\r\n if (componentCache.has(filePath)) return componentCache.get(filePath)!;\r\n\r\n const isClient = isClientComponent(filePath);\r\n const info: ComponentInfo = {\r\n filePath,\r\n isClientComponent: isClient,\r\n clientComponentId: isClient ? getClientComponentId(filePath, pagesDir) : undefined,\r\n };\r\n\r\n componentCache.set(filePath, info);\r\n return info;\r\n}\r\n\r\n// \u2500\u2500\u2500 Import extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Parses `import \u2026 from '\u2026'` statements in a file and returns a list of\r\n * resolved absolute paths for all *local* imports (relative or absolute paths).\r\n *\r\n * Non-local specifiers (npm packages) are skipped, except `nukejs` itself \u2014\r\n * which is resolved to our own index file so built-in \"use client\" components\r\n * like `<Link>` are included in the client component discovery walk.\r\n *\r\n * Extensions are tried in priority order if the specifier has none.\r\n */\r\nfunction extractImports(filePath: string): string[] {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n const dir = path.dirname(filePath);\r\n const imports: string[] = [];\r\n\r\n const importRegex =\r\n /import\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = importRegex.exec(content)) !== null) {\r\n const importPath = match[1];\r\n\r\n // Special case: resolve the 'nukejs' package import to our own source so\r\n // built-in \"use client\" exports (Link, useRouter, etc.) are discovered.\r\n if (importPath === 'nukejs') {\r\n const selfDir = path.dirname(fileURLToPath(import.meta.url));\r\n for (const candidate of [\r\n path.join(selfDir, 'index.ts'),\r\n path.join(selfDir, 'index.js'),\r\n ]) {\r\n if (fs.existsSync(candidate)) { imports.push(candidate); break; }\r\n }\r\n continue;\r\n }\r\n\r\n // Skip npm packages and other non-local specifiers.\r\n if (!importPath.startsWith('.') && !importPath.startsWith('/')) continue;\r\n\r\n // Resolve to an absolute path and add common extensions if needed.\r\n let resolved = path.resolve(dir, importPath);\r\n if (!fs.existsSync(resolved)) {\r\n for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {\r\n const candidate = resolved + ext;\r\n if (fs.existsSync(candidate)) { resolved = candidate; break; }\r\n }\r\n }\r\n if (fs.existsSync(resolved)) imports.push(resolved);\r\n }\r\n\r\n return imports;\r\n}\r\n\r\n// \u2500\u2500\u2500 Tree walk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively walks the import graph from `filePath`, collecting every\r\n * \"use client\" file encountered.\r\n *\r\n * The walk stops at client boundaries: a \"use client\" file is collected and\r\n * its own imports are NOT walked (the client runtime handles their subtree).\r\n *\r\n * The `visited` set prevents infinite loops from circular imports.\r\n *\r\n * @returns Map<id, absoluteFilePath> for every client component reachable\r\n * from `filePath` (including `filePath` itself if it's a client).\r\n */\r\nexport function findClientComponentsInTree(\r\n filePath: string,\r\n pagesDir: string,\r\n visited = new Set<string>(),\r\n): Map<string, string> {\r\n const clientComponents = new Map<string, string>();\r\n if (visited.has(filePath)) return clientComponents;\r\n visited.add(filePath);\r\n\r\n const info = analyzeComponent(filePath, pagesDir);\r\n\r\n // This file is a client boundary \u2014 record it and stop descending.\r\n if (info.isClientComponent && info.clientComponentId) {\r\n clientComponents.set(info.clientComponentId, filePath);\r\n return clientComponents;\r\n }\r\n\r\n // Server component \u2014 recurse into its imports.\r\n for (const importPath of extractImports(filePath)) {\r\n for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {\r\n clientComponents.set(id, p);\r\n }\r\n }\r\n\r\n return clientComponents;\r\n}\r\n\r\n// \u2500\u2500\u2500 Cache access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n\r\n/**\r\n * Looks up the absolute file path for a client component by its ID.\r\n * O(1) reverse lookup \u2014 avoids the O(n) linear scan in bundler.ts.\r\n *\r\n * Returns undefined when the ID is not in the cache (page not yet visited\r\n * in dev, or stale ID after a file change).\r\n */\r\nexport function getComponentById(id: string): string | undefined {\r\n for (const [filePath, info] of componentCache) {\r\n if (info.clientComponentId === id) return filePath;\r\n }\r\n return undefined;\r\n}\r\n\r\n/** Returns the live component cache (used by bundler.ts for ID\u2192path lookup). */\r\nexport function getComponentCache(): Map<string, ComponentInfo> {\r\n return componentCache;\r\n}\r\n\r\n/**\r\n * Removes a single file's analysis entry from the cache.\r\n * Call this whenever a source file changes in dev mode so the next render\r\n * re-analyses the file (picks up added/removed \"use client\" directives and\r\n * changed import graphs).\r\n */\r\nexport function invalidateComponentCache(filePath: string): void {\r\n componentCache.delete(filePath);\r\n}"],
|
|
5
|
-
"mappings": "AA6BA,OAAO,UAAU;AACjB,OAAO,
|
|
4
|
+
"sourcesContent": ["/**\n * component-analyzer.ts \u2014 Static Import Analyzer & Client Component Registry\n *\n * This module solves a core problem in NukeJS's partial hydration model:\n * the server needs to know *at render time* which components in a page's\n * import tree are \"use client\" boundaries so it can:\n *\n * 1. Emit <span data-hydrate-id=\"\u2026\"> markers instead of rendering them.\n * 2. Inject the matching bundle URLs into the page's runtime data blob.\n * 3. Serialize the props passed to those components so the browser can\n * reconstruct them after loading the bundle.\n *\n * How it works:\n * - analyzeComponent() checks whether a file starts with \"use client\"\n * and assigns a stable content-hash ID if it does.\n * - extractImports() parses `import \u2026 from '\u2026'` statements with a\n * regex and resolves relative/absolute paths.\n * - findClientComponentsInTree() recursively walks the import graph, stopping\n * at client boundaries (they own their subtree).\n *\n * Results are memoised in `componentCache` (process-lifetime) so repeated SSR\n * renders don't re-read and re-hash files they've already seen.\n *\n * ID scheme:\n * The ID for a client component is `cc_` + the first 8 hex chars of the MD5\n * hash of its path relative to pagesDir. This is stable across restarts and\n * matches what the browser will request from /__client-component/<id>.js.\n */\n\nimport path from 'path';\nimport fs from 'fs';\nimport { createHash } from 'node:crypto';\nimport { fileURLToPath } from 'url';\n\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ComponentInfo {\n filePath: string;\n /** True when the file's first non-comment line is \"use client\". */\n isClientComponent: boolean;\n /** Stable hash-based ID, present only for client components. */\n clientComponentId?: string;\n}\n\n// \u2500\u2500\u2500 In-process cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// Memoises analyze results for the lifetime of the dev server process.\n// In production builds the analysis runs once per build, so no cache is needed.\nconst componentCache = new Map<string, ComponentInfo>();\n\n// \u2500\u2500\u2500 Client boundary detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Returns true when a file begins with a `\"use client\"` or `'use client'`\n * directive (ignoring blank lines and line/block comment prefixes).\n *\n * Only the first five lines are checked \u2014 the directive must appear before\n * any executable code.\n */\nfunction isClientComponent(filePath: string): boolean {\n const content = fs.readFileSync(filePath, 'utf-8');\n for (const line of content.split('\\n').slice(0, 5)) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;\n if (/^[\"']use client[\"'];?$/.test(trimmed)) return true;\n break; // First substantive line is not \"use client\"\n }\n return false;\n}\n\n/**\n * Generates a deterministic, short ID for a client component.\n * The path is made relative to pagesDir before hashing so the ID is\n * portable across machines (absolute paths differ per developer).\n */\nfunction getClientComponentId(filePath: string, pagesDir: string): string {\n return 'cc_' + createHash('md5')\n .update(path.relative(pagesDir, filePath))\n .digest('hex')\n .substring(0, 8);\n}\n\n// \u2500\u2500\u2500 Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Analyses a component file and returns cached results on subsequent calls.\n *\n * @param filePath Absolute path to the source file.\n * @param pagesDir Absolute path to the pages root (used for ID generation).\n */\nexport function analyzeComponent(filePath: string, pagesDir: string): ComponentInfo {\n if (componentCache.has(filePath)) return componentCache.get(filePath)!;\n\n const isClient = isClientComponent(filePath);\n const info: ComponentInfo = {\n filePath,\n isClientComponent: isClient,\n clientComponentId: isClient ? getClientComponentId(filePath, pagesDir) : undefined,\n };\n\n componentCache.set(filePath, info);\n return info;\n}\n\n// \u2500\u2500\u2500 Import extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Parses `import \u2026 from '\u2026'` and `export \u2026 from '\u2026'` statements in a file\n * and returns a list of resolved absolute paths for all *local* imports.\n *\n * Non-local specifiers (npm packages) are skipped, except `nukejs` itself \u2014\n * which is resolved to our own index file so built-in \"use client\" components\n * like `<Link>` are included in the client component discovery walk.\n *\n * Extensions are tried in priority order if the specifier has none.\n */\nfunction extractImports(filePath: string): string[] {\n const content = fs.readFileSync(filePath, 'utf-8');\n const dir = path.dirname(filePath);\n const imports: string[] = [];\n\n const importRegex =\n /(?:import|export)\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]/g;\n let match: RegExpExecArray | null;\n\n while ((match = importRegex.exec(content)) !== null) {\n const spec = match[1];\n\n // Special case: resolve the 'nukejs' package to our own source so\n // built-in \"use client\" exports (Link, useRouter, etc.) are discovered.\n if (spec === 'nukejs') {\n const selfDir = path.dirname(fileURLToPath(import.meta.url));\n for (const candidate of [\n path.join(selfDir, 'index.ts'),\n path.join(selfDir, 'index.js'),\n ]) {\n if (fs.existsSync(candidate)) { imports.push(candidate); break; }\n }\n continue;\n }\n\n // Skip npm packages and other non-local specifiers.\n if (!spec.startsWith('.') && !spec.startsWith('/')) continue;\n\n // Resolve to an absolute path, trying extensions if needed.\n let resolved = path.resolve(dir, spec);\n const EXTS = ['.tsx', '.ts', '.jsx', '.js'] as const;\n const isFile = (p: string) => fs.existsSync(p) && fs.statSync(p).isFile();\n\n if (!isFile(resolved)) {\n let found = false;\n\n // 1. Try appending an extension (./Button \u2192 ./Button.tsx)\n for (const ext of EXTS) {\n if (isFile(resolved + ext)) { resolved += ext; found = true; break; }\n }\n\n // 2. Try an index file inside the directory (./components \u2192 ./components/index.tsx)\n if (!found && fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n for (const ext of EXTS) {\n const candidate = path.join(resolved, `index${ext}`);\n if (isFile(candidate)) { resolved = candidate; found = true; break; }\n }\n }\n\n if (!found) continue; // Unresolvable \u2014 skip silently\n }\n\n imports.push(resolved);\n }\n\n return imports;\n}\n\n// \u2500\u2500\u2500 Tree walk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Recursively walks the import graph from `filePath`, collecting every\n * \"use client\" file encountered.\n *\n * The walk stops at client boundaries: a \"use client\" file is recorded and\n * its own imports are NOT walked (the client runtime handles their subtree).\n *\n * The `visited` set prevents infinite loops from circular imports.\n *\n * @returns Map<id, absoluteFilePath> for every client component reachable\n * from `filePath` (including `filePath` itself if it's a client).\n */\nexport function findClientComponentsInTree(\n filePath: string,\n pagesDir: string,\n visited = new Set<string>(),\n): Map<string, string> {\n const found = new Map<string, string>();\n if (visited.has(filePath)) return found;\n visited.add(filePath);\n\n const info = analyzeComponent(filePath, pagesDir);\n\n if (info.isClientComponent && info.clientComponentId) {\n found.set(info.clientComponentId, filePath);\n return found; // Stop \u2014 client boundary owns its subtree\n }\n\n for (const importPath of extractImports(filePath)) {\n for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {\n found.set(id, p);\n }\n }\n\n return found;\n}\n\n// \u2500\u2500\u2500 Cache access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Looks up the absolute file path for a client component by its ID.\n * Returns undefined when the ID is not in the cache.\n */\nexport function getComponentById(id: string): string | undefined {\n for (const [filePath, info] of componentCache) {\n if (info.clientComponentId === id) return filePath;\n }\n return undefined;\n}\n\n/** Returns the live component cache (used by bundler.ts for ID\u2192path lookup). */\nexport function getComponentCache(): Map<string, ComponentInfo> {\n return componentCache;\n}\n\n/**\n * Removes a single file's analysis entry from the cache.\n * Call this whenever a source file changes in dev mode so the next render\n * re-analyses the file (picks up added/removed \"use client\" directives and\n * changed import graphs).\n */\nexport function invalidateComponentCache(filePath: string): void {\n componentCache.delete(filePath);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/router.d.ts
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
* Maps incoming URL paths to handler files using Next.js-compatible conventions:
|
|
5
5
|
*
|
|
6
6
|
* server/users/index.ts → /users
|
|
7
|
-
* server/users/[id].ts → /users/:id
|
|
8
|
-
* server/
|
|
9
|
-
* server/
|
|
7
|
+
* server/users/[id].ts → /users/:id (dynamic segment)
|
|
8
|
+
* server/users/[[id]].ts → /users or /users/42 (optional single)
|
|
9
|
+
* server/blog/[...slug].ts → /blog/* (required catch-all)
|
|
10
|
+
* server/files/[[...path]].ts → /files or /files/* (optional catch-all)
|
|
10
11
|
*
|
|
11
|
-
* Route specificity (higher
|
|
12
|
-
* static segment
|
|
13
|
-
* dynamic
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Route specificity (higher score wins):
|
|
13
|
+
* static segment +5 (e.g. 'about')
|
|
14
|
+
* [dynamic] +4 (e.g. '[id]')
|
|
15
|
+
* [[optional]] +3 (e.g. '[[id]]')
|
|
16
|
+
* [...catchAll] +2 (e.g. '[...slug]')
|
|
17
|
+
* [[...optCatchAll]] +1 (e.g. '[[...path]]')
|
|
16
18
|
*
|
|
17
19
|
* Path traversal protection:
|
|
18
20
|
* matchRoute() rejects URL segments that contain '..' or '.' and verifies
|
|
@@ -20,35 +22,36 @@
|
|
|
20
22
|
* checking whether the file exists.
|
|
21
23
|
*/
|
|
22
24
|
/**
|
|
23
|
-
* Recursively collects all .ts/.tsx files in `dir`, returning paths
|
|
24
|
-
* `baseDir` without the file extension.
|
|
25
|
+
* Recursively collects all routable .ts/.tsx files in `dir`, returning paths
|
|
26
|
+
* relative to `baseDir` without the file extension.
|
|
27
|
+
*
|
|
28
|
+
* layout.tsx files are excluded — they wrap pages but are never routes
|
|
29
|
+
* themselves. This mirrors the filter in collectServerPages() so dev-mode
|
|
30
|
+
* route matching behaves identically to the production build.
|
|
25
31
|
*
|
|
26
32
|
* Example output: ['index', 'users/index', 'users/[id]', 'blog/[...slug]']
|
|
27
33
|
*/
|
|
28
34
|
export declare function findAllRoutes(dir: string, baseDir?: string): string[];
|
|
29
35
|
/**
|
|
30
36
|
* Attempts to match `urlSegments` against a route that may contain dynamic
|
|
31
|
-
* segments ([param]),
|
|
37
|
+
* segments ([param]), optional single segments ([[param]]), catch-alls
|
|
38
|
+
* ([...slug]), and optional catch-alls ([[...path]]).
|
|
32
39
|
*
|
|
33
40
|
* Returns the captured params on success, or null if the route does not match.
|
|
34
41
|
*
|
|
35
42
|
* Param value types:
|
|
36
|
-
* [param]
|
|
37
|
-
* [
|
|
38
|
-
* [
|
|
43
|
+
* [param] → string (required)
|
|
44
|
+
* [[param]] → string (optional, '' when absent)
|
|
45
|
+
* [...slug] → string[] (required, ≥1 segment)
|
|
46
|
+
* [[...path]] → string[] (optional, may be empty)
|
|
39
47
|
*/
|
|
40
48
|
export declare function matchDynamicRoute(urlSegments: string[], routePath: string): {
|
|
41
49
|
params: Record<string, string | string[]>;
|
|
42
50
|
} | null;
|
|
43
51
|
/**
|
|
44
52
|
* Computes a specificity score for a route path.
|
|
45
|
-
* Used to sort candidate routes so more specific routes shadow
|
|
46
|
-
*
|
|
47
|
-
* Higher score = more specific:
|
|
48
|
-
* static segment 4
|
|
49
|
-
* [dynamic] 3
|
|
50
|
-
* [...catchAll] 2
|
|
51
|
-
* [[...optCatchAll]] 1
|
|
53
|
+
* Used to sort candidate routes so more specific routes shadow less specific ones.
|
|
54
|
+
* Higher score = more specific.
|
|
52
55
|
*/
|
|
53
56
|
export declare function getRouteSpecificity(routePath: string): number;
|
|
54
57
|
export interface RouteMatch {
|
|
@@ -62,6 +65,7 @@ export interface RouteMatch {
|
|
|
62
65
|
* Steps:
|
|
63
66
|
* 1. Reject '..' or '.' path segments (path traversal guard).
|
|
64
67
|
* 2. Try an exact file match (e.g. /about → baseDir/about.tsx).
|
|
68
|
+
* layout.tsx is explicitly excluded from exact matching.
|
|
65
69
|
* 3. Sort all discovered routes by specificity (most specific first).
|
|
66
70
|
* 4. Return the first dynamic route that matches.
|
|
67
71
|
*
|
|
@@ -78,7 +82,7 @@ export declare function matchRoute(urlPath: string, baseDir: string, extension?:
|
|
|
78
82
|
* app/pages/layout.tsx ← root layout
|
|
79
83
|
* app/pages/blog/layout.tsx ← blog section layout
|
|
80
84
|
*
|
|
81
|
-
*
|
|
85
|
+
* Outermost-first order matches how wrapWithLayouts() nests them:
|
|
82
86
|
* the last layout in the array is the innermost wrapper.
|
|
83
87
|
*/
|
|
84
88
|
export declare function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[];
|
package/dist/router.js
CHANGED
|
@@ -8,6 +8,8 @@ function findAllRoutes(dir, baseDir = dir) {
|
|
|
8
8
|
if (entry.isDirectory()) {
|
|
9
9
|
routes.push(...findAllRoutes(fullPath, baseDir));
|
|
10
10
|
} else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
|
|
11
|
+
const stem = entry.name.replace(/\.(tsx|ts)$/, "");
|
|
12
|
+
if (stem === "layout") continue;
|
|
11
13
|
routes.push(path.relative(baseDir, fullPath).replace(/\.(tsx|ts)$/, ""));
|
|
12
14
|
}
|
|
13
15
|
}
|
|
@@ -26,6 +28,12 @@ function matchDynamicRoute(urlSegments, routePath) {
|
|
|
26
28
|
params[optCatchAll[1]] = urlSegments.slice(ui);
|
|
27
29
|
return { params };
|
|
28
30
|
}
|
|
31
|
+
const optDynamic = seg.match(/^\[\[([^.][^\]]*)\]\]$/);
|
|
32
|
+
if (optDynamic) {
|
|
33
|
+
params[optDynamic[1]] = ui < urlSegments.length ? urlSegments[ui++] : "";
|
|
34
|
+
ri++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
29
37
|
const catchAll = seg.match(/^\[\.\.\.(.+)\]$/);
|
|
30
38
|
if (catchAll) {
|
|
31
39
|
const remaining = urlSegments.slice(ui);
|
|
@@ -50,8 +58,9 @@ function getRouteSpecificity(routePath) {
|
|
|
50
58
|
return routePath.split(path.sep).reduce((score, seg) => {
|
|
51
59
|
if (seg.match(/^\[\[\.\.\.(.+)\]\]$/)) return score + 1;
|
|
52
60
|
if (seg.match(/^\[\.\.\.(.+)\]$/)) return score + 2;
|
|
53
|
-
if (seg.match(/^\[(
|
|
54
|
-
return score + 4;
|
|
61
|
+
if (seg.match(/^\[\[([^.][^\]]*)\]\]$/)) return score + 3;
|
|
62
|
+
if (seg.match(/^\[(.+)\]$/)) return score + 4;
|
|
63
|
+
return score + 5;
|
|
55
64
|
}, 0);
|
|
56
65
|
}
|
|
57
66
|
function isWithinBase(baseDir, filePath) {
|
|
@@ -63,8 +72,7 @@ function matchRoute(urlPath, baseDir, extension = ".tsx") {
|
|
|
63
72
|
if (rawSegments.some((s) => s === ".." || s === ".")) return null;
|
|
64
73
|
const segments = rawSegments.length === 0 ? ["index"] : rawSegments;
|
|
65
74
|
const exactPath = path.join(baseDir, ...segments) + extension;
|
|
66
|
-
if (
|
|
67
|
-
if (fs.existsSync(exactPath)) {
|
|
75
|
+
if (isWithinBase(baseDir, exactPath) && path.basename(exactPath, extension) !== "layout" && fs.existsSync(exactPath)) {
|
|
68
76
|
return { filePath: exactPath, params: {}, routePattern: segments.join("/") };
|
|
69
77
|
}
|
|
70
78
|
const sortedRoutes = findAllRoutes(baseDir).sort(
|
|
@@ -74,8 +82,7 @@ function matchRoute(urlPath, baseDir, extension = ".tsx") {
|
|
|
74
82
|
const match = matchDynamicRoute(segments, route);
|
|
75
83
|
if (!match) continue;
|
|
76
84
|
const filePath = path.join(baseDir, route) + extension;
|
|
77
|
-
if (
|
|
78
|
-
if (fs.existsSync(filePath)) {
|
|
85
|
+
if (isWithinBase(baseDir, filePath) && fs.existsSync(filePath)) {
|
|
79
86
|
return { filePath, params: match.params, routePattern: route };
|
|
80
87
|
}
|
|
81
88
|
}
|
|
@@ -87,7 +94,7 @@ function findLayoutsForRoute(routeFilePath, pagesDir) {
|
|
|
87
94
|
if (fs.existsSync(rootLayout)) layouts.push(rootLayout);
|
|
88
95
|
const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));
|
|
89
96
|
if (!relativePath || relativePath === ".") return layouts;
|
|
90
|
-
const segments = relativePath.split(path.sep).filter(
|
|
97
|
+
const segments = relativePath.split(path.sep).filter(Boolean);
|
|
91
98
|
for (let i = 1; i <= segments.length; i++) {
|
|
92
99
|
const layoutPath = path.join(pagesDir, ...segments.slice(0, i), "layout.tsx");
|
|
93
100
|
if (fs.existsSync(layoutPath)) layouts.push(layoutPath);
|