@kudzujs/core 0.8.52 → 0.8.53
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/MIGRATION_ROADMAP.md +2 -1
- package/PERFORMANCE.md +5 -1
- package/README.md +1 -1
- package/RELEASES.md +45 -0
- package/docs/next-architecture/README.md +1 -1
- package/docs/next-architecture/compiler-current-architecture.md +1 -1
- package/docs/next-architecture/large-application-ai-native-roadmap.md +3 -3
- package/docs/next-architecture/versioning.md +2 -1
- package/framework/README.md +1 -1
- package/framework/build.mjs +12 -8
- package/framework/compiler/runtime-codegen.mjs +1 -1
- package/framework/compiler/source-compiler.mjs +11 -10
- package/framework/core.mjs +4 -2
- package/framework/navigation-runtime.js +86 -2
- package/package.json +1 -1
package/MIGRATION_ROADMAP.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This document is the source of truth for Kudzu's product direction, architecture invariants, and future development order. Read it before extending React-shaped syntax or browser capabilities.
|
|
4
4
|
|
|
5
|
-
The executable post-`0.8.
|
|
5
|
+
The executable post-`0.8.53` compiler and large-application sequence is maintained in [`docs/next-architecture/large-application-ai-native-roadmap.md`](./docs/next-architecture/large-application-ai-native-roadmap.md). Follow its PR dependencies for implementation work; this document remains authoritative when selecting or accepting a migration capability.
|
|
6
6
|
|
|
7
7
|
[`GOAL_A.md`](./GOAL_A.md) and [`GOAL_B.md`](./GOAL_B.md) are completed capability-validation records. Their commerce and realtime dashboard fixtures prove general lifecycle, navigation, async-workflow, and Worker capabilities; they are not separate product verticals or future priority lists.
|
|
8
8
|
|
|
@@ -59,6 +59,7 @@ Do not add `getStaticProps`, request-time SSR, an SPA router, a stream runtime,
|
|
|
59
59
|
The following are available building blocks, not future vertical roadmaps:
|
|
60
60
|
|
|
61
61
|
- Async build-time components, `getStaticPaths()`, runtime path parameters, metadata, base paths, public assets, CSS, CSS Modules, and post-build hooks.
|
|
62
|
+
- Relative source CSS imports close over each page's reachable TypeScript import/re-export graph; configured styles remain explicitly global. Enhanced navigation loads destination CSS before route DOM replacement, reuses shared layout links, and removes outgoing route links.
|
|
62
63
|
- React import normalization for supported named/aliased hooks, direct `React.*` members, fragments, same-file `memo`, inline `useCallback`, direct intrinsic `forwardRef`, top-level `useId`, and analyzable `useMemo` expressions and collections.
|
|
63
64
|
- Function components, props, children, context, direct bindings, conditions, controlled form properties, refs, and synchronous or async handlers.
|
|
64
65
|
- `useState`, independent repeated non-keyed child state with conditional mount ownership, reduced relative-imported `useReducer`, direct dispatch specialization, and reduced Zustand-shaped shared state lowered through package-neutral shared-state/action IR and proven by migration fixtures.
|
package/PERFORMANCE.md
CHANGED
|
@@ -6,7 +6,11 @@ Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, `npm ru
|
|
|
6
6
|
|
|
7
7
|
The maintained 2026-08-13 comparison used Node 24.14.0 and an Intel Core i5-9500 Linux x64 host, one warm-up, and seven alternating fresh-process samples against clean `v0.8.44`. A narrow fast path skips Kudzu semantic transformation for 450 plain `.ts` modules whose runtime edges are exclusively resolvable relative TypeScript imports or exports; all other modules retain the existing transformer. Compile median fell from 2,323.9 ms to 1,413.2 ms (39.2%) and clean-build median from 3,325.3 ms to 2,382.4 ms (28.4%); every paired sample improved. Compile peak-RSS median fell from 571.2 MiB to 552.6 MiB, while build peak RSS was 570.9 MiB versus 568.8 MiB. Compiler scratch fell from 7,328,390 to 1,971,061 bytes. Both targets emitted the same 50 static HTML files, 10,980 bytes, and deploy SHA-256 `e107d78a7f55bc8a1af0ea6e53efeffa19b3d44d21c892484d103fa346e7ba7b`. This is a source-scale compiler comparison, not a cross-framework result.
|
|
8
8
|
|
|
9
|
-
## Current 0.8.
|
|
9
|
+
## Current 0.8.53 Release Snapshot
|
|
10
|
+
|
|
11
|
+
Kudzu 0.8.53 removes unrelated source stylesheet links from route HTML and reuses shared layout links during enhanced navigation. Static routes add no JavaScript, and no new performance claim is made.
|
|
12
|
+
|
|
13
|
+
## Maintained 0.8.52 Release Snapshot
|
|
10
14
|
|
|
11
15
|
Kudzu 0.8.52 moves proven effect-private mutable refs from component scope into existing setup-invocation closures. The refs add no serialized route scope, shared runtime, or static-sibling JavaScript; no new performance claim is made.
|
|
12
16
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
|
|
|
14
14
|
|
|
15
15
|
> Experimental `0.8.x`: the compiler API and supported TSX surface may change.
|
|
16
16
|
|
|
17
|
-
**Latest release: 0.8.
|
|
17
|
+
**Latest release: 0.8.53 - Route-aware CSS closure.** Relative source CSS follows each page's reachable TypeScript import and re-export graph, while configured styles remain explicitly global. Enhanced navigation loads destination styles before route replacement, retains shared layout links, removes outgoing styles, and rolls back cancelled loads. Read the [release notes](./RELEASES.md#0853---route-aware-css-closure), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.8.53), or follow the [architecture packet](./docs/next-architecture/README.md).
|
|
18
18
|
|
|
19
19
|
- [Documentation](https://kudzujs.cloud/docs)
|
|
20
20
|
- [Installation guide](https://kudzujs.cloud/docs#install)
|
package/RELEASES.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Kudzu Releases
|
|
2
2
|
|
|
3
|
+
## 0.8.53 - Route-aware CSS closure
|
|
4
|
+
|
|
5
|
+
Kudzu 0.8.53 links source styles only from routes whose reachable TypeScript graph imports them.
|
|
6
|
+
|
|
7
|
+
### Changed in 0.8.53
|
|
8
|
+
|
|
9
|
+
- Relative CSS imports follow each page's runtime import and re-export graph in deterministic source order.
|
|
10
|
+
- RouteBuildRecord receives each route's exact source stylesheet URLs instead of one site-wide CSS list.
|
|
11
|
+
- `kudzu.config styles` remains explicitly global, deduplicated, and excluded from route-managed ownership.
|
|
12
|
+
- CSS `?url` imports emit downloadable assets without creating stylesheet links.
|
|
13
|
+
- Unimported source CSS is no longer implicitly linked; applications import route/layout styles or configure true globals.
|
|
14
|
+
|
|
15
|
+
### Enhanced navigation
|
|
16
|
+
|
|
17
|
+
- Navigable documents mark route-managed source styles separately from configured global links.
|
|
18
|
+
- Destination styles load before route cleanup and DOM replacement.
|
|
19
|
+
- Shared layout stylesheet links retain URL, DOM identity, and cascade order across navigation.
|
|
20
|
+
- Outgoing route styles are removed after successful preparation.
|
|
21
|
+
- Overlapping navigation cancels and rolls back provisional stylesheet transactions without disturbing the newer route.
|
|
22
|
+
- Invalid, duplicate, cross-origin, or failed managed stylesheets fall back to native document navigation.
|
|
23
|
+
|
|
24
|
+
### Migration evidence
|
|
25
|
+
|
|
26
|
+
- The React/Vite static sibling excludes the interactive app stylesheet while retaining zero JavaScript.
|
|
27
|
+
- Independent navigation groups exclude one another's layout and route styles.
|
|
28
|
+
- A delayed stylesheet browser test verifies cancellation rollback, shared-link identity, destination activation, and outgoing-link removal.
|
|
29
|
+
- Global style fixtures prove configured source/URL deduplication, while dynamic routes now import their intended CSS explicitly.
|
|
30
|
+
- The starter imports shared CSS through its common header so both generated routes reach the style intentionally.
|
|
31
|
+
|
|
32
|
+
### Output and performance
|
|
33
|
+
|
|
34
|
+
- Route HTML omits unrelated feature CSS and its cascade effects.
|
|
35
|
+
- Static pages remain JavaScript-free; only configured enhanced-navigation routes receive managed-style reconciliation.
|
|
36
|
+
- No CSS bundler, style runtime, SPA router, or new performance claim is added.
|
|
37
|
+
|
|
38
|
+
### Validation
|
|
39
|
+
|
|
40
|
+
- `npm run check`, `npm test`, and `npm run test:package` pass with all 219 tests and 168 generated pages.
|
|
41
|
+
|
|
42
|
+
### Upgrade
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install @kudzujs/core@^0.8.53
|
|
46
|
+
```
|
|
47
|
+
|
|
3
48
|
## 0.8.52 - Effect-private mutable refs
|
|
4
49
|
|
|
5
50
|
Kudzu 0.8.52 compiles component-authored mutable refs when one inline effect exclusively owns their complete lifecycle.
|
|
@@ -11,7 +11,7 @@ The top-level [`GOAL_A.md`](../../GOAL_A.md) and [`GOAL_B.md`](../../GOAL_B.md)
|
|
|
11
11
|
| C: state/resource model | Research only | Reduced fixtures expose a limitation |
|
|
12
12
|
| D: routing compatibility | Current behavior preserved | Revisit only with migration evidence and invariant review |
|
|
13
13
|
|
|
14
|
-
The active post-`0.8.
|
|
14
|
+
The active post-`0.8.53` implementation sequence is [`large-application-ai-native-roadmap.md`](./large-application-ai-native-roadmap.md). P0.12 validation, property-level object-state dependencies, direct three-boundary callback/ref ownership, direct primitive prop state initialization, repeated direct leaf-handler callback use, direct child callback fan-out, collision-free and private Context action setters, package-neutral shared-state/action IR, browser-only package imports and private mutable refs in owned effects, route/layout CSS closure, measured route-entry output optimization, and the plain TypeScript source-scale fast path are complete. Route capability signatures and emitted shared-chunk reporting are next; ResourceIR remains research for cross-owner transports and subscriptions. The plan orders compiler semantic generalization, large-application foundations, compatibility boundaries, AI tooling, and production validation without changing the invariants below.
|
|
15
15
|
|
|
16
16
|
## Required Invariants
|
|
17
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Current Compiler Architecture
|
|
2
2
|
|
|
3
|
-
This maps the current `0.8.
|
|
3
|
+
This maps the current `0.8.53` architecture, built on the completed `0.8.23` Goal A compiler foundation. File and function names are the stable references; line numbers are intentionally omitted because later work may still move code.
|
|
4
4
|
|
|
5
5
|
## Responsibility Map
|
|
6
6
|
|
|
@@ -42,7 +42,7 @@ The current limiting architecture is observable in these files:
|
|
|
42
42
|
| Lists/ranges | `source-compiler.mjs`, `list-runtime.js` | Strong keyed ownership, but syntax recognition and runtime branches are list-specific and not a general range/window primitive |
|
|
43
43
|
| ModuleIR | `compiler/ir/module-ir.mjs` | Numeric slots, names, export strings, and formatted owner strings coexist; several sections are descriptive rather than authoritative inputs |
|
|
44
44
|
| RouteIR/CapabilityIR | `route-capability-planner.mjs` | Validation is shallow and capability selection is site-wide across interactive routes |
|
|
45
|
-
| Build/codegen | `framework/build.mjs`, runtime generators | Full destructive builds,
|
|
45
|
+
| Build/codegen | `framework/build.mjs`, runtime generators | Full destructive builds, source-text runtime surgery, and no route capability/chunk closure report; route CSS closure is structural |
|
|
46
46
|
| Browser lifetime | `native-runtime.js`, `effect-runtime.js`, `navigation-runtime.js` | Effects and native handlers invalidate stale writes at their existing effect or DOM ownership boundary |
|
|
47
47
|
| Compatibility | React/Router/Zustand passes plus core branches | Package-specific knowledge is not contained behind one adapter boundary |
|
|
48
48
|
| AI interface | CLI and string diagnostics | No stable diagnostic codes, semantic index, explain, fix, or migration analysis output |
|
|
@@ -383,7 +383,7 @@ After the relevant P0 foundations, investigate capabilities in this order:
|
|
|
383
383
|
3. Package-neutral shared state/actions and migration of current Zustand internals. **Completed in `0.8.50`:** Zustand source normalization produces one generic shared-state adapter descriptor; selectors and handlers register JSON-safe SharedStateIR/SharedActionIR records, handler lowering consumes package-neutral actions, and existing RouteIR, layout ownership, same-turn updates, navigation persistence, and browser output remain unchanged. Redux/RTK and public adapter APIs remain unsupported.
|
|
384
384
|
4. Browser-only package imports in owned effect/resource modules. **Completed in `0.8.51` for effects:** direct package references in inline effect setup/cleanup callbacks use existing package import records and route-owned effect ESM bundling; build-time component modules and static siblings omit the package. Helper-indirect, render-time, dynamic-import, and ResourceIR package graphs remain unsupported.
|
|
385
385
|
5. ResourceIR from at least two independent WebSocket/SSE/SDK fixtures with the same semantics. **Completed in `0.8.52` without ResourceIR for private ownership:** the E2B terminal and route-owned WebSocket fixtures lower refs used exclusively by one inline effect to invocation-private closure objects, while existing effect ownership supplies replacement, cleanup, stale setter invalidation, navigation, and BFCache disposal. ResourceIR remains unapproved and now requires independent cross-owner transport/subscription fixtures that cannot fit this narrower model.
|
|
386
|
-
6. Route/layout capability and CSS chunk closure.
|
|
386
|
+
6. Route/layout capability and CSS chunk closure. **Route/layout CSS closure completed in `0.8.53`:** each page's reachable TypeScript import/re-export graph supplies exact source stylesheet edges to RouteBuildRecord, configured styles remain global, static siblings exclude unrelated feature CSS, and enhanced navigation loads incoming links before DOM replacement while retaining shared layout link identity and removing outgoing links. Route capability signatures and bundled shared-chunk reporting remain.
|
|
387
387
|
7. Incremental source and affected-route builds.
|
|
388
388
|
8. Range ownership and virtualization only after a real data-heavy fixture establishes direct DOM limits.
|
|
389
389
|
9. Optimistic shared transactions only after independent mutation fixtures establish commit/rollback semantics.
|
|
@@ -481,4 +481,4 @@ The first comparison is Kudzu versus React + Vite using the same agent, model, t
|
|
|
481
481
|
|
|
482
482
|
## Immediate Decision
|
|
483
483
|
|
|
484
|
-
PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive prop initializer slice, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, and the `0.8.42` measured route-output optimization are complete. Continue
|
|
484
|
+
PR 1 through PR 12, the `0.8.40` property-dependency slice, the `0.8.41` and `0.8.43` direct multi-boundary callback/ref slices, the `0.8.44` Context alias slice, the `0.8.46` action-only Provider setter slice, the `0.8.47` direct primitive prop initializer slice, the `0.8.48` repeated direct leaf-handler callback slice, the `0.8.49` direct child callback fan-out slice, the `0.8.50` package-neutral shared-state/action slice, the `0.8.51` owned-effect package import slice, the `0.8.52` effect-private mutable-ref slice, the `0.8.53` route/layout CSS closure slice, and the `0.8.42` measured route-output optimization are complete. Continue item 6 with route capability signatures and emitted shared-chunk reporting; keep ResourceIR limited to cross-owner fixtures and do not skip directly to a public adapter/store API, router, virtualization, or unsupported ecosystem package feature.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Planned Version Sequence
|
|
2
2
|
|
|
3
|
-
This is an execution sequence, not release history. `0.8.16` through `0.8.
|
|
3
|
+
This is an execution sequence, not release history. `0.8.16` through `0.8.53` are completed scopes represented by package/release records.
|
|
4
4
|
|
|
5
5
|
Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
|
|
6
6
|
|
|
@@ -43,6 +43,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
|
|
|
43
43
|
| `0.8.50` | Isolate reduced Zustand migration behind package-neutral shared-state and action IR. | Validated shared identities/actions preserve RouteIR, layout lifetime, same-turn updates, diagnostics, browser behavior, and zero store runtime. |
|
|
44
44
|
| `0.8.51` | Bundle direct browser-only package references from inline owned effect setup/cleanup callbacks. | Package code stays in referenced route effect ESM; build scratch and static siblings omit it while indirect/render-time uses fail. |
|
|
45
45
|
| `0.8.52` | Lower effect-exclusive mutable refs into setup-invocation closures. | E2B and WebSocket fixtures preserve generation, replacement, BFCache, and cleanup without captures, ResourceIR, or static-sibling JavaScript. |
|
|
46
|
+
| `0.8.53` | Close source CSS over each route's reachable TypeScript graph and reconcile managed styles during navigation. | Routes exclude unrelated CSS; destination styles load before replacement, shared layout links retain identity, and cancelled loads roll back safely. |
|
|
46
47
|
|
|
47
48
|
## Sequence Rules
|
|
48
49
|
|
package/framework/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Static routes receive no browser runtime. Command routes receive `runtime.js`; d
|
|
|
76
76
|
|
|
77
77
|
Exact relative `.worker.ts` constructors in inline effects are validated and bundled in a separate content-hashed ESM graph under `dist/assets/workers/`. Those files are referenced only by rendered effect handlers and never become document capability scripts, preloads, or window imports; unreachable source effects do not emit their Worker roots. Worker graphs allow relative TypeScript ESM runtime imports only and reject JSX, package runtime imports, import-equals declarations, dynamic imports, `require()`, and paths outside `src`. Ordinary runtime imports or re-exports of `.worker.ts` and Worker construction in imported keyed-row effects are rejected.
|
|
78
78
|
|
|
79
|
-
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
79
|
+
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Relative source CSS imports follow each page's reachable TypeScript graph and link only from those routes; `kudzu.config` styles remain explicitly global. Enhanced navigation loads incoming managed styles before route replacement, reuses shared links, and removes outgoing links. All styles are emitted before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
|
|
80
80
|
|
|
81
81
|
Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases use the existing generic `setAttribute` path. Reactive conditionals and flat intrinsic keyed lists store inert branch or row markup on SVG markers and parse it in the actual parent namespace only when replacement nodes are needed; existing condition/list ownership then handles insertion, identity, updates, and removal. Focus, keyboard, and click handlers on keyed SVG points may update parent state for an external accessible HTML tooltip, and retained handlers read the latest item after list updates. Builds without structural SVG compile out that fragment path, and static SVG adds no JavaScript. Keyed-item conditions, nested SVG lists, MathML structures, and namespaced attributes remain unsupported.
|
|
82
82
|
|
package/framework/build.mjs
CHANGED
|
@@ -62,6 +62,8 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
62
62
|
const config = await loadConfig(root)
|
|
63
63
|
const base = normalizeBase(config.base)
|
|
64
64
|
const configuredStyles = normalizeStyles(config.styles, base, project)
|
|
65
|
+
const globalStyleUrls = [...new Set(configuredStyles.urls)]
|
|
66
|
+
const globalStyleUrlSet = new Set(globalStyleUrls)
|
|
65
67
|
const publicDirectory = normalizePublicDirectory(config.publicDir, project)
|
|
66
68
|
const navigationGroups = normalizeNavigation(config.navigation)
|
|
67
69
|
const navigationRoutes = navigationGroups.flatMap(group => group.routes)
|
|
@@ -82,7 +84,6 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
82
84
|
const projectFiles = await walk(sourceDirectory)
|
|
83
85
|
const allSourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file) && !file.endsWith(".d.ts")).sort()
|
|
84
86
|
const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
|
|
85
|
-
const discoveredCssFiles = projectFiles.filter(file => file.toLowerCase().endsWith(".css") && !configuredStyleSources.has(file)).sort()
|
|
86
87
|
if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
87
88
|
const allSourceFileSet = new Set(allSourceFiles)
|
|
88
89
|
const sourceIndex = project.sourceIndex
|
|
@@ -93,7 +94,8 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
93
94
|
const sourceFileSet = project.sourceFiles
|
|
94
95
|
for (const file of sourceFiles) sourceFileSet.add(file)
|
|
95
96
|
const staticFiles = await safeStaticFiles(projectFiles)
|
|
96
|
-
const
|
|
97
|
+
const stylesByPage = new Map(pageFiles.map(file => [file, orderSourceStyles([file], sourceFiles, sourceIndex, staticFiles).filter(style => !configuredStyleSources.has(style))]))
|
|
98
|
+
const cssFiles = [...new Set([...stylesByPage.values()].flat())]
|
|
97
99
|
const importedAssets = new Set()
|
|
98
100
|
const { cssModules, cssOutputs } = await prepareSourceStyles(cssFiles, staticFiles, importedAssets, base, project)
|
|
99
101
|
|
|
@@ -122,13 +124,11 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
122
124
|
const emittedRoutes = new Set()
|
|
123
125
|
const emittedApplicationRoutes = new Set()
|
|
124
126
|
const emittedNavigationRecords = []
|
|
125
|
-
const styleUrls = [...new Set([
|
|
126
|
-
...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
|
|
127
|
-
...configuredStyles.urls
|
|
128
|
-
])]
|
|
129
127
|
const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
|
|
130
128
|
|
|
131
129
|
for (const pageFile of pageFiles) {
|
|
130
|
+
const sourceStyleUrls = stylesByPage.get(pageFile).map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)).filter(url => !globalStyleUrlSet.has(url))
|
|
131
|
+
const styleUrls = [...sourceStyleUrls, ...globalStyleUrls]
|
|
132
132
|
const compiledFile = compiledPath(pageFile)
|
|
133
133
|
const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
|
|
134
134
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
@@ -179,6 +179,7 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
179
179
|
...configuredMetadata,
|
|
180
180
|
...pageMetadata,
|
|
181
181
|
styles: styleUrls.length ? styleUrls : false,
|
|
182
|
+
managedStyles: navigable ? sourceStyleUrls : [],
|
|
182
183
|
base,
|
|
183
184
|
runtimeAsset: runtimePlaceholder,
|
|
184
185
|
effectAsset: assetPath(base, `assets/${effectPath}`),
|
|
@@ -352,16 +353,19 @@ async function buildInto(project, outputDirectory, { minify }) {
|
|
|
352
353
|
}
|
|
353
354
|
const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
|
|
354
355
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
|
|
356
|
+
const emittedCssFiles = new Set()
|
|
355
357
|
for (const file of cssFiles.filter(file => renderedStyleUrls.has(assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)))) {
|
|
356
358
|
const output = join(assetsDirectory, relative(sourceDirectory, file))
|
|
357
359
|
await mkdir(dirname(output), { recursive: true })
|
|
358
360
|
await writeFile(output, cssOutputs.get(file))
|
|
361
|
+
emittedCssFiles.add(file)
|
|
359
362
|
}
|
|
360
363
|
for (const file of [...importedAssets].sort()) {
|
|
361
|
-
if (cssOutputs.has(file)) continue
|
|
362
364
|
const output = join(assetsDirectory, relative(sourceDirectory, file))
|
|
363
365
|
await mkdir(dirname(output), { recursive: true })
|
|
364
|
-
|
|
366
|
+
if (cssOutputs.has(file)) {
|
|
367
|
+
if (!emittedCssFiles.has(file)) await writeFile(output, cssOutputs.get(file))
|
|
368
|
+
} else await writeFile(output, await readFile(file))
|
|
365
369
|
}
|
|
366
370
|
for (const style of configuredStyles.sources.filter(style => renderedStyleUrls.has(withBase(base, style.output)))) {
|
|
367
371
|
let css = await readFile(style.source, "utf8")
|
|
@@ -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"],
|
|
@@ -3059,7 +3059,7 @@ async function safeStaticFiles(files) {
|
|
|
3059
3059
|
return new Set(entries.filter(Boolean))
|
|
3060
3060
|
}
|
|
3061
3061
|
|
|
3062
|
-
function orderSourceStyles(
|
|
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
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
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
|
-
|
|
3086
|
+
visit(resolveSourceImport(file, specifier, sourceSet))
|
|
3085
3087
|
}
|
|
3086
3088
|
}
|
|
3087
|
-
for (const file of
|
|
3088
|
-
|
|
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
|
-
|
|
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
|
}
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
: ""
|
|
@@ -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,
|
|
@@ -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)
|
|
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
|
-
|
|
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) {
|