@kudzujs/core 0.8.52 → 0.8.55

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.
@@ -113,7 +113,7 @@ async function mountInitial() {
113
113
  `, "", "initial effect mount"],
114
114
  [" await ready\n", "", "initial effect readiness"],
115
115
  [" const { incoming, parsed, capabilities } = documentResult\n", " const { incoming, parsed } = documentResult\n", "navigation capability result"],
116
- [" await routeDispose()\n if (current !== revision) return\n", "", "route effect disposal"],
116
+ [" await routeDispose()\n if (current !== revision) {\n styleUpdate.rollback()\n return\n }\n", "", "route effect disposal"],
117
117
  [" commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)\n", " commit(incoming, parsed.nodes)\n", "navigation capability commit"],
118
118
  [" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "", "route effect mount"],
119
119
  [" return { incoming, parsed, capabilities: await loadCapabilities(parsed), record }\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n return { incoming, parsed, record }\n", "navigation capability load"],
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto"
2
+ import { assertRouteBuildRecord } from "./route-build-record.mjs"
3
+ import { planRouteCapabilities } from "./route-capability-planner.mjs"
4
+
5
+ export function capabilitySignature(capability) {
6
+ return createHash("sha256").update(JSON.stringify(capability)).digest("hex")
7
+ }
8
+
9
+ export function planRuntimeFamilies(records, navigationGroups = []) {
10
+ for (const record of records) assertRouteBuildRecord(record)
11
+ const grouped = new Set()
12
+ const scopes = []
13
+ for (const group of navigationGroups) {
14
+ const groupRecords = group.buildRecords ?? group.records ?? []
15
+ if (!groupRecords.length) continue
16
+ for (const record of groupRecords) {
17
+ if (!records.includes(record)) throw new Error(`Navigation runtime family contains an unknown route: ${record.route}`)
18
+ if (grouped.has(record)) throw new Error(`Route belongs to multiple runtime families: ${record.route}`)
19
+ grouped.add(record)
20
+ }
21
+ scopes.push({ navigation: true, records: groupRecords })
22
+ }
23
+ for (const record of records) if (!grouped.has(record) && record.capabilities.hasBehaviors) scopes.push({ navigation: false, records: [record] })
24
+
25
+ const familiesBySignature = new Map()
26
+ const signaturesById = new Map()
27
+ const familyByRecord = new Map()
28
+ for (const scope of scopes) {
29
+ const capability = planRouteCapabilities(scope.records, { navigationRouteCount: scope.navigation ? scope.records.length : 0 })
30
+ const descriptor = { version: 1, navigation: scope.navigation, capability }
31
+ const signature = capabilitySignature(descriptor)
32
+ const id = signature.slice(0, 16)
33
+ const existingSignature = signaturesById.get(id)
34
+ if (existingSignature && existingSignature !== signature) throw new Error(`Runtime family ID collision: ${id}`)
35
+ signaturesById.set(id, signature)
36
+ let family = familiesBySignature.get(signature)
37
+ if (!family) {
38
+ family = { id, signature, navigation: scope.navigation, capability, records: [] }
39
+ familiesBySignature.set(signature, family)
40
+ }
41
+ for (const record of scope.records) {
42
+ family.records.push(record)
43
+ familyByRecord.set(record, family)
44
+ }
45
+ }
46
+ const families = [...familiesBySignature.values()].map(family => ({ ...family, records: [...family.records].sort((left, right) => left.route.localeCompare(right.route)) })).sort((left, right) => left.id.localeCompare(right.id))
47
+ return { families, familyByRecord }
48
+ }
@@ -3059,7 +3059,7 @@ async function safeStaticFiles(files) {
3059
3059
  return new Set(entries.filter(Boolean))
3060
3060
  }
3061
3061
 
3062
- function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3062
+ function orderSourceStyles(entryFiles, sourceFiles, sourceIndex, staticFiles) {
3063
3063
  const ordered = []
3064
3064
  const seenStyles = new Set()
3065
3065
  const seenSources = new Set()
@@ -3069,11 +3069,13 @@ function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3069
3069
  seenSources.add(file)
3070
3070
  const sourceFile = parseSourceFile(file, sourceIndex.get(file))
3071
3071
  for (const statement of sourceFile.statements) {
3072
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) continue
3072
+ if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !runtimeModuleReference(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) continue
3073
3073
  const specifier = statement.moduleSpecifier.text
3074
- if (staticImportExtension(specifier) === ".css") {
3075
- let target
3076
- try { target = resolveStaticImport(file, specifier, staticFiles) } catch { continue }
3074
+ const queryIndex = specifier.indexOf("?")
3075
+ const query = queryIndex === -1 ? "" : specifier.slice(queryIndex + 1)
3076
+ if (ts.isImportDeclaration(statement) && staticImportExtension(specifier) === ".css") {
3077
+ if (query) continue
3078
+ const target = resolveStaticImport(file, specifier, staticFiles)
3077
3079
  if (!seenStyles.has(target)) {
3078
3080
  seenStyles.add(target)
3079
3081
  ordered.push(target)
@@ -3081,12 +3083,11 @@ function orderSourceStyles(cssFiles, sourceFiles, sourceIndex, staticFiles) {
3081
3083
  continue
3082
3084
  }
3083
3085
  if (isStaticImport(specifier)) continue
3084
- try { visit(resolveSourceImport(file, specifier, sourceSet)) } catch {}
3086
+ visit(resolveSourceImport(file, specifier, sourceSet))
3085
3087
  }
3086
3088
  }
3087
- for (const file of sourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))) visit(file)
3088
- for (const file of sourceFiles) visit(file)
3089
- return [...ordered, ...cssFiles.filter(file => !seenStyles.has(file))]
3089
+ for (const file of entryFiles) visit(file)
3090
+ return ordered
3090
3091
  }
3091
3092
 
3092
3093
  function staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory) {
@@ -3105,7 +3106,7 @@ function staticImportEntry(node, sourceFile, file, staticFiles, importedAssets,
3105
3106
  const extension = staticImportExtension(specifier)
3106
3107
  if (query === "url") {
3107
3108
  if (!node.importClause?.name || node.importClause.isTypeOnly || node.importClause.namedBindings) throw sourceNodeError(node, sourceFile, "Static assets require one default import")
3108
- if (extension !== ".css") importedAssets.add(target)
3109
+ importedAssets.add(target)
3109
3110
  const value = factory.createStringLiteral(assetPath(base, `assets/${relative(sourceDirectory, target).replaceAll(sep, "/")}`))
3110
3111
  return staticImportReplacement(node.importClause.name.text, value, factory)
3111
3112
  }
@@ -130,7 +130,7 @@ export function createWorkerCompiler({
130
130
 
131
131
  const emit = async (references, sourceFiles, assetsDirectory, base, minify) => {
132
132
  const roots = [...new Set(references.map(reference => resolve(sourceDirectory, reference.root)))].sort()
133
- if (!roots.length) return new Map()
133
+ if (!roots.length) return { assets: new Map(), outputs: new Map() }
134
134
  await validateGraphs(roots, sourceFiles)
135
135
  const workerDirectory = resolve(assetsDirectory, "workers")
136
136
  await mkdir(workerDirectory, { recursive: true })
@@ -152,16 +152,35 @@ export function createWorkerCompiler({
152
152
  logLevel: "silent"
153
153
  })
154
154
  const emitted = new Map()
155
+ const outputUrls = new Map(Object.keys(result.metafile.outputs).map(output => {
156
+ const outputFile = resolve(root, output)
157
+ return [outputFile, assetPath(base, relative(resolve(assetsDirectory, ".."), outputFile).replaceAll(sep, "/"))]
158
+ }))
159
+ const outputImports = new Map(Object.entries(result.metafile.outputs).map(([output, metadata]) => {
160
+ const outputFile = resolve(root, output)
161
+ return [outputUrls.get(outputFile), (metadata.imports ?? []).filter(entry => !entry.external).map(entry => outputUrls.get(resolve(root, entry.path)) ?? outputUrls.get(resolve(outputFile, "..", entry.path))).filter(Boolean)]
162
+ }))
163
+ const outputs = new Map()
155
164
  for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
156
165
  if (!metadata.entryPoint) continue
157
166
  const entry = resolve(root, metadata.entryPoint)
158
167
  const rootReferences = references.filter(reference => resolve(sourceDirectory, reference.root) === entry)
159
168
  const outputFile = resolve(root, output)
160
- const url = assetPath(base, relative(resolve(assetsDirectory, ".."), outputFile).replaceAll(sep, "/"))
161
- for (const reference of rootReferences) emitted.set(reference.placeholder, url)
169
+ const url = outputUrls.get(outputFile)
170
+ const closure = new Set()
171
+ const visit = path => {
172
+ if (closure.has(path)) return
173
+ closure.add(path)
174
+ for (const imported of outputImports.get(path) ?? []) visit(imported)
175
+ }
176
+ visit(url)
177
+ for (const reference of rootReferences) {
178
+ emitted.set(reference.placeholder, url)
179
+ outputs.set(reference.placeholder, { entry: url, chunks: [...closure].filter(path => path !== url).sort() })
180
+ }
162
181
  }
163
182
  for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${reference.root}`)
164
- return emitted
183
+ return { assets: emitted, outputs }
165
184
  }
166
185
 
167
186
  return { candidate, emit, rejectConstructions, rejectOrdinaryImports, rewriteEffect }
@@ -64,6 +64,8 @@ export type PageMetadata = {
64
64
  styles?: boolean | string[]
65
65
  base?: string
66
66
  runtimeAsset?: string
67
+ bindingAsset?: string
68
+ listAsset?: string
67
69
  effectAsset?: string
68
70
  nativeAsset?: string
69
71
  paramAsset?: string
@@ -486,9 +486,11 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
486
486
  const title = escapeHtml(metadata.title ?? "Kudzu")
487
487
  const head = renderMetadata(metadata)
488
488
  const capability = metadata.navigationAsset ? " data-k-capability" : ""
489
+ const managedStyles = new Set(metadata.managedStyles ?? [])
490
+ const styleAnchor = metadata.navigationAsset ? "<meta data-k-style-anchor>" : ""
489
491
  const styles = metadata.styles === false
490
492
  ? ""
491
- : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
493
+ : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}"${managedStyles.has(href) ? " data-k-route-style" : ""}>`).join("")
492
494
  const runtime = renderContext.hasBehaviors
493
495
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
494
496
  : ""
@@ -499,10 +501,10 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
499
501
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.paramAsset)}"></script>`
500
502
  : ""
501
503
  const bindingRuntime = renderContext.hasBindings
502
- ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
504
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.bindingAsset ?? assetPath(metadata.base, "assets/kudzu-binding.js"))}"></script>`
503
505
  : ""
504
506
  const listRuntime = renderContext.hasLists
505
- ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
507
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.listAsset ?? assetPath(metadata.base, "assets/kudzu-list.js"))}"></script>`
506
508
  : ""
507
509
  const effectRuntime = renderContext.hasEffects
508
510
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.effectAsset)}"></script>`
@@ -526,7 +528,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
526
528
  : ""
527
529
 
528
530
  return {
529
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}" data-k-route="${escapeAttribute(metadata.routeId)}"` : ""}>${body}</body></html>`,
531
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styleAnchor}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}" data-k-route="${escapeAttribute(metadata.routeId)}"` : ""}>${body}</body></html>`,
530
532
  hasBehaviors: renderContext.hasBehaviors,
531
533
  hasEffects: renderContext.hasEffects,
532
534
  hasParams: renderContext.hasParams,
@@ -5,7 +5,7 @@ import { extname, join, resolve, sep } from "node:path"
5
5
  import { browserPath, withBase } from "./compiler/path-helpers.mjs"
6
6
  import { stateSchema } from "./dev-state.js"
7
7
 
8
- const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\/assets\/kudzu(?:-(?:binding|list|native))?\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
8
+ const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\/assets\/runtime\/[a-f0-9]+\/kudzu(?:-deps|-binding|-list|-native)?\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
9
9
 
10
10
  export function parseDevPort(value) {
11
11
  if (value === undefined || value.trim() === "") return 3000
@@ -12,6 +12,7 @@ status.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;padding
12
12
  document.body.append(status)
13
13
 
14
14
  let request
15
+ let pendingStyleUpdate
15
16
  let revision = 0
16
17
  const documents = new Map()
17
18
  let observer
@@ -115,9 +116,12 @@ async function navigate(url, push) {
115
116
  const record = matchRoute(url.pathname)
116
117
  if (!record) return fallback(url, push)
117
118
  const current = ++revision
119
+ pendingStyleUpdate?.rollback()
120
+ pendingStyleUpdate = undefined
118
121
  request?.abort()
119
122
  request = new AbortController()
120
123
  let committed = false
124
+ let styleUpdate
121
125
  try {
122
126
  let documentResult
123
127
  const cached = documents.get(url.href)
@@ -128,8 +132,20 @@ async function navigate(url, push) {
128
132
  documents.set(url.href, Promise.resolve(documentResult))
129
133
  const { incoming, parsed, capabilities } = documentResult
130
134
  if (current !== revision) return
135
+ styleUpdate = prepareStyles(parsed.styles)
136
+ pendingStyleUpdate = styleUpdate
137
+ await styleUpdate.ready
138
+ if (current !== revision) {
139
+ styleUpdate.rollback()
140
+ return
141
+ }
131
142
  await routeDispose()
132
- if (current !== revision) return
143
+ if (current !== revision) {
144
+ styleUpdate.rollback()
145
+ return
146
+ }
147
+ styleUpdate.commit()
148
+ if (pendingStyleUpdate === styleUpdate) pendingStyleUpdate = undefined
133
149
  commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)
134
150
  committed = true
135
151
  routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
@@ -139,6 +155,8 @@ async function navigate(url, push) {
139
155
  status.textContent = `Navigated to ${document.title}`
140
156
  discover()
141
157
  } catch (error) {
158
+ styleUpdate?.rollback()
159
+ if (pendingStyleUpdate === styleUpdate) pendingStyleUpdate = undefined
142
160
  if (current !== revision || error.name === "AbortError") return
143
161
  fallback(url, push)
144
162
  if (committed) return
@@ -173,7 +191,73 @@ function validate(incoming, record) {
173
191
  return url.pathname
174
192
  })
175
193
  if (!assets.includes(navigationAsset)) throw new Error("Navigation capability asset is missing")
176
- return { nodes, assets: [...new Set(assets)] }
194
+ const styles = [...incoming.head.querySelectorAll('link[data-k-route-style][rel="stylesheet"][href]')]
195
+ const styleUrls = styles.map(link => {
196
+ const url = new URL(link.href)
197
+ if (url.origin !== location.origin) throw new Error("Navigation stylesheet must be same-origin")
198
+ return url.href
199
+ })
200
+ if (new Set(styleUrls).size !== styleUrls.length) throw new Error("Navigation document has duplicate route stylesheets")
201
+ return { nodes, assets: [...new Set(assets)], styles }
202
+ }
203
+
204
+ function prepareStyles(incoming) {
205
+ const anchor = document.head.querySelector("meta[data-k-style-anchor]")
206
+ if (!anchor || document.head.querySelectorAll("meta[data-k-style-anchor]").length !== 1) throw new Error("Current navigation style anchor is invalid")
207
+ const current = [...document.head.querySelectorAll('link[data-k-route-style][rel="stylesheet"][href]')]
208
+ const place = links => {
209
+ let previous = anchor
210
+ for (const link of links) {
211
+ if (link.previousSibling !== previous) previous.after(link)
212
+ previous = link
213
+ }
214
+ }
215
+ const byUrl = new Map(current.map(link => [new URL(link.href).href, link]))
216
+ const next = []
217
+ const created = []
218
+ const loads = []
219
+ for (const source of incoming) {
220
+ const href = new URL(source.href).href
221
+ let link = byUrl.get(href)
222
+ if (link) byUrl.delete(href)
223
+ else {
224
+ link = document.importNode(source, true)
225
+ created.push(link)
226
+ loads.push(new Promise((resolve, reject) => {
227
+ link.addEventListener("load", resolve, { once: true })
228
+ link.addEventListener("error", () => reject(new Error("Navigation stylesheet failed to load")), { once: true })
229
+ }))
230
+ }
231
+ next.push(link)
232
+ }
233
+ let settled = false
234
+ let cancel
235
+ const update = {
236
+ ready: Promise.race([
237
+ Promise.all(loads),
238
+ new Promise((resolve, reject) => {
239
+ cancel = () => {
240
+ const error = new Error("Navigation stylesheet load was cancelled")
241
+ error.name = "AbortError"
242
+ reject(error)
243
+ }
244
+ })
245
+ ]),
246
+ commit() {
247
+ if (settled) return
248
+ settled = true
249
+ for (const link of byUrl.values()) link.remove()
250
+ },
251
+ rollback() {
252
+ if (settled) return
253
+ settled = true
254
+ place(current)
255
+ for (const link of created) link.remove()
256
+ cancel()
257
+ }
258
+ }
259
+ place(next)
260
+ return update
177
261
  }
178
262
 
179
263
  function commit(incoming, incomingNodes, initializeParams, pathname, search) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.52",
3
+ "version": "0.8.55",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",