@uniweb/build 0.64.1 → 0.65.0

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.64.1",
3
+ "version": "0.65.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-reader": "^1.2.5",
60
+ "@uniweb/schemas": "^0.8.0",
61
+ "@uniweb/projections": "^0.7.5",
62
+ "@uniweb/theming": "^0.1.15",
61
63
  "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/projections": "^0.7.4",
63
- "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/schemas": "^0.7.1",
65
- "@uniweb/theming": "^0.1.15"
64
+ "@uniweb/content-reader": "^1.2.5",
65
+ "@uniweb/semantic-parser": "^1.4.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.6"
68
+ "@uniweb/runtime": "^0.27.7"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
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.30.1",
78
- "@uniweb/runtime": "^0.27.6"
77
+ "@uniweb/core": "^0.31.0",
78
+ "@uniweb/runtime": "^0.27.7"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -24,7 +24,7 @@ import { loadFreeformRecord } from './freeform.js'
24
24
  import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
25
25
  import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
26
26
  import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
27
- import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
27
+ import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored, storedFromMerged } from '@uniweb/schemas/conform'
28
28
  import { resolveDocForLocale } from './merge.js'
29
29
  import { extractUnitsFromDoc } from './extract.js'
30
30
  import { flatFormRefusal, extractExcerpt } from '../site/query-processor.js'
@@ -392,7 +392,7 @@ function isProseMirrorDoc(value) {
392
392
 
393
393
  /**
394
394
  * Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
395
- * delivered in its schema's content body field — `article_body.content` for
395
+ * delivered in its schema's content body field — `body.content` for
396
396
  * `@std/article` — and at `content` only for a record with no schema; a rich field can
397
397
  * sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
398
398
  * where a delivered `@std/article` holds no body.
@@ -796,7 +796,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
796
796
  // record whole beside its lean list, and a deferred field is exactly what the list
797
797
  // does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
798
798
  // one derived from a schema's brief included — never reached the manifest.
799
- const wholes = await wholeRecords(dataDir, queryName)
799
+ const wholes = await wholeRecords(dataDir, queryName, dataSchemas.get(queryName) ?? null)
800
800
  const extract = (item) => {
801
801
  const whole = wholes.get(recordHandle(item))
802
802
  const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
@@ -847,15 +847,19 @@ async function recordFileNames(dataDir, queryName) {
847
847
  }
848
848
 
849
849
  /**
850
- * A query's per-record files (`recordFileNames`), each record whole, by its handle.
850
+ * A query's per-record files (`recordFileNames`), each record whole, by its handle — as the MERGED
851
+ * view this module walks (`mergedFromStored`): a file holds the record as stored, a key per
852
+ * section (2026-09-27), and translation reads a record with its brief's fields at the top.
851
853
  *
854
+ * @param {Object|null} [dataSchema] - the query's data schema; with none a file is read as it is
852
855
  * @returns {Promise<Map<string, Object>>}
853
856
  */
854
- async function wholeRecords(dataDir, queryName) {
857
+ async function wholeRecords(dataDir, queryName, dataSchema = null) {
855
858
  const out = new Map()
856
859
  for (const name of await recordFileNames(dataDir, queryName)) {
857
860
  try {
858
- const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
861
+ const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
862
+ const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
859
863
  if (isPlainObject(record)) out.set(recordHandle(record), record)
860
864
  } catch (err) {
861
865
  console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
@@ -890,7 +894,7 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
890
894
 
891
895
  /**
892
896
  * Extract from a ProseMirror document — `field` is where the record holds it
893
- * (`content`, `article_body.content`), which each unit's `field` starts with.
897
+ * (`content`, `body.content`), which each unit's `field` starts with.
894
898
  */
895
899
  function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
896
900
  // The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
@@ -1043,13 +1047,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
1043
1047
  const wholes = new Map() // handle → { source, translated }
1044
1048
  for (const name of recordNames) {
1045
1049
  try {
1046
- const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
1050
+ // ⭐ A record's file holds it AS STORED (2026-09-27); it is translated as the merged
1051
+ // view and written back as stored (`mergedFromStored`, `storedFromMerged`).
1052
+ const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
1053
+ const { dataSchema } = recordOptions
1054
+ const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
1047
1055
  const translatedRecord = isPlainObject(record)
1048
1056
  ? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
1049
1057
  : record
1050
1058
  if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
1059
+ const written = dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord
1051
1060
  await mkdir(localeRecordsDir, { recursive: true })
1052
- await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
1061
+ await writeFile(join(localeRecordsDir, name), JSON.stringify(written, null, 2))
1053
1062
  } catch (err) {
1054
1063
  console.error(`[i18n] Failed to translate ${queryName}/${name} for ${locale}: ${err.message}`)
1055
1064
  failures.push({ locale, file: `${queryName}/${name}`, message: err.message })
@@ -1265,7 +1274,6 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1265
1274
 
1266
1275
  const one = !!items && typeof items === 'object' && !Array.isArray(items)
1267
1276
  if (!Array.isArray(items) && !one) return items
1268
- const records = one ? [items] : items
1269
1277
 
1270
1278
  const schema = await resolveSchema(queryName, siteRoot)
1271
1279
  const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
@@ -1279,11 +1287,15 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1279
1287
  targets,
1280
1288
  excerpt: excerpts.get(queryName),
1281
1289
  }
1290
+ const { dataSchema } = recordOptions
1291
+ // ONE record is a record's own file, which holds it AS STORED (2026-09-27): translated as the
1292
+ // merged view, returned as stored (`mergedFromStored`, `storedFromMerged`).
1293
+ const records = one ? [dataSchema ? mergedFromStored(dataSchema, items) : items] : items
1282
1294
 
1283
1295
  // A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
1284
1296
  const wholes = new Map()
1285
1297
  if (!one) {
1286
- for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
1298
+ for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName, dataSchema)) {
1287
1299
  wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
1288
1300
  }
1289
1301
  }
@@ -1291,7 +1303,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1291
1303
  records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
1292
1304
  )
1293
1305
 
1294
- return one ? translated[0] : translated
1306
+ if (!one) return translated
1307
+ return dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0]
1295
1308
  }
1296
1309
 
1297
1310
  // ---------------------------------------------------------------------------
@@ -85,6 +85,9 @@ export const bridgeFileName = (spec) => `${spec.replace(/\//g, '-')}.js`
85
85
  * @param {Object} [options.devBridges] - Map of specifier → dev-mode URL for import map injection in dev.
86
86
  * When provided, the import map is injected in both dev and prod (with different URLs).
87
87
  * When omitted, the import map is only injected in prod (dev uses other mechanisms like transformRequest).
88
+ * @param {boolean} [options.serveInDev] - In dev, point each external at a bridge the dev server serves
89
+ * itself — the module a build would emit, through Vite's pipeline, so its bare imports become the very
90
+ * URLs the host app imports. For a module loaded from a URL, which no dev plugin rewrites.
88
91
  * @returns {import('vite').Plugin}
89
92
  */
90
93
  export function importMapPlugin({
@@ -93,14 +96,17 @@ export function importMapPlugin({
93
96
  basePath = '/',
94
97
  resolveFrom,
95
98
  devBridges,
99
+ serveInDev = false,
96
100
  } = {}) {
97
101
  let isBuild = false
102
+ let devBase = '/'
98
103
 
99
104
  return {
100
105
  name,
101
106
 
102
107
  configResolved(config) {
103
108
  isBuild = config.command === 'build'
109
+ devBase = config.base || '/'
104
110
  },
105
111
 
106
112
  resolveId(id, importer) {
@@ -199,6 +205,13 @@ export function importMapPlugin({
199
205
  }
200
206
  } else if (devBridges) {
201
207
  Object.assign(imports, devBridges)
208
+ } else if (serveInDev) {
209
+ // Vite serves a virtual module at `/@id/`, its NUL written `__x00__` — the id `load`
210
+ // answers below, so dev and build bridge one module the same way.
211
+ for (const ext of externals) {
212
+ const spec = specOf(ext)
213
+ imports[spec] = `${devBase}@id/__x00__${IMPORT_MAP_PREFIX.slice(1)}${spec}`
214
+ }
202
215
  } else {
203
216
  // No dev injection — consumer handles dev mode separately
204
217
  return html
package/src/prerender.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  deriveCacheKey,
25
25
  evaluateQuery,
26
26
  } from '@uniweb/core'
27
- import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
27
+ import { recordTitle, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
28
28
  import DataStore from '@uniweb/core/datastore'
29
29
  import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
30
30
  import { createFileTransport } from './site/file-transport.js'
@@ -258,6 +258,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
258
258
  * @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
259
259
  * @param {Object} [options]
260
260
  * @param {Object|Array|null} [options.siteFetch] - the site's `fetch`, the last level of a route query
261
+ * @param {Object|null} [options.queries] - the site's `config.queries`: a route query that binds the
262
+ * URL's last segment to a field (`where: { slug: ':slug' }`, `routeFieldOf`) is expanded by that field
261
263
  * @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
262
264
  */
263
265
  /**
@@ -295,7 +297,7 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
295
297
  return (website.basePath || '') + withSlash
296
298
  }
297
299
 
298
- export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null } = {}) {
300
+ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null, queries = null } = {}) {
299
301
  if (!stats.unrouted) stats.unrouted = {}
300
302
  const expandedPages = []
301
303
 
@@ -378,6 +380,8 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
378
380
 
379
381
  const items = data
380
382
  const key = route.key
383
+ // The field the route query binds the URL's last segment to, if any — else `[slug]` names `$name`.
384
+ const field = routeFieldOf(queries?.[route.config?.query ?? key])
381
385
 
382
386
  onProgress(` Expanding ${page.route} → ${items.length} pages from ${key}`)
383
387
 
@@ -399,7 +403,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
399
403
  // value — the case the rule is for — gets exactly one page. Ruled 2026-09-12
400
404
  // [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
401
405
  // until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
402
- const values = routeParamValues(item, paramName)
406
+ const values = routeParamValues(item, paramName, field)
403
407
  if (values.length === 0) {
404
408
  unrouted += 1
405
409
  continue
@@ -470,7 +474,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
470
474
  if (unrouted > 0) {
471
475
  stats.unrouted[page.route] = unrouted
472
476
  onProgress(
473
- ` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName}" — no page was ` +
477
+ ` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName === 'slug' ? field || '$name' : paramName}" — no page was ` +
474
478
  `generated for them under ${page.route}`
475
479
  )
476
480
  }
@@ -893,6 +897,7 @@ export async function prerenderSite(siteDir, options = {}) {
893
897
  onProgress('Expanding dynamic routes...')
894
898
  siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
895
899
  siteFetch: siteContent.config?.fetch ?? null,
900
+ queries: siteContent.config?.queries ?? null,
896
901
  })
897
902
 
898
903
  // ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS — its branch's view, a `deferred:`
@@ -38,6 +38,7 @@
38
38
  import { readFile } from 'node:fs/promises'
39
39
  import { existsSync, readdirSync, statSync } from 'node:fs'
40
40
  import { join, resolve, isAbsolute, extname, basename } from 'node:path'
41
+ import { dataRefOf } from '@uniweb/core/data-keys'
41
42
  import { pathToFileURL } from 'node:url'
42
43
  import { createRequire } from 'node:module'
43
44
  import yaml from 'js-yaml'
@@ -105,8 +106,9 @@ export function collectSchemaRefs(components, foundationData = null) {
105
106
  const readData = (data) => {
106
107
  if (!data || typeof data !== 'object' || data === false) return
107
108
  for (const binding of Object.values(data)) {
108
- const ref = typeof binding === 'string' ? binding : binding?.schema
109
- if (typeof ref === 'string') refs.add(ref)
109
+ // `'@std/article/*'` asks for whole records of `@std/article` — the ref is what precedes it.
110
+ const { ref } = dataRefOf(binding)
111
+ if (ref) refs.add(ref)
110
112
  }
111
113
  }
112
114
  for (const meta of Object.values(components || {})) readData(meta?.data)
@@ -25,7 +25,8 @@
25
25
  */
26
26
 
27
27
  import { isRichSchema } from '@uniweb/core'
28
- import { deliveredFields } from '@uniweb/schemas/conform'
28
+ import { dataRefOf } from '@uniweb/core/data-keys'
29
+ import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
29
30
  import { enumValues } from '@uniweb/schemas/format'
30
31
 
31
32
  /**
@@ -189,26 +190,22 @@ function normalizeRichSchemaValue(value) {
189
190
  * @returns {Object|null}
190
191
  */
191
192
  function leanDataSchema(value, dataSchemaMap) {
192
- // Named ref → the resolved schema's fields.
193
- const ref = typeof value === 'string'
194
- ? value
195
- : (value && typeof value.schema === 'string' ? value.schema : null)
193
+ // Named ref → the resolved schema's fields. `'@std/article/*'` asks for whole records of it.
194
+ const { ref, whole } = dataRefOf(value)
196
195
  if (ref) {
197
196
  const resolved = dataSchemaMap[ref]
198
- // ⭐ THE DELIVERED RECORD'S FIELDS (`deliveredFields`) — the brief's at the top and each
199
- // other section as one field under its name — so a default lands where the record
200
- // a component receives carries its field: `@std/article`'s `status` inside
201
- // `article_body`, as the records service delivers it (measured 2026-09-24). A section
202
- // is filled only when the record holds it (`applySchemaToObject` recurses into what is
203
- // there), so a list of briefs gains no section it was not sent.
197
+ // ⭐ THE FIELDS OF WHAT THE COMPONENT RECEIVES (ruled 2026-09-27 [Diego]): a brief's fields at
198
+ // the top (`briefFieldMap`), or, for `'@x/y/*'`, the whole record as stored — one field per
199
+ // section, the brief's included (`wholeFieldMap`) — so a default lands where the record carries
200
+ // its field: `@std/article`'s `status` inside `body`. A section is filled only when the record
201
+ // holds it (`applySchemaToObject` recurses into what is there).
204
202
  //
205
- // ⛔ It was `flatRecordFields` until 2026-09-24 — the retired flat form, every single
206
- // section's fields at the top — so on a delivered record those defaults landed beside
207
- // the section they belong to. And before 2026-09-03 it read `resolved.fields`, which a
208
- // sections-form schema does not have: no defaults at all. `dataSchemaMap` holds each
209
- // schema normalized in its authored form (`fields:` or `sections:`), and only these
210
- // readers say what one record of it looks like.
211
- const fields = deliveredFields(resolved)
203
+ // ⛔ It was the merged `deliveredFields` until 2026-09-27 — the brief's fields beside the other
204
+ // sections' names, one map for two shapes; `flatRecordFields`, the retired flat form, until
205
+ // 2026-09-24; and before 2026-09-03 `resolved.fields`, which a sections-form schema does not
206
+ // have. `dataSchemaMap` holds each schema normalized in its authored form (`fields:` or
207
+ // `sections:`), and only these readers say what one record of it looks like.
208
+ const fields = whole ? wholeFieldMap(resolved) : briefFieldMap(resolved)
212
209
  if (!fields) return null
213
210
  const lean = extractSchemaFields(fields)
214
211
  return Object.keys(lean).length > 0 ? lean : null
@@ -20,7 +20,7 @@
20
20
  * })
21
21
  */
22
22
 
23
- import { existsSync, readFileSync } from 'node:fs'
23
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
24
24
  import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -34,6 +34,8 @@ import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
34
34
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
35
35
  import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
36
36
  import { detectFoundationType } from './foundation-ref.js'
37
+ import { readPreview, PREVIEW_ENV } from './preview.js'
38
+ import { previewMediaPlugin } from './preview-media.js'
37
39
 
38
40
  /**
39
41
  * Normalize a base path for Vite compatibility
@@ -112,6 +114,25 @@ function unique(list) {
112
114
  return [...new Set(list)]
113
115
  }
114
116
 
117
+ /**
118
+ * The site's `@uniweb/runtime`, as a path to resolve its dependencies from — its REAL
119
+ * directory, since under pnpm a package's dependencies sit beside its real location and
120
+ * not beside the link to it. Null when the site has none installed.
121
+ */
122
+ function runtimeAnchor(siteRoot) {
123
+ for (let dir = siteRoot; ; dir = dirname(dir)) {
124
+ const candidate = join(dir, 'node_modules', '@uniweb', 'runtime')
125
+ if (existsSync(join(candidate, 'package.json'))) {
126
+ try {
127
+ return join(realpathSync(candidate), 'package.json')
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+ if (dirname(dir) === dir) return null
133
+ }
134
+ }
135
+
115
136
  export async function defineSiteConfig(options = {}) {
116
137
  const {
117
138
  plugins: extraPlugins = [],
@@ -147,8 +168,15 @@ export async function defineSiteConfig(options = {}) {
147
168
  const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
148
169
  const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
149
170
 
150
- // Detect foundation type
151
- const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
171
+ // What `uniweb dev` read from the backend the site is on, for this dev server alone
172
+ // (`./preview.js`) — null unless it was handed one.
173
+ const preview = readPreview(process.env)
174
+
175
+ // Detect foundation type. A catalog ref resolves only for a preview, which is handed where
176
+ // the backend serves it.
177
+ const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot, {
178
+ served: preview?.foundation
179
+ })
152
180
 
153
181
  // Check for runtime mode (env variable or URL-based foundation).
154
182
  // Runtime mode means the foundation is loaded by URL at runtime; the
@@ -376,13 +404,39 @@ export async function defineSiteConfig(options = {}) {
376
404
  plugins.push(importMapPlugin({
377
405
  basePath: base || '/',
378
406
  // Under pnpm strict mode, the site may not have @uniweb/core in its own
379
- // node_modules. Resolve from the foundation directory where it's a direct dep.
407
+ // node_modules. Resolve from the foundation directory where it's a direct dep —
408
+ // or, with no local foundation, as the site's runtime resolves it: the runtime is
409
+ // the host app whose instances a foundation must share.
380
410
  resolveFrom: foundationInfo.path
381
411
  ? resolve(foundationInfo.path, 'package.json')
382
- : resolve(siteRoot, 'main.js'),
412
+ : runtimeAnchor(siteRoot) || resolve(siteRoot, 'main.js'),
413
+ // A foundation served from a URL imports its externals by bare name, and in dev
414
+ // nothing else resolves them: the foundation dev plugin rewrites a LOCAL
415
+ // foundation's imports, and a served one is not ours to rewrite. So the dev server
416
+ // answers the import map a build writes, bridging to the site's own copies.
417
+ serveInDev: foundationInfo.type === 'url'
383
418
  }))
384
419
  }
385
420
 
421
+ if (preview) {
422
+ // ⛔ A PREVIEW'S ANSWER NEVER REACHES A BUILT ARTIFACT. Where the backend serves a
423
+ // version is decided at publish and read for one preview (`./preview.js`); written into
424
+ // `dist/` it would outlive the answer.
425
+ plugins.push({
426
+ name: 'uniweb:preview-only',
427
+ config(_config, env) {
428
+ if (env.command === 'build') {
429
+ throw new Error(
430
+ `[site] ${PREVIEW_ENV} is what \`uniweb dev\` read from ${preview.backend} for one ` +
431
+ `preview, and a build does not take it. Unset it to build.`
432
+ )
433
+ }
434
+ }
435
+ })
436
+ // A media URL the site keeps from its backend is fetched from that backend.
437
+ plugins.push(previewMediaPlugin({ backend: preview.backend, siteRoot }))
438
+ }
439
+
386
440
  // Preload hints for runtime-loaded foundations and extensions.
387
441
  // In runtime mode, foundation JS is loaded via import() and CSS is injected
388
442
  // dynamically in JavaScript — the browser doesn't discover them until JS executes.
@@ -192,8 +192,8 @@ export function schemaForPoolDirs(dirs) {
192
192
  * ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
193
193
  * exists at all: the reader derives a model from a path and the pull side derives
194
194
  * a path from a model, and if those two ever disagree a pulled record lands
195
- * somewhere the next build reads as a different schema. Same rule as
196
- * `deferredFromSchema` — a deriver and its recognizer must not be two copies.
195
+ * somewhere the next build reads as a different schema. A deriver and its
196
+ * recognizer must not be two copies.
197
197
  *
198
198
  * @param {string} schema - a ref: `@/name` or `@org/name`
199
199
  * @returns {string[]|null} the directory segments below `records/`, or null for
@@ -66,11 +66,15 @@ export function parseCatalogRef(value) {
66
66
  *
67
67
  * @param {string|Object} foundation - Foundation config from site.yml
68
68
  * @param {string} siteRoot - Path to site directory
69
- * @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
69
+ * @param {Object} [options]
70
+ * @param {{ ref: string, url: string, cssUrl?: string|null }|null} [options.served] - where a
71
+ * catalog ref is served, for a preview: the answer `uniweb dev` read from the backend the site
72
+ * is on (`./preview.js`). Honored for the ref it names and no other.
73
+ * @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string, served?: string }}
70
74
  * @throws {Error} when the declaration shape is invalid (versionless registry
71
75
  * ref, unknown name with no local match, etc.)
72
76
  */
73
- export function detectFoundationType(foundation, siteRoot) {
77
+ export function detectFoundationType(foundation, siteRoot, { served = null } = {}) {
74
78
  // Object form with explicit URL
75
79
  if (foundation && typeof foundation === 'object') {
76
80
  if (foundation.url) {
@@ -119,11 +123,26 @@ export function detectFoundationType(foundation, siteRoot) {
119
123
  // --backend` and the documented env var all left it pinned — and the artifact
120
124
  // names were the pre-`entry.js` ones the build stopped emitting.
121
125
  if (parseCatalogRef(name)) {
126
+ // ⭐ A PREVIEW is handed the answer instead of guessing it: `uniweb dev` asks the backend
127
+ // the site is on where this version is served, for its one dev server. The answer names
128
+ // the ref it is for, so it resolves the DECLARED foundation and can substitute no other.
129
+ if (served?.url && served.ref === name) {
130
+ return { type: 'url', url: served.url, cssUrl: served.cssUrl || null, served: name }
131
+ }
132
+ if (served?.ref) {
133
+ throw new Error(
134
+ `This dev server was handed where "${served.ref}" is served, and site.yml names "${name}".\n` +
135
+ `Restart \`uniweb dev\`, which asks for the foundation site.yml names.`
136
+ )
137
+ }
122
138
  throw new Error(
123
139
  [
124
140
  `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
125
141
  `Where a foundation is served is the host's to declare, so the build does not guess it.`,
126
142
  ``,
143
+ ` • Previewing the site? Run \`uniweb dev\`, which asks the backend the site is on`,
144
+ ` where this version is served — and says so when that backend does not tell it.`,
145
+ ``,
127
146
  ` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
128
147
  ` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
129
148
  ` the runtime accepts any URL, from any host:`,
package/src/site/index.js CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  export { siteContentPlugin } from './plugin.js'
10
10
  export { defineSiteConfig, readSiteConfig, default } from './config.js'
11
+ // What `uniweb dev` hands the dev server of a site a backend holds (`./preview.js`).
12
+ export { PREVIEW_ENV, encodePreview, readPreview } from './preview.js'
11
13
  export { collectSiteContent } from './content-collector.js'
12
14
  // The `agents:` vocabulary lives in @uniweb/projections (which owns the block);
13
15
  // re-exported so the CLI can validate an author's spelling without taking a
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A PREVIEW FETCHES THE MEDIA ITS BACKEND SERVES — from that backend.
3
+ *
4
+ * A clone keeps a serve URL wherever the backend held one the pull cannot map back to an author's
5
+ * file — a record's image, a param (`uwx/asset-map.js::restoreAssetRefs`: *"the URL that works"*).
6
+ * A root-relative one names a file on the origin that served the document holding it. On the dev
7
+ * server's origin it names nothing, and a dev server answers a path it lacks with `index.html` —
8
+ * so the image broke with a 200.
9
+ *
10
+ * So in a preview (`./preview.js`), a request for media the site does not hold is answered by the
11
+ * backend the site is on. ⭐ Nothing is composed: the path is the URL the content holds, resolved
12
+ * against the origin it came from. ⛔ Only media — a request whose destination is an image, a video,
13
+ * audio, a track or a font — and only what the site does not hold, in `public/` or under its root:
14
+ * the site's own files, its routes and Vite's own paths are never sent anywhere. The request carries
15
+ * no credential; a served asset is public.
16
+ */
17
+ import { existsSync, statSync } from 'node:fs'
18
+ import { join, normalize, sep } from 'node:path'
19
+ import { Readable } from 'node:stream'
20
+
21
+ const MEDIA_DESTINATIONS = new Set(['image', 'video', 'audio', 'track', 'font'])
22
+
23
+ /**
24
+ * @param {{ backend: string, siteRoot: string }} options - the backend's origin (`readPreview`)
25
+ * @returns {import('vite').Plugin}
26
+ */
27
+ export function previewMediaPlugin({ backend, siteRoot }) {
28
+ return {
29
+ name: 'uniweb:preview-media',
30
+ apply: 'serve',
31
+ configureServer(server) {
32
+ // Registered before Vite's own middlewares, whose SPA fallback would answer first.
33
+ server.middlewares.use(async (req, res, next) => {
34
+ const pathname = mediaPath(req)
35
+ if (!pathname) return next()
36
+ const base = server.config.base || '/'
37
+ const local = [pathname]
38
+ if (base !== '/' && pathname.startsWith(base)) local.push('/' + pathname.slice(base.length))
39
+ const roots = [server.config.publicDir, siteRoot].filter(Boolean)
40
+ if (local.some((p) => roots.some((root) => holds(root, p)))) return next()
41
+
42
+ let upstream
43
+ try {
44
+ upstream = await fetch(new URL(req.url, backend), { method: req.method, redirect: 'follow' })
45
+ } catch (err) {
46
+ res.statusCode = 502
47
+ return res.end(`The site's backend (${backend}) did not answer: ${err.message}`)
48
+ }
49
+ // Its answer, whatever it is — a 404 here is the backend not having it either, which
50
+ // is the truth; falling through would answer `index.html`.
51
+ res.statusCode = upstream.status
52
+ for (const header of ['content-type', 'content-length', 'cache-control', 'etag', 'last-modified']) {
53
+ const value = upstream.headers.get(header)
54
+ if (value) res.setHeader(header, value)
55
+ }
56
+ if (req.method === 'HEAD' || !upstream.body) return res.end()
57
+ Readable.fromWeb(upstream.body).pipe(res)
58
+ })
59
+ }
60
+ }
61
+ }
62
+
63
+ /** The root-relative path a media request names, or null for any other request. */
64
+ export function mediaPath(req) {
65
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
66
+ const dest = req.headers['sec-fetch-dest']
67
+ const accept = String(req.headers.accept || '')
68
+ const media = dest ? MEDIA_DESTINATIONS.has(dest) : /^(image|video|audio)\//.test(accept)
69
+ if (!media) return null
70
+ let pathname
71
+ try {
72
+ pathname = decodeURIComponent(new URL(req.url, 'http://preview.invalid').pathname)
73
+ } catch {
74
+ return null
75
+ }
76
+ // Vite's own paths are never the backend's.
77
+ if (/^\/(@|node_modules\/|__)/.test(pathname)) return null
78
+ return pathname
79
+ }
80
+
81
+ /** Whether a file lies at `pathname` under `root`, and inside it. */
82
+ function holds(root, pathname) {
83
+ const file = normalize(join(root, pathname))
84
+ if (file !== root && !file.startsWith(root.endsWith(sep) ? root : root + sep)) return false
85
+ try {
86
+ return existsSync(file) && statSync(file).isFile()
87
+ } catch {
88
+ return false
89
+ }
90
+ }