@uniweb/build 1.3.6 → 1.3.7

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": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,12 +56,12 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/content-reader": "~1.4.0",
60
59
  "@uniweb/content-writer": "~1.1.2",
60
+ "@uniweb/content-reader": "~1.4.0",
61
61
  "@uniweb/projections": "~1.1.1",
62
+ "@uniweb/semantic-parser": "~1.8.1",
62
63
  "@uniweb/theming": "~1.2.0",
63
- "@uniweb/schemas": "~1.3.0",
64
- "@uniweb/semantic-parser": "~1.8.1"
64
+ "@uniweb/schemas": "~1.3.1"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.1.4"
package/src/dev/plugin.js CHANGED
@@ -26,7 +26,7 @@ import { readFile } from 'node:fs/promises'
26
26
  import { existsSync, readdirSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
28
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
- import { DEV_REBUILD_MARKER } from '../vite-foundation-plugin.js'
29
+ import { DEV_REBUILD_MARKER } from '../foundation/dev-rebuild.js'
30
30
  import { shouldRegenerateForFile } from '../generate-entry.js'
31
31
 
32
32
  /** Directories that never hold foundation source and must never be walked. */
@@ -27,6 +27,7 @@ import { resolve } from 'node:path'
27
27
  import { foundationPlugin } from '../vite-foundation-plugin.js'
28
28
  import { jsxExtensionGuard } from '../jsx-extension.js'
29
29
  import { nodeEnvDefine } from './node-env.js'
30
+ import { foundationScopeDefine } from './scope-define.js'
30
31
  import { resolveFoundationSrcDir } from '../utils/foundation-source-root.js'
31
32
  // The ONE definition. This list used to be re-typed here, and again in
32
33
  // vite-foundation-plugin.js — three hand-synced copies of "what a foundation
@@ -114,6 +115,8 @@ export async function defineFoundationConfig(options = {}) {
114
115
  // JSX compiles only in .jsx files: a .js file holding it fails with that said.
115
116
  jsxExtensionGuard(),
116
117
  nodeEnvDefine(),
118
+ // `__FOUNDATION_SCOPE__` — the scope the foundation's own `@/x` names (`scope-define.js`).
119
+ foundationScopeDefine({ foundationDir: foundationRoot }),
117
120
  tailwind && tailwindcss(),
118
121
  react(),
119
122
  svgr(),
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Marks a foundation build as a DEV-server rebuild — a leaf, so the foundation
3
+ * build plugin (`vite-foundation-plugin.js`), the dev server that injects the
4
+ * marker (`dev/plugin.js`) and the scope define (`scope-define.js`) share it.
5
+ *
6
+ * The dev server runs a real Vite `build()` of the foundation on every watched
7
+ * change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
8
+ * a dev rebuild from a shipping build — all of them say "build". An injected
9
+ * marker plugin can, and it survives the foundation's own `vite.config.js`
10
+ * being merged in, which a plugin *option* would not (the dev server does not
11
+ * construct the foundation plugin — the foundation's config file does).
12
+ *
13
+ * Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
14
+ * pass per save, and nothing in the dev loop reads it (dev never ships, and the
15
+ * SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
16
+ * And to build the foundation as a working copy, as `uniweb dev` serves it in
17
+ * bundled mode: its `@/x` stays as written (`scope-define.js`).
18
+ */
19
+ export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
20
+
21
+ /**
22
+ * Whether a Vite config carries the marker — read in a `config` hook, where
23
+ * `plugins` is still the array as written (nested, with falsy entries), or
24
+ * once resolved.
25
+ *
26
+ * @param {{ plugins?: unknown[] }} config
27
+ * @returns {boolean}
28
+ */
29
+ export function hasDevRebuildMarker(config) {
30
+ return (config?.plugins || []).flat(Infinity).some((p) => p?.name === DEV_REBUILD_MARKER)
31
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `__FOUNDATION_SCOPE__` — the scope a foundation's own `@/x` names, stamped
3
+ * into its bundle.
4
+ *
5
+ * A foundation names its own types `@/x` wherever it names one, and a call to
6
+ * the site's backend is one of those places (`useRecords({ schema: '@/track' })`,
7
+ * `@uniweb/api`). A backend holds the type under the name `register` stored it
8
+ * under, `@<scope>/x`, so the call must say the scope — the one in the
9
+ * foundation's name (`foundation-name.js`), which only its build knows. The
10
+ * bundle reads it from this constant and names the type with `qualifyRef`
11
+ * (`@uniweb/schemas/refs`), the rule `register` and a push use.
12
+ *
13
+ * Three values, and each says something:
14
+ *
15
+ * '@acme' a build of `@acme/marketing` that ships — `uniweb build`, and
16
+ * `register`, which builds through it: `@/x` is sent as `@acme/x`.
17
+ * null the foundation as a working copy — served by `uniweb dev`, or
18
+ * rebuilt by it on each save — or a name with no scope yet: `@/x`
19
+ * is sent as written, which is how a local mock holds the
20
+ * foundation's own types.
21
+ * undefined not a Uniweb build: `@uniweb/api` refuses `@/x` rather than guess.
22
+ *
23
+ * ⭐ Real-backend testing is a job for a built foundation [Diego, 2026-10-17].
24
+ * A dev rebuild never ships: it empties `dist/` and writes no `entry-ssr.js`,
25
+ * so `register` builds again before it uploads (`foundationNeedsBuild`).
26
+ *
27
+ * An author's own `define` for the key wins: it is left untouched.
28
+ */
29
+
30
+ import { readFileSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+ import { loadFoundationConfig } from '../schema.js'
33
+ import { foundationNameOf, splitFoundationName } from '../foundation-name.js'
34
+ import { resolveFoundationSrcDir } from '../utils/foundation-source-root.js'
35
+ import { hasDevRebuildMarker } from './dev-rebuild.js'
36
+
37
+ export const FOUNDATION_SCOPE_KEY = '__FOUNDATION_SCOPE__'
38
+
39
+ /**
40
+ * The define, for a build of the foundation at `foundationDir` — its own, its
41
+ * SSR twin's, or a site's that bundles it.
42
+ *
43
+ * @param {{ foundationDir?: string }} [options] - default: the Vite root
44
+ * @returns {import('vite').Plugin}
45
+ */
46
+ export function foundationScopeDefine({ foundationDir } = {}) {
47
+ return {
48
+ name: 'uniweb:foundation-scope',
49
+ async config(config, { command }) {
50
+ if (config.define && FOUNDATION_SCOPE_KEY in config.define) return
51
+ const workingCopy = command === 'serve' || hasDevRebuildMarker(config)
52
+ const scope = workingCopy ? null : await foundationScope(foundationDir ?? config.root ?? process.cwd())
53
+ return { define: { [FOUNDATION_SCOPE_KEY]: JSON.stringify(scope) } }
54
+ },
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The scope in a foundation's name — `@acme` for `@acme/marketing`, null for a
60
+ * bare name — read as the build reads the name (`buildSchema`): `main.js`'s
61
+ * `name`, else `package.json`'s (`foundationNameOf`). A `main.js` that does not
62
+ * load stops the build here, as it stops the schema.
63
+ *
64
+ * ⛔ Not `readFoundationName`, which refuses a leftover `uniweb.scope` or
65
+ * `uniweb.id` for `register`: a build does not register, and warns about them
66
+ * instead (`loadPackageJson`) — the scope in the name is what registers.
67
+ *
68
+ * @param {string} foundationDir
69
+ * @returns {Promise<string|null>}
70
+ */
71
+ export async function foundationScope(foundationDir) {
72
+ const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
73
+ const config = await loadFoundationConfig(srcDir)
74
+ const { name } = foundationNameOf({ config, pkg: readPackage(foundationDir) })
75
+ return splitFoundationName(name).scope
76
+ }
77
+
78
+ function readPackage(foundationDir) {
79
+ try {
80
+ return JSON.parse(readFileSync(join(foundationDir, 'package.json'), 'utf8')) || {}
81
+ } catch {
82
+ return {}
83
+ }
84
+ }
@@ -79,7 +79,12 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
79
79
  const model = modelOf(schema, '@/inline', (err) => (lowering = err))
80
80
  if (problems?.length || !model) warn(where, problems?.length ? problems.join('; ') : lowering?.message || 'it lowers to no model')
81
81
  models.set(where, model)
82
- } else models.set(where, null)
82
+ } else {
83
+ // A declaration the normalizer could not take at all — a retired `'@x/y/*'` ref, a value that
84
+ // is no shape — is a shape declared and not read, as much as a field map that fails.
85
+ if (decl?.problems?.length) warn(where, decl.problems.join('; '))
86
+ models.set(where, null)
87
+ }
83
88
  }
84
89
 
85
90
  return (type, tag) => {
@@ -101,9 +106,10 @@ function warnUnreadable(siteRoot, where, reason, declaration) {
101
106
  if (warned.has(once)) return
102
107
  warned.add(once)
103
108
  const who = owner ? `the ${owner} section type` : 'the foundation (main.js)'
104
- const fieldsCalledType = declaration && typeof declaration === 'object' && typeof declaration.type === 'string' && ('values' in declaration || 'fields' in declaration)
109
+ // ⚠️ `values` only: `{ type: 'object', fields: { … } }` is the whole-schema form, read as its fields.
110
+ const fieldsCalledType = declaration && typeof declaration === 'object' && typeof declaration.type === 'string' && 'values' in declaration && !('fields' in declaration)
105
111
  const hint = fieldsCalledType
106
- ? ` A data key is declared as its block's fields: { type: '${declaration.type}', ${'values' in declaration ? 'values' : 'fields'}: … } declares two fields, named "type" and "${'values' in declaration ? 'values' : 'fields'}" — name the block's fields instead ({ email: 'string', time: 'string' }).`
112
+ ? ` A data key is declared as its block's fields: { type: '${declaration.type}', values: … } declares two fields, named "type" and "values" — name the block's fields instead ({ email: 'string', time: 'string' }).`
107
113
  : /open map/.test(reason)
108
114
  ? " An open map is read when its values are records: values: { type: 'object', fields: { … } }."
109
115
  : ''
@@ -480,6 +480,11 @@ function addUnit(units, source, field, context, markup = null) {
480
480
  // Two elements can share a key and differ in markup ("Learn more" plain in one
481
481
  // place, linked in another). The first occurrence wins the field and the order,
482
482
  // but a later one may be the only carrier of markup, so fill it rather than drop it.
483
+ // ⭐ And say that the places disagree (`markupVaries`): one value serves them all, so
484
+ // `uniweb i18n init` starts such a unit with its plain words, which a plain place takes as
485
+ // they are and a link takes as its label (`merge.js::translatedInline`). ⛔ Until 2026-10-16
486
+ // nothing said so, and init seeded the markup everywhere — a header's heading became a link.
487
+ if ((markup || null) !== (units[hash].markup || null)) units[hash].markupVaries = true
483
488
  if (markup && !units[hash].markup) units[hash].markup = markup
484
489
  const existingContexts = units[hash].contexts
485
490
  const contextKey = `${context.page}:${context.section}`
package/src/i18n/merge.js CHANGED
@@ -183,6 +183,9 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
183
183
  return translated
184
184
  }
185
185
 
186
+ // The blocks that hold words, which `toPlainText` reads as one run each.
187
+ const TEXT_BLOCKS = new Set(['paragraph', 'heading', 'codeBlock'])
188
+
186
189
  /**
187
190
  * ⭐ A TRANSLATION VALUE IS INLINE MARKDOWN, AND A PLAIN-TEXT SLOT RENDERS ITS TEXT.
188
191
  *
@@ -207,8 +210,7 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
207
210
  *
208
211
  * ⛔ Do not add the field or the role to the hash. Genuine wording divergence by
209
212
  * context is what `overrides` is for (`lookupTranslation`); representation
210
- * divergence is this function's job — and `translatedInline`'s, for an element whose
211
- * source has no link.
213
+ * divergence is this function's job.
212
214
  *
213
215
  * ⭐ One rule for both lanes: a push sends a plain-text slot's translations through it too
214
216
  * (`uwx/locale-sync.js::plainTextTargets`). ⛔ Until 2026-10-16 only the build did, so a host
@@ -220,11 +222,16 @@ export function toPlainText(value) {
220
222
  }
221
223
  try {
222
224
  const doc = markdownToProseMirror(value)
223
- const collect = (nodes) =>
225
+ // A block's words run together; blocks are apart. ⛔ Until 2026-10-16 every level joined with
226
+ // nothing, so a value of two paragraphs read as one word at the seam ("…17hFermé…").
227
+ const inline = (nodes) =>
228
+ (nodes || []).map((n) => (n.type === 'text' ? n.text || '' : n.type === 'hardBreak' ? ' ' : inline(n.content))).join('')
229
+ const blocks = (nodes) =>
224
230
  (nodes || [])
225
- .map((n) => (n.type === 'text' ? n.text || '' : n.type === 'hardBreak' ? ' ' : collect(n.content)))
226
- .join('')
227
- const text = collect(doc?.content).trim()
231
+ .map((n) => (TEXT_BLOCKS.has(n.type) ? inline(n.content) : n.type === 'text' ? n.text || '' : blocks(n.content)))
232
+ .filter((t) => t.trim())
233
+ .join(' ')
234
+ const text = blocks(doc?.content).trim()
228
235
  return text || value
229
236
  } catch {
230
237
  return value
@@ -387,19 +394,18 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
387
394
  * its link: the button became a plain paragraph, or vanished from a section that renders only
388
395
  * links. Locale files keyed before whole-element keying hold exactly such labels.
389
396
  *
390
- * ⭐ And a link is the SOURCE's to have: a value that writes one, given to an element whose source
391
- * has none, gives it its words alone. One text is one unit wherever it appears (`hash.js`), so its
392
- * value serves a heading that says it and a link that says it alike — `Faculty of Science` in a
393
- * header, `[Faculty of Science](/)` in a list — and the link belongs to the place that has one.
394
- * Representation is the consumer's, as a title's is (`toPlainText`). ⛔ Until 2026-10-16 the value
395
- * was taken as written: `uniweb i18n init` seeds a unit with the markup of whichever occurrence
396
- * carries some, so an untouched seeded locale turned the heading into a link — and the parser,
397
- * which reads a heading made only of a link as a link, left its section without a title.
397
+ * A value that writes a link is taken as written, re-targeted or not — and so where the source
398
+ * has none: an author may add a link in one language in the app, and a push carries back what a
399
+ * pull wrote. So is a plain value for an element that mixes words and links, since which words
400
+ * were linked cannot be known; `uniweb i18n status` reports that loss (`translationKeepsMarkup`).
398
401
  *
399
- * A value that writes a link, given to an element whose source has one, is taken as written,
400
- * re-targeted or not. So is a plain value for an element that mixes words and links, since which
401
- * words were linked cannot be known; `uniweb i18n status` reports that loss
402
- * (`translationKeepsMarkup`). Emphasis is the translator's: a value's bold or italic stays.
402
+ * ⭐ One text is one unit wherever it appears (`hash.js`), so a heading that says `Faculty of
403
+ * Science` and a link that says it share a value. Its places disagreeing on markup is the seed's to
404
+ * handle, not this function's: extraction marks such a unit (`markupVaries`, `extract.js`), and
405
+ * `uniweb i18n init` starts it with its plain words — which the heading takes as they are and the
406
+ * link takes as its label. ⛔ @uniweb/build 1.3.6 (2026-10-16) instead dropped a value's link from
407
+ * any element whose source had none: it fixed the seeded heading, and lost a link an author had
408
+ * added on the next push. Reverted the same day.
403
409
  *
404
410
  * @param {Array} source - the element's source inline content
405
411
  * @param {string} value - the translation, inline markdown
@@ -408,36 +414,7 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
408
414
  export function translatedInline(source, value) {
409
415
  const fragment = inlineMarkdownToFragment(value)
410
416
  if (!fragment) return null
411
- const labels = asLinkLabels(source, fragment, value)
412
- if (labels) return labels
413
- return hasLink(source) ? fragment : withoutLinks(fragment)
414
- }
415
-
416
- // Whether inline content holds a link — on its text, or inside an inline wrapper.
417
- function hasLink(nodes) {
418
- return (nodes || []).some((node) => Boolean(linkMarkOf(node)) || hasLink(node?.content))
419
- }
420
-
421
- // Inline content without its links: the words, and every other mark, kept — runs that end up
422
- // alike joined, as the reader would have written them.
423
- function withoutLinks(nodes) {
424
- const out = []
425
- for (const node of nodes) {
426
- let next = node
427
- if (node?.content) next = { ...node, content: withoutLinks(node.content) }
428
- else if (linkMarkOf(node)) {
429
- const { marks, ...rest } = node
430
- const kept = marks.filter((mark) => mark.type !== 'link')
431
- next = kept.length ? { ...rest, marks: kept } : rest
432
- }
433
- const last = out[out.length - 1]
434
- if (last?.type === 'text' && next?.type === 'text' && JSON.stringify(last.marks || []) === JSON.stringify(next.marks || [])) {
435
- out[out.length - 1] = { ...last, text: last.text + next.text }
436
- } else {
437
- out.push(next)
438
- }
439
- }
440
- return out
417
+ return asLinkLabels(source, fragment, value) || fragment
441
418
  }
442
419
 
443
420
  /**
@@ -540,8 +517,10 @@ export function linkLabels(source, target) {
540
517
  return labels.length === links.length ? labels : null
541
518
  }
542
519
 
520
+ // A link's mark — `link`, or `button`, which is a link drawn as a button (`[Go](/x){button}`).
521
+ // ⛔ Until 2026-10-16 only `link`: a `{button}` translated by its label alone lost the button.
543
522
  function linkMarkOf(node) {
544
- return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
523
+ return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link' || mark.type === 'button') || null : null
545
524
  }
546
525
 
547
526
  function sameMark(a, b) {
@@ -626,7 +605,7 @@ function translateDataBlocks(doc, context, translations, fallbackToSource, model
626
605
  const data = node.attrs?.data
627
606
  if (data && typeof data === 'object') {
628
607
  visitDataBlockStrings(data, (value) => {
629
- const translated = asDataString(value, lookupTranslation(value, context, translations, fallbackToSource))
608
+ const translated = lookupTranslation(value, context, translations, fallbackToSource)
630
609
  if (translated !== value) changed = true
631
610
  return translated
632
611
  }, modelFor(node.attrs?.tag))
@@ -640,18 +619,6 @@ function translateDataBlocks(doc, context, translations, fallbackToSource, model
640
619
  return changed
641
620
  }
642
621
 
643
- /**
644
- * ⭐ A data block's string written as TEXT takes its translation as text — the rule an element
645
- * with no link follows (`translatedInline`), for a string a component renders as it is: the markup a
646
- * translation carries for a link elsewhere is not this string's. ⛔ Until 2026-10-16 it was taken
647
- * as written, and a label shared with a link rendered as `[Sign in](/sign-in)`. A string written
648
- * with markdown keeps its translation as written.
649
- */
650
- function asDataString(source, translated) {
651
- if (typeof translated !== 'string' || translated === source) return translated
652
- return toPlainText(source) === source ? toPlainText(translated) : translated
653
- }
654
-
655
622
  /**
656
623
  * Resolve ONE ProseMirror content doc for a single target locale: a deep clone of
657
624
  * the source doc with each whole-element translated (inline content replaced from
@@ -31,6 +31,7 @@ import {
31
31
  } from '../generate-entry.js'
32
32
  import { importMapPlugin } from '../import-map-plugin.js'
33
33
  import { jsxExtensionGuard } from '../jsx-extension.js'
34
+ import { foundationScopeDefine } from '../foundation/scope-define.js'
34
35
  import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
35
36
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
36
37
  import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
@@ -333,6 +334,11 @@ export async function defineSiteConfig(options = {}) {
333
334
  foundationPath: foundationInfo.path // For loading foundation theme vars
334
335
  }),
335
336
 
337
+ // A bundled foundation is compiled here, so its `__FOUNDATION_SCOPE__` is set here: its
338
+ // scope in a build, null under `uniweb dev` (`foundation/scope-define.js`). A foundation
339
+ // loaded by URL carries its own, set by its build.
340
+ !isRuntimeMode && foundationInfo.path && foundationScopeDefine({ foundationDir: foundationInfo.path }),
341
+
336
342
  // Foundation dev server (only in runtime mode with local foundation)
337
343
  isRuntimeMode &&
338
344
  foundationInfo.type === 'local' &&
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { parseOptionsRef, FIELD_RULES } from '@uniweb/schemas/format'
25
+ import { qualifyRef } from '@uniweb/schemas/refs'
25
26
 
26
27
  // Text kinds carry per-locale values when localized.
27
28
  const TEXT_KINDS = new Set(['string', 'text'])
@@ -690,9 +691,5 @@ function sectionAttrsFromField(field) {
690
691
  function defaultResolver(ownName) {
691
692
  const slash = ownName.indexOf('/')
692
693
  const org = ownName[0] === '@' && slash > 1 ? ownName.slice(1, slash) : ''
693
- return (ref) => {
694
- if (typeof ref !== 'string') return ref
695
- if (ref.startsWith('@/')) return `@${org}/${ref.slice(2)}`
696
- return ref
697
- }
694
+ return (ref) => qualifyRef(ref, org)
698
695
  }
@@ -239,17 +239,6 @@ export function contextKey(context) {
239
239
  return context?.key ?? `${context?.page}:${context?.section}`
240
240
  }
241
241
 
242
- /**
243
- * Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
244
- * of localized strings") into per-locale form by localizing each element. Container
245
- * shape is preserved so the round-trip is a fixed point: an array authored as an
246
- * array stays an array of `{ <lang>: value }` maps; a bare scalar (the degenerate
247
- * single-value authoring) wraps as one localized scalar. null/undefined → undefined.
248
- *
249
- * @param {*} value
250
- * @param {string} sourceLocale
251
- * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
252
- */
253
242
  /**
254
243
  * A localized PLAIN-TEXT value — a page's title, label, description, keywords and Open Graph
255
244
  * texts, and the site's — with each translation as text, as the build renders it
@@ -274,6 +263,17 @@ export function plainTextTargets(value, sourceLocale) {
274
263
  return out
275
264
  }
276
265
 
266
+ /**
267
+ * Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
268
+ * of localized strings") into per-locale form by localizing each element. Container
269
+ * shape is preserved so the round-trip is a fixed point: an array authored as an
270
+ * array stays an array of `{ <lang>: value }` maps; a bare scalar (the degenerate
271
+ * single-value authoring) wraps as one localized scalar. null/undefined → undefined.
272
+ *
273
+ * @param {*} value
274
+ * @param {string} sourceLocale
275
+ * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
276
+ */
277
277
  export function localizeScalarList(value, sourceLocale, translations, context = null) {
278
278
  if (value == null) return undefined
279
279
  if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations, context)
@@ -46,7 +46,8 @@ import { dataKeyTypes } from './data-key-types.js'
46
46
  import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
47
47
  import { folderYmlPath } from '../site/records-config.js'
48
48
  import { contentBodyTarget } from './record-layout.js'
49
- import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
49
+ import { qualifyRef } from '@uniweb/schemas/refs'
50
+ import { unresolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
50
51
  import { parseCatalogRef } from '../site/foundation-ref.js'
51
52
  import { renderEntityDocument } from './backfill.js'
52
53
  import { labelForFile } from '../site/entry-label.js'
@@ -216,13 +217,13 @@ function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes =
216
217
  const at = [first]
217
218
  const keyType = !own?.has(first) ? keyTypes?.get(first) : null
218
219
  if (
219
- resolveSelfScope(schemaForPoolDirs(at), scope) === model ||
220
- (keyType && resolveSelfScope(keyType, scope) === model)
220
+ qualifyRef(schemaForPoolDirs(at), scope) === model ||
221
+ (keyType && qualifyRef(keyType, scope) === model)
221
222
  ) {
222
223
  dirs.push(join(recordsRoot, first))
223
224
  }
224
225
  for (const second of visible(join(recordsRoot, first))) {
225
- if (resolveSelfScope(schemaForPoolDirs([first, second]), scope) === model) {
226
+ if (qualifyRef(schemaForPoolDirs([first, second]), scope) === model) {
226
227
  dirs.push(join(recordsRoot, first, second))
227
228
  }
228
229
  }
@@ -452,7 +453,7 @@ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
452
453
  for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
453
454
  if (schema !== null || own?.has(name)) continue
454
455
  const type = keyTypes.get(name)
455
- const model = type ? resolveSelfScope(type, scope) : null
456
+ const model = type ? qualifyRef(type, scope) : null
456
457
  if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
457
458
  }
458
459
  return out
@@ -59,7 +59,8 @@ import {
59
59
  import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isOpenMapSection, openMapEntries } from './data-schema.js'
60
60
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
61
61
  import { emitEntitySyncPackage } from './entity-document.js'
62
- import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
62
+ import { qualifyRef } from '@uniweb/schemas/refs'
63
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
63
64
  import { sha256Hex, toJsonBuffer } from './manifest.js'
64
65
  import { isUuid } from './uuid.js'
65
66
  import { markdownToProseMirror } from '@uniweb/content-reader'
@@ -823,10 +824,7 @@ function resolveDeclaration(schema, modelName) {
823
824
  const dataSchemas = schema?.dataSchemas || {}
824
825
  const m = /^@([^/]+)\/(.+)$/.exec(modelName)
825
826
  const org = m ? m[1] : null
826
- const resolveName = (ref) =>
827
- typeof ref === 'string' && ref.startsWith('@/') && org
828
- ? `@${org}/${ref.slice(2)}`
829
- : ref
827
+ const resolveName = (ref) => qualifyRef(ref, org)
830
828
  for (const [ref, normalized] of Object.entries(dataSchemas)) {
831
829
  if (resolveName(ref) === modelName) {
832
830
  return toDataSchemaDeclaration(normalized, { name: modelName, resolveName })
@@ -973,7 +971,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
973
971
  opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
974
972
  const warnedUnscoped = new Set()
975
973
  const modelFor = (schema, where) => {
976
- const modelName = resolveSelfScope(schema, scope)
974
+ const modelName = qualifyRef(schema, scope)
977
975
  // Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
978
976
  // throwing (a `status` probe on a never-registered foundation must still count),
979
977
  // but say so: the backend's refusal names a missing Model and cannot name this cause.
@@ -1278,8 +1276,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1278
1276
  ? !unresolved.has(schema)
1279
1277
  : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema))))
1280
1278
  if (!resolved) {
1281
- if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
1282
- schemaless.push({ name, model: resolveSelfScope(schema, scope) })
1279
+ if (decl.schemaExplicit) throw unresolvedExplicit(qualifyRef(schema, scope), name)
1280
+ schemaless.push({ name, model: qualifyRef(schema, scope) })
1283
1281
  continue
1284
1282
  }
1285
1283
  if (!hasRecords) {
@@ -28,6 +28,7 @@ import { join } from 'node:path'
28
28
 
29
29
  import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
30
30
  import { parseOptionsRef } from '@uniweb/schemas/format'
31
+ import { qualifyRef } from '@uniweb/schemas/refs'
31
32
  import { toDataSchemaDeclaration, optionsSectionPath } from './data-schema.js'
32
33
  import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
33
34
 
@@ -146,8 +147,7 @@ function buildDataSchemaEntities(dataSchemas, scope) {
146
147
  // `scope` ('@acme' or 'acme') resolves a defined schema's own `@/x` name to a
147
148
  // concrete `@acme/x`. With no scope, `@/x` passes through (local preview).
148
149
  const org = scope ? String(scope).replace(/^@/, '').replace(/\/.*$/, '') : null
149
- const scoped = (ref) =>
150
- org && typeof ref === 'string' && ref.startsWith('@/') ? `@${org}/${ref.slice(2)}` : ref
150
+ const scoped = (ref) => qualifyRef(ref, org)
151
151
 
152
152
  const resolveOptions = makeOptionsResolver(dataSchemas, scoped)
153
153
  const entities = Object.entries(dataSchemas)
@@ -7,7 +7,9 @@
7
7
  // the entity store at restore, and a records service answering a hosted page's
8
8
  // query.
9
9
  //
10
- // ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH, and its inverse:
10
+ // ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH — `qualifyRef`
11
+ // (`@uniweb/schemas/refs`, the rule `register` and a foundation's own calls to its
12
+ // backend use too) with the scope `siteSelfScope` reads — and its inverse here:
11
13
  //
12
14
  // - a record's `$schema` `records.js::buildRecordEntities`
13
15
  // - a query's `schema` `site.js::queriesNested` (the `queries` Section)
@@ -51,24 +53,8 @@ export function bareOrg(scope) {
51
53
  }
52
54
 
53
55
  /**
54
- * `@/x` → `@<scope>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
55
- * does `@/x` when no scope is known — callers that ship an unresolved alias say so
56
- * themselves (`buildRecordEntities` warns per query).
57
- *
58
- * @param {unknown} ref
59
- * @param {unknown} scope - the site's foundation's scope (`siteSelfScope`)
60
- * @returns {unknown}
61
- */
62
- export function resolveSelfScope(ref, scope) {
63
- const handle = bareOrg(scope)
64
- return typeof ref === 'string' && ref.startsWith('@/') && handle
65
- ? `@${handle}/${ref.slice(2)}`
66
- : ref
67
- }
68
-
69
- /**
70
- * The inverse, for a pull: `@<scope>/x` → `@/x` — when `x` is one of the foundation's
71
- * OWN data schemas.
56
+ * The inverse of `qualifyRef`, for a pull: `@<scope>/x` → `@/x` — when `x` is one
57
+ * of the foundation's OWN data schemas.
72
58
  *
73
59
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
74
60
  * on both ends. A record authored under `records/article/` comes back as
package/src/uwx/site.js CHANGED
@@ -87,7 +87,8 @@ import {
87
87
  refuseUnreadableServices
88
88
  } from './services-request.js'
89
89
  import { resolveQueriesConfig } from './queries-config.js'
90
- import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
90
+ import { qualifyRef } from '@uniweb/schemas/refs'
91
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
91
92
  import { siteContentDirs } from './site-dirs.js'
92
93
 
93
94
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
@@ -1055,7 +1056,7 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1055
1056
  // and a store that checks a query's `schema` against the site's declared types refused the
1056
1057
  // whole content push for it. An explicit `schema:` that does not resolve never reaches here:
1057
1058
  // the records lane refuses it.
1058
- if (!schemaless?.has(name)) setIf(data, 'schema', resolveSelfScope(schema, scope))
1059
+ if (!schemaless?.has(name)) setIf(data, 'schema', qualifyRef(schema, scope))
1059
1060
  // ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
1060
1061
  // one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
1061
1062
  // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
@@ -18,6 +18,8 @@ import { DEFAULT_EXTERNALS } from './import-map-plugin.js'
18
18
  import { deriveSupports } from './foundation/derive-supports.js'
19
19
  import { proseWithoutStyles, PROSE_WITHOUT_STYLES_WARNING } from './foundation/prose-styles.js'
20
20
  import { nodeEnvDefine } from './foundation/node-env.js'
21
+ import { foundationScopeDefine } from './foundation/scope-define.js'
22
+ import { DEV_REBUILD_MARKER, hasDevRebuildMarker } from './foundation/dev-rebuild.js'
21
23
 
22
24
  // Foundations already told that their prose has no stylesheet — once per process, so a dev
23
25
  // server's rebuild on every save does not repeat it.
@@ -348,7 +350,7 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
348
350
  }
349
351
  const react = (await import('@vitejs/plugin-react')).default
350
352
  const svgr = (await import('vite-plugin-svgr')).default
351
- plugins.push(react(), svgr(), nodeEnvDefine())
353
+ plugins.push(react(), svgr(), nodeEnvDefine(), foundationScopeDefine({ foundationDir: foundationRoot }))
352
354
 
353
355
  await viteBuild({
354
356
  root: foundationRoot,
@@ -389,21 +391,8 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
389
391
  }
390
392
  }
391
393
 
392
- /**
393
- * Marks a foundation build as a DEV-server rebuild.
394
- *
395
- * The dev server runs a real Vite `build()` of the foundation on every watched
396
- * change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
397
- * a dev rebuild from a shipping build — all of them say "build". An injected
398
- * marker plugin can, and it survives the foundation's own `vite.config.js`
399
- * being merged in, which a plugin *option* would not (the dev server does not
400
- * construct the foundation plugin — the foundation's config file does).
401
- *
402
- * Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
403
- * pass per save, and nothing in the dev loop reads it (dev never ships, and the
404
- * SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
405
- */
406
- export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
394
+ // The dev-rebuild marker lives in a leaf (`foundation/dev-rebuild.js`); exported here as before.
395
+ export { DEV_REBUILD_MARKER }
407
396
 
408
397
  /**
409
398
  * Vite plugin for foundation builds
@@ -440,7 +429,7 @@ export function foundationBuildPlugin(options = {}) {
440
429
  resolvedOutDir = config.build.outDir
441
430
  resolvedRoot = config.root
442
431
  isProduction = config.mode === 'production'
443
- isDevRebuild = (config.plugins || []).some((p) => p?.name === DEV_REBUILD_MARKER)
432
+ isDevRebuild = hasDevRebuildMarker(config)
444
433
  },
445
434
 
446
435
  async writeBundle(_options, bundle) {