@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 +4 -4
- package/src/dev/plugin.js +1 -1
- package/src/foundation/config.js +3 -0
- package/src/foundation/dev-rebuild.js +31 -0
- package/src/foundation/scope-define.js +84 -0
- package/src/i18n/data-models.js +9 -3
- package/src/i18n/extract.js +5 -0
- package/src/i18n/merge.js +29 -62
- package/src/site/config.js +6 -0
- package/src/uwx/data-schema.js +2 -5
- package/src/uwx/locale-sync.js +11 -11
- package/src/uwx/records-project.js +6 -5
- package/src/uwx/records.js +6 -8
- package/src/uwx/registry-package.js +2 -2
- package/src/uwx/self-scope.js +5 -19
- package/src/uwx/site.js +3 -2
- package/src/vite-foundation-plugin.js +6 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.3.
|
|
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.
|
|
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 '../
|
|
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. */
|
package/src/foundation/config.js
CHANGED
|
@@ -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
|
+
}
|
package/src/i18n/data-models.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}',
|
|
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
|
: ''
|
package/src/i18n/extract.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
226
|
-
.
|
|
227
|
-
|
|
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
|
-
*
|
|
391
|
-
* has none
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
package/src/site/config.js
CHANGED
|
@@ -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' &&
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -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
|
}
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
220
|
-
(keyType &&
|
|
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 (
|
|
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 ?
|
|
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
|
package/src/uwx/records.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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(
|
|
1282
|
-
schemaless.push({ name, model:
|
|
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)
|
package/src/uwx/self-scope.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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 {
|
|
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',
|
|
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
|
-
|
|
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
|
|
432
|
+
isDevRebuild = hasDevRebuildMarker(config)
|
|
444
433
|
},
|
|
445
434
|
|
|
446
435
|
async writeBundle(_options, bundle) {
|