@uniweb/build 0.64.0 → 0.64.2

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.0",
3
+ "version": "0.64.2",
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/schemas": "^0.7.2",
61
+ "@uniweb/content-reader": "^1.2.5",
62
+ "@uniweb/content-writer": "^0.3.4",
60
63
  "@uniweb/projections": "^0.7.4",
61
64
  "@uniweb/semantic-parser": "^1.4.1",
62
- "@uniweb/schemas": "^0.7.0",
63
- "@uniweb/theming": "^0.1.15",
64
- "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/content-reader": "^1.2.5"
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.5"
68
+ "@uniweb/runtime": "^0.27.6"
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.0",
78
- "@uniweb/runtime": "^0.27.5"
77
+ "@uniweb/runtime": "^0.27.6",
78
+ "@uniweb/core": "^0.30.1"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -30,6 +30,9 @@ try {
30
30
  export function extractTranslatableContent(siteContent) {
31
31
  const units = {}
32
32
 
33
+ // The site's own Open Graph title and description (`site.yml` `seo:`), in the site's context.
34
+ extractFromSiteMeta(siteContent.config, units)
35
+
33
36
  for (const page of siteContent.pages || []) {
34
37
  const pageRoute = page.route || '/'
35
38
 
@@ -82,6 +85,22 @@ export function extractTranslatableContent(siteContent) {
82
85
  * @param {string} pageRoute - Page route
83
86
  * @param {Object} units - Units accumulator
84
87
  */
88
+ /**
89
+ * The context of the site's own metadata — its Open Graph title and description (`site.yml` `seo:`),
90
+ * which a push sends per language as `settings.og_title` / `og_description` (`uwx/open-graph.js`).
91
+ * A page's is `<route>:_meta`; the site's is this one.
92
+ */
93
+ export const SITE_META_CONTEXT = Object.freeze({ page: '/', section: '_site' })
94
+
95
+ function extractFromSiteMeta(config, units) {
96
+ const seo = config?.seo
97
+ if (!seo || typeof seo !== 'object') return
98
+ if (typeof seo.ogTitle === 'string' && seo.ogTitle) addUnit(units, seo.ogTitle, 'site.seo.ogTitle', SITE_META_CONTEXT)
99
+ if (typeof seo.ogDescription === 'string' && seo.ogDescription) {
100
+ addUnit(units, seo.ogDescription, 'site.seo.ogDescription', SITE_META_CONTEXT)
101
+ }
102
+ }
103
+
85
104
  function extractFromPageMeta(page, pageRoute, units) {
86
105
  // Use special section identifier for page-level metadata
87
106
  const context = { page: pageRoute, section: '_meta' }
package/src/i18n/merge.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
- import { elementText, blockElements, translationContext } from './extract.js'
17
+ import { elementText, blockElements, translationContext, SITE_META_CONTEXT } from './extract.js'
18
18
  import { visitDataStrings } from './data-strings.js'
19
19
 
20
20
  // Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
@@ -71,6 +71,7 @@ export function mergeTranslations(siteContent, translations, options = {}) {
71
71
  function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
72
72
  // Deep clone to avoid mutating original
73
73
  const translated = JSON.parse(JSON.stringify(siteContent))
74
+ translateSiteMeta(translated.config, translations, fallbackToSource)
74
75
 
75
76
  for (const page of translated.pages || []) {
76
77
  const pageRoute = page.route || '/'
@@ -120,6 +121,7 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
120
121
 
121
122
  // Deep clone to avoid mutating original
122
123
  const translated = JSON.parse(JSON.stringify(siteContent))
124
+ translateSiteMeta(translated.config, translations, fallbackToSource)
123
125
 
124
126
  for (const page of translated.pages || []) {
125
127
  const pageRoute = page.route || '/'
@@ -222,6 +224,18 @@ function toPlainText(value) {
222
224
  * Every field here is a PLAIN-TEXT slot — `<title>`, a nav label, a meta
223
225
  * description — so each value is flattened. See `toPlainText`.
224
226
  */
227
+ // The site's own Open Graph title and description (`config.seo`), in the site's context — as a page's
228
+ // are in its own (`translatePageMeta`).
229
+ function translateSiteMeta(config, translations, fallbackToSource) {
230
+ const seo = config?.seo
231
+ if (!seo || typeof seo !== 'object') return
232
+ for (const key of ['ogTitle', 'ogDescription']) {
233
+ if (typeof seo[key] === 'string' && seo[key]) {
234
+ seo[key] = lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource)
235
+ }
236
+ }
237
+ }
238
+
225
239
  function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
226
240
  const context = { page: pageRoute, section: '_meta' }
227
241
  const translate = (value) =>
@@ -23,7 +23,7 @@ import { computeHash } from './hash.js'
23
23
  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
- import { toDataSchemaDeclaration, isProseMirrorField } from '../uwx/data-schema.js'
26
+ import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
27
27
  import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
28
28
  import { resolveDocForLocale } from './merge.js'
29
29
  import { extractUnitsFromDoc } from './extract.js'
@@ -224,6 +224,10 @@ function eachModelField(record, model, visit) {
224
224
  function eachSectionValue(value, section, path, visit) {
225
225
  if (!section?.multiple) return eachSectionField(value, section?.fields, path, visit)
226
226
  if (Array.isArray(value)) value.forEach((item, i) => eachSectionField(item, section.fields, `${path}[${i}]`, visit))
227
+ // An open map as its file holds it: each entry is one of the rows a push sends (`isOpenMapSection`).
228
+ else if (isOpenMapSection(section) && isPlainObject(value)) {
229
+ for (const [key, item] of Object.entries(value)) eachSectionField(item, section.fields, `${path}.${key}`, visit)
230
+ }
227
231
  }
228
232
 
229
233
  function eachSectionField(holder, fields, path, visit) {
@@ -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
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { isRichSchema } from '@uniweb/core'
28
28
  import { deliveredFields } from '@uniweb/schemas/conform'
29
+ import { enumValues } from '@uniweb/schemas/format'
29
30
 
30
31
  /**
31
32
  * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
@@ -70,7 +71,8 @@ function extractSchemaField(field) {
70
71
  // inert at runtime but carried through.
71
72
  if (field.type) lean.type = field.type
72
73
  if (field.default !== undefined) lean.default = field.default
73
- if (field.enum !== undefined) lean.enum = field.enum
74
+ // The values only: an entry's label (`{ value, label }`) is an editor's, and the runtime compares.
75
+ if (field.enum !== undefined) lean.enum = enumValues(field.enum)
74
76
  if (field.options) lean.options = field.options
75
77
 
76
78
  // Nested object → recurse into its field map.
@@ -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.
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A PREVIEW OF A SITE A BACKEND HOLDS — what `uniweb dev` read from that backend, handed to the one
3
+ * dev server it starts.
4
+ *
5
+ * A clone names its foundation by catalog ref (`@acme/fnd@1.2.0`) and keeps the media URLs the
6
+ * backend serves, so two things its dev server needs are the backend's to say. `uniweb dev` asks the
7
+ * backend the site is on, through the site, and passes what it learned in `UNIWEB_PREVIEW`, as JSON:
8
+ *
9
+ * { backend: 'https://…', the backend the site is on
10
+ * foundation: { ref, url, cssUrl } } where it serves the version site.yml names
11
+ *
12
+ * ⭐ It resolves the declared foundation; it never substitutes one. The answer names the ref it is
13
+ * for, and `detectFoundationType` honors it for that ref alone — which is what separates it from the
14
+ * `UNIWEB_FOUNDATION_REF` override `config.js` retired, a way for a site to be rendered by code its
15
+ * own `site.yml` did not name.
16
+ *
17
+ * ⭐ And a media URL the clone kept is the backend's own. A pull keeps any serve URL it cannot map
18
+ * back to an author's file (`uwx/asset-map.js::restoreAssetRefs`, *"the URL that works"*), and a
19
+ * root-relative one works on the origin that served the document holding it. So the dev server
20
+ * fetches from `backend` what the content names and the site does not have (`config.js`) — it
21
+ * resolves the URL it was given against the origin it came from, and composes no route.
22
+ *
23
+ * ⛔ Nothing keeps it. A serve location is decided at publish and read from the response that
24
+ * carries it, so it lives as long as the process that asked: `site.yml` keeps the ref, and no push,
25
+ * pull or build sees a URL from here. A build handed one refuses it (`config.js`), so a preview's
26
+ * answer never reaches a built artifact.
27
+ *
28
+ * ⚠️ And it may not be known. Whether a backend tells the CLI where a version is served is the
29
+ * backend's to decide; `uniweb dev` says so and starts nothing when it is not told.
30
+ */
31
+
32
+ /** The variable `uniweb dev` sets for the dev server it starts. */
33
+ export const PREVIEW_ENV = 'UNIWEB_PREVIEW'
34
+
35
+ /**
36
+ * The variable's value.
37
+ *
38
+ * @param {{ backend: string, foundation: { ref: string, url: string, cssUrl?: string|null } }} preview
39
+ * @returns {string}
40
+ */
41
+ export function encodePreview({ backend, foundation }) {
42
+ const { ref, url, cssUrl = null } = foundation
43
+ return JSON.stringify({ backend, foundation: { ref, url, cssUrl: cssUrl || null } })
44
+ }
45
+
46
+ /**
47
+ * What a dev server was handed, or null when it was handed nothing.
48
+ *
49
+ * @param {Record<string, string|undefined>} [env]
50
+ * @returns {{ backend: string, foundation: { ref: string, url: string, cssUrl: string|null } }|null}
51
+ * @throws {Error} when the variable is set and says nothing usable — a preview that cannot read its
52
+ * answer must not start as if it had none
53
+ */
54
+ export function readPreview(env = process.env) {
55
+ const raw = env[PREVIEW_ENV]
56
+ if (!raw) return null
57
+ let given = null
58
+ try {
59
+ given = JSON.parse(raw)
60
+ } catch {
61
+ // reported below
62
+ }
63
+ const f = given?.foundation
64
+ if (!isHttpOrigin(given?.backend) || typeof f?.ref !== 'string' || typeof f.url !== 'string' || !f.url) {
65
+ throw new Error(
66
+ `${PREVIEW_ENV} holds no preview (${JSON.stringify(raw).slice(0, 120)}). ` +
67
+ 'It is set by `uniweb dev`; run that rather than setting it.'
68
+ )
69
+ }
70
+ return {
71
+ backend: new URL(given.backend).origin,
72
+ foundation: { ref: f.ref, url: f.url, cssUrl: typeof f.cssUrl === 'string' ? f.cssUrl : null }
73
+ }
74
+ }
75
+
76
+ // ⛔ `new URL('localhost:8080')` parses — as the scheme `localhost:` — and its origin is the string
77
+ // "null". An origin is http(s), or it is nothing.
78
+ function isHttpOrigin(value) {
79
+ try {
80
+ return /^https?:$/.test(new URL(value).protocol)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
@@ -31,7 +31,8 @@
31
31
  import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
33
  import { recordLayout } from '@uniweb/schemas/conform'
34
- import { detectFoundationType } from './foundation-ref.js'
34
+ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
35
+ import { readRegisteredFoundation } from './registered-foundation.js'
35
36
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
36
37
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
37
38
  import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
@@ -457,6 +458,12 @@ export function foundationDataSchemas(siteRoot, siteYml) {
457
458
  */
458
459
  export function foundationSchemaJson(siteRoot, siteYml) {
459
460
  if (!siteYml?.foundation) return null
461
+ // ⭐ A foundation named by catalog ref — every clone's — is read from the registered version the
462
+ // project keeps (`registered-foundation.js`): its section types, with their `data:`, and no data
463
+ // schemas, which travel as Models. Null until a push or a pull has read it.
464
+ if (parseCatalogRef(siteYml.foundation)) {
465
+ return readRegisteredFoundation(siteRoot, siteYml.foundation)?.schema || null
466
+ }
460
467
  let info
461
468
  try {
462
469
  info = detectFoundationType(siteYml.foundation, siteRoot)
@@ -606,10 +606,12 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
606
606
  * (`@uniweb/schemas/conform`'s `toDeliveredRecord`), with a markdown body in the
607
607
  * schema's content body field — the ProseMirror document for a `format: prosemirror`
608
608
  * field (`article_body.content`, for `@std/article`), the markdown source for a markup
609
- * `text` one. A component is written against one shape, so a static site hands it that
610
- * one (ruled 2026-09-24 [Diego]: the same as hosted). ⛔ Until then a record reached a
611
- * component as its file held it, its body in `content` at the top — a shape no host
612
- * delivers.
609
+ * `text` one. A component is written against one shape, so both lanes deliver the same
610
+ * one — ruled 2026-09-24 [Diego] as a requirement that the shape be one AND a good one,
611
+ * not as an instruction to adopt the host's. ⚠️ It was built by adopting the host's as it
612
+ * was (read until 2026-09-26 as "the same as hosted"), and that shape puts two namespaces
613
+ * in one object (`@uniweb/schemas/conform`, above `toDeliveredRecord`). ⛔ Until 2026-09-24
614
+ * a record reached a component as its file held it, its body in `content` at the top.
613
615
  *
614
616
  * `$name` is the record's handle, taken BEFORE the lift: a brief may declare a `slug`
615
617
  * field of its own (`@std/article`'s does), and the lift puts that one over the file's.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A REGISTERED FOUNDATION, AS A PROJECT THAT NAMES IT BY CATALOG REF KEEPS IT.
3
+ *
4
+ * A site whose foundation is a catalog ref (`@acme/fnd@1.2.0`) — every clone — has no build of it
5
+ * to read from. What the project's own tools need from it is its section types' `data:`, which is
6
+ * what types a query named for a data key (`uwx/data-key-types.js`). The CLI reads the registered
7
+ * version from the backend the site is on and keeps the reply here, under `.uniweb/`; the build,
8
+ * the push and the pull read it back through `foundationSchemaJson`.
9
+ *
10
+ * ⭐ A registered version never changes, so the copy never goes stale, and losing it costs a round
11
+ * trip, never a wrong answer: the next push or pull reads it again. That is what makes `.uniweb/`
12
+ * (gitignored, per copy) the right home for it.
13
+ *
14
+ * Only its `schema` is kept — the foundation schema a build would emit, less its data schemas, which
15
+ * travel as Models. ⛔ The reply carries more than a project reads, a serve location among it; a serve
16
+ * location is decided at publish and read from the response that carries it, never kept here.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+ import { parseCatalogRef } from './foundation-ref.js'
21
+
22
+ /** Where a project keeps a registered foundation's reply — null for anything but a catalog ref. */
23
+ export function registeredFoundationPath(siteRoot, ref) {
24
+ const parsed = parseCatalogRef(ref)
25
+ if (!parsed) return null
26
+ return join(siteRoot, '.uniweb', 'foundations', parsed.scope, `${parsed.name}@${parsed.version}.json`)
27
+ }
28
+
29
+ /** The kept reply for a catalog ref, or null when the project keeps none. */
30
+ export function readRegisteredFoundation(siteRoot, ref) {
31
+ const path = registeredFoundationPath(siteRoot, ref)
32
+ if (!path || !existsSync(path)) return null
33
+ try {
34
+ const reply = JSON.parse(readFileSync(path, 'utf8'))
35
+ return reply && typeof reply === 'object' ? reply : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ /** Keep a registered foundation's reply. Returns whether it was written. */
42
+ export function writeRegisteredFoundation(siteRoot, ref, reply) {
43
+ const path = registeredFoundationPath(siteRoot, ref)
44
+ if (!path || !reply || typeof reply !== 'object') return false
45
+ mkdirSync(dirname(path), { recursive: true })
46
+ writeFileSync(path, JSON.stringify(reply, null, 2) + '\n')
47
+ return true
48
+ }