@uniweb/build 0.14.24 → 0.14.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.24",
3
+ "version": "0.14.26",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.5",
62
+ "@uniweb/theming": "0.1.6",
63
63
  "@uniweb/content-writer": "0.2.6"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.22",
67
- "@uniweb/schemas": "0.2.4",
68
- "@uniweb/content-reader": "1.1.12"
66
+ "@uniweb/content-reader": "1.1.12",
67
+ "@uniweb/runtime": "0.8.24",
68
+ "@uniweb/schemas": "0.2.4"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "0.7.16"
77
+ "@uniweb/core": "0.7.18"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -96,7 +96,7 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
96
96
  onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
97
97
  const result = await executeFetch(cfg, opts)
98
98
  if (result.data && !result.error) {
99
- fetchedData.push({ config: cfg, data: result.data })
99
+ fetchedData.push({ config: cfg, data: result.data, _scope: '__site__' })
100
100
  }
101
101
  }
102
102
 
@@ -112,7 +112,7 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
112
112
  onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
113
113
  const result = await executeFetch(cfg, opts)
114
114
  if (result.data && !result.error) {
115
- fetchedData.push({ config: cfg, data: result.data })
115
+ fetchedData.push({ config: cfg, data: result.data, _scope: page.route })
116
116
  // Store for dynamic route expansion
117
117
  pageFetchedData.set(page.route, {
118
118
  schema: pageFetch.schema,
@@ -212,13 +212,19 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
212
212
  concretePage.paramName = undefined
213
213
  concretePage.parentSchema = undefined
214
214
 
215
- // Store the dynamic route context for runtime data resolution
215
+ // Store the dynamic route context for runtime data resolution. Only the
216
+ // keys the runtime actually uses — the entity cascade re-finds the record
217
+ // from the fetched collection by paramName/paramValue/schema. The record
218
+ // (`currentItem`) and the full sibling list (`allItems`) are deliberately
219
+ // NOT baked in: nothing reads them (the documented dynamicContext shape is
220
+ // { paramName, paramValue, schema }; the record is delivered via
221
+ // content.data and siblings via `fetch: { refine: true, detail: false }`),
222
+ // and embedding `allItems` duplicated the whole collection onto every
223
+ // prerendered page in split mode.
216
224
  concretePage.dynamicContext = {
217
225
  paramName,
218
226
  paramValue,
219
227
  schema, // Plural: 'articles'
220
- currentItem: item, // The item for this specific route
221
- allItems: items, // All items from parent
222
228
  }
223
229
 
224
230
  // Use item data for page metadata if available
@@ -323,6 +329,42 @@ async function discoverLocaleContents(distDir, defaultContent) {
323
329
  return locales
324
330
  }
325
331
 
332
+ /**
333
+ * Strip the internal `_scope` tag off a fetchedData entry, leaving the clean
334
+ * `{ config, data }` shape the runtime's hydrateDataStore expects.
335
+ */
336
+ function stripFetchScope(entry) {
337
+ const { _scope, ...clean } = entry
338
+ return clean
339
+ }
340
+
341
+ /**
342
+ * Scope `fetchedData` for a single page's inline __SITE_CONTENT__.
343
+ *
344
+ * Every fetchedData entry is tagged (in executeAllFetches) with `_scope`:
345
+ * '__site__' for a site-level fetch, or the owning page's route for a
346
+ * page-level fetch. A page's first render only ever reads the cascade
347
+ * block → page → page.parent → site (see @uniweb/core EntityStore), so in
348
+ * split-content mode we embed just the entries that cascade can reach —
349
+ * site-level plus the page's own route and its parent's route (`scopeRoutes`).
350
+ * Other pages' data, and dynamic detail data reached only by client-side
351
+ * navigation, is fetched on demand and must not ride in every page's payload.
352
+ *
353
+ * `scopeRoutes == null` means "don't scope" (non-split mode, or the SPA
354
+ * fallback): keep every entry. Either way the internal `_scope` tag is stripped.
355
+ *
356
+ * @param {Array<{config: Object, data: any, _scope?: string}>} fetchedData
357
+ * @param {Set<string>|null} scopeRoutes - Routes whose entries to keep, or null.
358
+ * @returns {Array<{config: Object, data: any}>}
359
+ */
360
+ export function scopeFetchedData(fetchedData, scopeRoutes) {
361
+ if (!Array.isArray(fetchedData)) return fetchedData
362
+ if (!scopeRoutes) return fetchedData.map(stripFetchScope)
363
+ return fetchedData
364
+ .filter((e) => e._scope === '__site__' || scopeRoutes.has(e._scope))
365
+ .map(stripFetchScope)
366
+ }
367
+
326
368
  /**
327
369
  * Inject build-specific data into HTML (theme CSS, __SITE_CONTENT__, icon cache).
328
370
  * Called after the shared injectPageContent for build-specific additions.
@@ -334,7 +376,7 @@ async function discoverLocaleContents(distDir, defaultContent) {
334
376
  * @param {string|null} [options.currentRoute=null] - Route of the page this HTML is for
335
377
  * @returns {string} HTML with build-specific data injected
336
378
  */
337
- function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null } = {}) {
379
+ function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null, scopeRoutes = null } = {}) {
338
380
  let result = html
339
381
 
340
382
  // Inject theme CSS if not already present
@@ -367,6 +409,16 @@ function injectBuildData(html, siteContent, { splitContent = false, currentRoute
367
409
  }
368
410
  }
369
411
 
412
+ // Scope fetched (collection / API) data to this page's cascade in split mode,
413
+ // so a page never carries other pages' collections. Non-split keeps it all
414
+ // (single-file inline). Either way the internal `_scope` tag is stripped.
415
+ if (Array.isArray(contentForJson.fetchedData)) {
416
+ contentForJson = {
417
+ ...contentForJson,
418
+ fetchedData: scopeFetchedData(contentForJson.fetchedData, splitContent ? scopeRoutes : null),
419
+ }
420
+ }
421
+
370
422
  const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson).replace(/</g, '\\u003c')}</script>`
371
423
  if (result.includes('__SITE_CONTENT__')) {
372
424
  // Replace existing site content with updated version (includes expanded dynamic routes)
@@ -667,10 +719,14 @@ export async function prerenderSite(siteDir, options = {}) {
667
719
  sectionOverrideCSS: result.sectionOverrideCSS,
668
720
  })
669
721
 
670
- // Build-specific: theme CSS, __SITE_CONTENT__, icon cache
722
+ // Build-specific: theme CSS, __SITE_CONTENT__, icon cache.
723
+ // scopeRoutes mirrors the runtime data cascade (page → page.parent → site)
724
+ // so split-mode pages embed only the collection data their first render reads.
725
+ const scopeRoutes = new Set([page.route, page.parent?.route].filter(Boolean))
671
726
  html = injectBuildData(html, siteContent, {
672
727
  splitContent,
673
728
  currentRoute: page.route,
729
+ scopeRoutes,
674
730
  })
675
731
 
676
732
  // Output to the locale-prefixed route
@@ -686,6 +742,7 @@ export async function prerenderSite(siteDir, options = {}) {
686
742
  const fallbackBaseHtml = injectBuildData(htmlShell, siteContent, {
687
743
  splitContent,
688
744
  currentRoute: null, // 404 has no current page — manifest only
745
+ scopeRoutes: new Set(), // SPA fallback carries site-level fetched data only
689
746
  })
690
747
  const { html: notFoundHtml, hasNotFoundPage } = generate404Html({
691
748
  baseHtml: fallbackBaseHtml,
@@ -710,6 +767,11 @@ export async function prerenderSite(siteDir, options = {}) {
710
767
  return metadata
711
768
  })
712
769
  }
770
+ // The manifest is a single (non-per-page) file, so it keeps all fetched
771
+ // data — but the internal `_scope` tag must never leak into it.
772
+ if (Array.isArray(manifest.fetchedData)) {
773
+ manifest.fetchedData = manifest.fetchedData.map(stripFetchScope)
774
+ }
713
775
  await writeFile(localeContentPath, JSON.stringify(manifest))
714
776
  onProgress('Rewrote site-content.json as lightweight manifest')
715
777
  }
package/src/schema.js CHANGED
@@ -92,9 +92,13 @@ export async function loadPackageJson(srcDir) {
92
92
  const content = await readFile(packagePath, 'utf-8')
93
93
  const pkg = JSON.parse(content)
94
94
 
95
- // Extract only identity fields for schema
95
+ // Extract only identity fields for schema.
96
+ // `uniweb.id` is the REGISTERED name (registry identity), decoupled from the
97
+ // workspace package `name` (pnpm linking / file: deps / site.yml). It lets a
98
+ // foundation keep a scaffold-default package name like "src" while registering
99
+ // under a distinct id (e.g. "docs" → @org/docs). Falls back to `name`.
96
100
  return {
97
- name: pkg.name,
101
+ name: pkg.uniweb?.id || pkg.name,
98
102
  version: pkg.version,
99
103
  description: pkg.description,
100
104
  }
@@ -74,8 +74,45 @@ function resolveFaviconHref(configFavicon, publicDir, basePath) {
74
74
  }
75
75
 
76
76
  /**
77
- * Execute all fetches for site content (used in dev mode)
78
- * Collects fetchedData for DataStore pre-population at runtime
77
+ * Should this fetch config be pre-executed at build time and embedded into the
78
+ * dev boot payload, or left for the runtime to fetch live?
79
+ *
80
+ * Dev has no prerender step, so there is no server-rendered HTML to hydrate and
81
+ * nothing to gain from embedding fetched data — while embedding it *costs*
82
+ * freshness: the boot payload is a one-time snapshot, so editing a local
83
+ * collection regenerates `/data/*.json` but the runtime keeps reading the stale
84
+ * embed until the dev server is restarted.
85
+ *
86
+ * So in dev we embed only what the browser genuinely cannot fetch itself:
87
+ * - Local `path:` sources (file-based collections → `/data/*.json`) are served
88
+ * by Vite and always browser-reachable, so we never embed them — the runtime
89
+ * fetches them live and picks up edits on reload. This also gives local
90
+ * collections true parity with a real backend fetcher.
91
+ * - `prerender: false` opts a source out of build-time fetching by definition.
92
+ * - Remote `url:` sources with `prerender !== false` may target a
93
+ * build-time-only endpoint (server-side auth, no CORS), so we keep
94
+ * pre-fetching and embedding them, mirroring what prod prerender does.
95
+ *
96
+ * Prod is unaffected: prerender still embeds fetched data for flash-free SSG
97
+ * hydration (see build/src/prerender.js executeAllFetches / injectBuildData).
98
+ *
99
+ * @param {Object|null} cfg - A normalized fetch config (parseFetchConfig output)
100
+ * @returns {boolean}
101
+ */
102
+ export function shouldPrefetchInDev(cfg) {
103
+ if (!cfg) return false
104
+ if (!cfg.path && !cfg.url) return false // refinement / nothing to fetch
105
+ if (cfg.prerender === false) return false // author opted into runtime fetch
106
+ if (cfg.path && !cfg.url) return false // local file — runtime fetches it live
107
+ return true // remote build-time fetch — keep embedding in dev
108
+ }
109
+
110
+ /**
111
+ * Execute the dev fetches that should be embedded (see shouldPrefetchInDev) and
112
+ * collect them as `fetchedData` for DataStore pre-population. Local file-based
113
+ * collections and `prerender: false` sources are intentionally skipped so the
114
+ * runtime fetches them live — keeping the dev payload free of dynamic data and
115
+ * ensuring edits to `/data/*.json` show up on reload without a dev restart.
79
116
  *
80
117
  * @param {Object} siteContent - The collected site content
81
118
  * @param {string} siteDir - Path to site directory
@@ -86,7 +123,7 @@ async function executeDevFetches(siteContent, siteDir) {
86
123
 
87
124
  // Site-level fetch
88
125
  const siteFetch = siteContent.config?.fetch
89
- if (siteFetch) {
126
+ if (shouldPrefetchInDev(siteFetch)) {
90
127
  const result = await executeFetch(siteFetch, fetchOptions)
91
128
  if (result.data && !result.error) {
92
129
  fetchedData.push({ config: siteFetch, data: result.data })
@@ -97,7 +134,7 @@ async function executeDevFetches(siteContent, siteDir) {
97
134
  for (const page of siteContent.pages || []) {
98
135
  // Page-level fetch
99
136
  const pageFetch = page.fetch
100
- if (pageFetch) {
137
+ if (shouldPrefetchInDev(pageFetch)) {
101
138
  const result = await executeFetch(pageFetch, fetchOptions)
102
139
  if (result.data && !result.error) {
103
140
  fetchedData.push({ config: pageFetch, data: result.data })
@@ -125,7 +162,7 @@ async function processDevSectionFetches(sections, fetchOptions) {
125
162
  for (const section of sections) {
126
163
  // Execute section-level fetch
127
164
  const sectionFetch = section.fetch
128
- if (sectionFetch) {
165
+ if (shouldPrefetchInDev(sectionFetch)) {
129
166
  const result = await executeFetch(sectionFetch, fetchOptions)
130
167
  if (result.data && !result.error) {
131
168
  // Merge fetched data into section's parsedContent (not cascadedData)
@@ -340,6 +340,132 @@ async function emitFoundationVarsCss(outDir, schema) {
340
340
  console.log(`Emitted ${Object.keys(flatVars).length} foundation theme-var default(s) to assets/style.css`)
341
341
  }
342
342
 
343
+ /**
344
+ * Externals for the SSR bundle (`dist/entry-ssr.js`).
345
+ *
346
+ * Same set the browser foundation build externalizes (DEFAULT_EXTERNALS in
347
+ * foundation/config.js — react/react-dom/react-dom-server/jsx-runtime/core),
348
+ * which the Cloudflare edge isolate resolves to the SHARED runtime's React/core
349
+ * (worker-runtime.js) via its shims — so React stays deduped and runtime patches
350
+ * still propagate without a foundation rebuild.
351
+ *
352
+ * PLUS the client-only libraries kit code-splits via dynamic import:
353
+ * - shiki / shiki/bundle/full — syntax highlighting (kit Code renderer)
354
+ * - fuse.js — client search index
355
+ * Both hydrate in the browser and never run during renderToString (CLAUDE.md
356
+ * gotcha #12). Keeping them external drops the ~10 MB Shiki language graph from
357
+ * the SSR bundle and leaves them as DORMANT dynamic imports the isolate never
358
+ * awaits — so no extra modules-map entry is needed for them edge-side.
359
+ */
360
+ const SSR_DEFAULT_EXTERNALS = [
361
+ 'react',
362
+ 'react-dom',
363
+ 'react-dom/server',
364
+ 'react/jsx-runtime',
365
+ 'react/jsx-dev-runtime',
366
+ '@uniweb/core',
367
+ ]
368
+
369
+ function isSSRExternal(id) {
370
+ if (SSR_DEFAULT_EXTERNALS.includes(id)) return true
371
+ if (id === 'shiki' || id.startsWith('shiki/')) return true
372
+ if (id === 'fuse.js' || id.startsWith('fuse.js/')) return true
373
+ return false
374
+ }
375
+
376
+ /**
377
+ * Emit `dist/entry-ssr.js` — the single-file SSR twin of the (code-split)
378
+ * browser `dist/entry.js`.
379
+ *
380
+ * The modern browser `entry.js` is a facade that re-exports from
381
+ * `_entry.generated-*.js` and lazily code-splits kit's client-only features
382
+ * (Shiki, Fuse) into hundreds of chunks — a graph the Cloudflare Dynamic Worker
383
+ * isolate can't resolve (it loads a single `foundation` module). This builds the
384
+ * SAME source entry into ONE file, inlining the foundation's own graph and
385
+ * externalizing the runtime/React set (→ the isolate's shared worker-runtime)
386
+ * and the client-only Shiki/Fuse libs. Result: a ~foundation-sized ESM module
387
+ * (no React, no Shiki) the edge loads as `foundation` for request-time SSR.
388
+ *
389
+ * Built from source (not by re-bundling the built `entry.js`, whose Shiki
390
+ * specifier is already rewritten to a relative chunk path that couldn't be
391
+ * externalized) via a secondary Vite build into a temp dir; only the JS is
392
+ * copied out (the throwaway CSS is discarded — the SSR bundle needs no styles).
393
+ *
394
+ * Best-effort: a failure warns and emits nothing, so the edge simply serves
395
+ * the client-render shell for this foundation (existence-gated) — no regression.
396
+ *
397
+ * @param {string} foundationRoot - foundation project root (vite `root`).
398
+ * @param {string} entrySourcePath - absolute path to `_entry.generated.js`.
399
+ * @param {string} outDir - dist/ directory to write `entry-ssr.js` into.
400
+ */
401
+ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
402
+ if (_buildingSSRBundle) return
403
+ _buildingSSRBundle = true
404
+
405
+ const { rm, cp, stat } = await import('node:fs/promises')
406
+ const tmpDir = join(outDir, '.entry-ssr-tmp')
407
+
408
+ try {
409
+ if (!existsSync(entrySourcePath)) {
410
+ console.warn(`Skipping entry-ssr.js: entry source not found at ${entrySourcePath}`)
411
+ return
412
+ }
413
+
414
+ const { build: viteBuild } = await import('vite')
415
+
416
+ // Same transform plugins as the browser foundation build (JSX, SVGR, and —
417
+ // best-effort — Tailwind), but WITHOUT foundationPlugin: no schema/entry
418
+ // regeneration and no writeBundle recursion. CSS output is discarded.
419
+ const plugins = []
420
+ try {
421
+ const tailwindcss = (await import('@tailwindcss/vite')).default
422
+ plugins.push(tailwindcss())
423
+ } catch {
424
+ // Tailwind optional / not installed — the SSR bundle discards CSS anyway.
425
+ }
426
+ const react = (await import('@vitejs/plugin-react')).default
427
+ const svgr = (await import('vite-plugin-svgr')).default
428
+ plugins.push(react(), svgr())
429
+
430
+ await viteBuild({
431
+ root: foundationRoot,
432
+ configFile: false,
433
+ logLevel: 'warn',
434
+ plugins,
435
+ build: {
436
+ outDir: tmpDir,
437
+ emptyOutDir: true,
438
+ sourcemap: false,
439
+ cssCodeSplit: false,
440
+ lib: {
441
+ entry: entrySourcePath,
442
+ formats: ['es'],
443
+ fileName: () => 'entry-ssr.js',
444
+ },
445
+ rollupOptions: {
446
+ external: isSSRExternal,
447
+ output: { inlineDynamicImports: true },
448
+ },
449
+ },
450
+ })
451
+
452
+ const built = join(tmpDir, 'entry-ssr.js')
453
+ if (!existsSync(built)) {
454
+ console.warn('Warning: entry-ssr.js build produced no JS output — skipped.')
455
+ return
456
+ }
457
+ const dest = join(outDir, 'entry-ssr.js')
458
+ await cp(built, dest)
459
+ const size = ((await stat(dest)).size / 1024).toFixed(1)
460
+ console.log(`Generated entry-ssr.js (${size} KB)`)
461
+ } catch (err) {
462
+ console.warn(`Warning: entry-ssr.js build failed: ${err.message}`)
463
+ } finally {
464
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
465
+ _buildingSSRBundle = false
466
+ }
467
+ }
468
+
343
469
  /**
344
470
  * Vite plugin for foundation builds
345
471
  */
@@ -418,17 +544,18 @@ export function foundationBuildPlugin(options = {}) {
418
544
  // automatically once the edge is updated.
419
545
  await emitRuntimePin(outDir, resolvedRoot)
420
546
 
421
- // Strategy S Phase 2: foundations no longer carry a self-contained
422
- // SSR bundle. The runtime + React + core + theming live in R2 under
423
- // runtime/{version}/worker-runtime.js (uploaded by the platform's
424
- // /deploy-runtime skill); the Cloudflare isolate side-loads them
425
- // alongside dist/entry.js via the edge dual-mode dispatcher.
547
+ // Emit dist/entry-ssr.js the single-file SSR twin of the (code-split)
548
+ // browser dist/entry.js for the Cloudflare edge isolate. React + the
549
+ // runtime stay externalized (resolved to the isolate's SHARED
550
+ // worker-runtime, so runtime patches propagate without a rebuild — the
551
+ // Strategy S win); the client-only Shiki/Fuse libs are externalized so the
552
+ // ~10 MB Shiki graph stays out. The edge loads this as its single
553
+ // `foundation` module for request-time SSR, gated on its presence.
426
554
  //
427
- // The buildSSRBundle() function is kept (just not invoked) so it
428
- // can be flipped back on with one line if Phase 1's edge dispatcher
429
- // misbehaves in production. Phase 3 cleanup deletes the function
430
- // entirely once we're confident the new path is healthy.
431
- // await buildSSRBundle(outDir)
555
+ // (The legacy self-contained buildSSRBundle() React + runtime INLINED,
556
+ // ~14 MB with Shiki is retained below, unused, for reference only.)
557
+ const entrySourcePath = join(resolvedSrcDir, entryFileName)
558
+ await buildEntrySSR(resolvedRoot, entrySourcePath, outDir)
432
559
  },
433
560
 
434
561
  async closeBundle() {