@uniweb/build 1.3.5 → 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 +46 -4
- package/src/i18n/extract.js +5 -0
- package/src/i18n/merge.js +34 -10
- package/src/i18n/records.js +3 -2
- package/src/site/config.js +6 -0
- package/src/uwx/data-schema.js +2 -5
- package/src/uwx/locale-sync.js +25 -1
- package/src/uwx/records-project.js +11 -7
- package/src/uwx/records.js +8 -9
- package/src/uwx/registry-package.js +2 -2
- package/src/uwx/self-scope.js +5 -19
- package/src/uwx/site-diff.js +4 -4
- package/src/uwx/site-project.js +5 -3
- package/src/uwx/site.js +12 -8
- package/src/uwx/source-locale.js +57 -0
- package/src/uwx/sync-package.js +5 -1
- 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-writer": "~1.1.2",
|
|
59
60
|
"@uniweb/content-reader": "~1.4.0",
|
|
60
61
|
"@uniweb/projections": "~1.1.1",
|
|
61
|
-
"@uniweb/content-writer": "~1.1.2",
|
|
62
|
-
"@uniweb/schemas": "~1.3.0",
|
|
63
62
|
"@uniweb/semantic-parser": "~1.8.1",
|
|
64
|
-
"@uniweb/theming": "~1.2.0"
|
|
63
|
+
"@uniweb/theming": "~1.2.0",
|
|
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
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* normalizer (`@uniweb/schemas/foundation::normalizeData`); a ref resolves as a query's records
|
|
12
12
|
* resolve theirs (`resolveRecordSchemas`). A key with no shape, or a ref that does not resolve, has
|
|
13
13
|
* no model, and its blocks keep the heuristic.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ A key that DOES declare a shape, which the translation step cannot read, is said aloud
|
|
16
|
+
* (`warnUnreadable`): its blocks fall back to the heuristic, which never offers a field named
|
|
17
|
+
* `email`, `time`, `href` and the like. ⛔ Until 2026-10-16 that fall was silent — measured on a
|
|
18
|
+
* block of contact labels declared `{ type: 'object', values: { type: 'string' } }`, whose `email`
|
|
19
|
+
* and `time` labels were never offered for translation.
|
|
14
20
|
*/
|
|
15
21
|
import { normalizeData } from '@uniweb/schemas/foundation'
|
|
16
22
|
import { foundationSections, resolveRecordSchemas } from '../site/queries-config.js'
|
|
@@ -27,6 +33,9 @@ export const NO_DATA_MODELS = () => null
|
|
|
27
33
|
* @returns {Promise<(type: string, tag: string) => object|null>}
|
|
28
34
|
*/
|
|
29
35
|
export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
36
|
+
// Each key's declaration as written, to say what was meant when its shape cannot be read.
|
|
37
|
+
const raw = new Map()
|
|
38
|
+
const warn = (where, reason) => warnUnreadable(siteRoot, where, reason, raw.get(where))
|
|
30
39
|
let sections
|
|
31
40
|
try {
|
|
32
41
|
sections = await foundationSections(siteRoot, siteYml)
|
|
@@ -39,7 +48,10 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
39
48
|
const declared = new Map()
|
|
40
49
|
const declare = (owner, data) => {
|
|
41
50
|
const normalized = normalizeData(data, { strict: false })
|
|
42
|
-
for (const [key, decl] of Object.entries(normalized || {}))
|
|
51
|
+
for (const [key, decl] of Object.entries(normalized || {})) {
|
|
52
|
+
declared.set(at(owner, key), decl)
|
|
53
|
+
raw.set(at(owner, key), data?.[key])
|
|
54
|
+
}
|
|
43
55
|
}
|
|
44
56
|
for (const [name, entry] of Object.entries(sections)) {
|
|
45
57
|
if (!entry || typeof entry !== 'object' || name === 'dataSchemas' || name === '_layouts') continue
|
|
@@ -62,9 +74,17 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
62
74
|
for (const [where, decl] of declared) {
|
|
63
75
|
if (decl.kind === 'schema') models.set(where, modelOf(resolved[decl.schema] || null, decl.schema))
|
|
64
76
|
else if (decl.kind === 'fields') {
|
|
65
|
-
const { kind: _kind, problems
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
const { kind: _kind, problems, ...schema } = decl
|
|
78
|
+
let lowering = null
|
|
79
|
+
const model = modelOf(schema, '@/inline', (err) => (lowering = err))
|
|
80
|
+
if (problems?.length || !model) warn(where, problems?.length ? problems.join('; ') : lowering?.message || 'it lowers to no model')
|
|
81
|
+
models.set(where, model)
|
|
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
|
+
}
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
return (type, tag) => {
|
|
@@ -75,3 +95,25 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
const at = (owner, key) => `${owner}\u0000${key}`
|
|
98
|
+
|
|
99
|
+
// Each unreadable shape once per site per process: extraction, the build, a push and a pull all
|
|
100
|
+
// read the same declarations.
|
|
101
|
+
const warned = new Set()
|
|
102
|
+
|
|
103
|
+
function warnUnreadable(siteRoot, where, reason, declaration) {
|
|
104
|
+
const [owner, key] = where.split('\u0000')
|
|
105
|
+
const once = `${siteRoot}\u0000${where}\u0000${reason}`
|
|
106
|
+
if (warned.has(once)) return
|
|
107
|
+
warned.add(once)
|
|
108
|
+
const who = owner ? `the ${owner} section type` : 'the foundation (main.js)'
|
|
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)
|
|
111
|
+
const hint = fieldsCalledType
|
|
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' }).`
|
|
113
|
+
: /open map/.test(reason)
|
|
114
|
+
? " An open map is read when its values are records: values: { type: 'object', fields: { … } }."
|
|
115
|
+
: ''
|
|
116
|
+
console.warn(
|
|
117
|
+
`Warning: ${who} declares data key "${key}" with a shape the translation step cannot read (${reason}), so its blocks' strings are guessed — a field named email, time, href or the like is never offered for translation.${hint}`
|
|
118
|
+
)
|
|
119
|
+
}
|
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
|
*
|
|
@@ -208,18 +211,27 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
|
|
|
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
213
|
* divergence is this function's job.
|
|
214
|
+
*
|
|
215
|
+
* ⭐ One rule for both lanes: a push sends a plain-text slot's translations through it too
|
|
216
|
+
* (`uwx/locale-sync.js::plainTextTargets`). ⛔ Until 2026-10-16 only the build did, so a host
|
|
217
|
+
* was handed `[Sign in](/sign-in)` as a page's French title.
|
|
211
218
|
*/
|
|
212
|
-
function toPlainText(value) {
|
|
219
|
+
export function toPlainText(value) {
|
|
213
220
|
if (typeof value !== 'string' || !value.includes('[') && !value.includes('*') && !value.includes('`') && !value.includes('_')) {
|
|
214
221
|
return value
|
|
215
222
|
}
|
|
216
223
|
try {
|
|
217
224
|
const doc = markdownToProseMirror(value)
|
|
218
|
-
|
|
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) =>
|
|
219
230
|
(nodes || [])
|
|
220
|
-
.map((n) => (n.type
|
|
221
|
-
.
|
|
222
|
-
|
|
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()
|
|
223
235
|
return text || value
|
|
224
236
|
} catch {
|
|
225
237
|
return value
|
|
@@ -239,7 +251,8 @@ function translateSiteMeta(config, translations, fallbackToSource) {
|
|
|
239
251
|
if (!seo || typeof seo !== 'object') return
|
|
240
252
|
for (const key of ['ogTitle', 'ogDescription']) {
|
|
241
253
|
if (typeof seo[key] === 'string' && seo[key]) {
|
|
242
|
-
|
|
254
|
+
// A plain-text slot, as a page's are (`translatePageMeta`). ⛔ Not flattened until 2026-10-16.
|
|
255
|
+
seo[key] = toPlainText(lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource))
|
|
243
256
|
}
|
|
244
257
|
}
|
|
245
258
|
}
|
|
@@ -381,9 +394,18 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
|
|
|
381
394
|
* its link: the button became a plain paragraph, or vanished from a section that renders only
|
|
382
395
|
* links. Locale files keyed before whole-element keying hold exactly such labels.
|
|
383
396
|
*
|
|
384
|
-
* A value that writes a link is taken as written, re-targeted or not
|
|
385
|
-
*
|
|
386
|
-
*
|
|
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`).
|
|
401
|
+
*
|
|
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.
|
|
387
409
|
*
|
|
388
410
|
* @param {Array} source - the element's source inline content
|
|
389
411
|
* @param {string} value - the translation, inline markdown
|
|
@@ -495,8 +517,10 @@ export function linkLabels(source, target) {
|
|
|
495
517
|
return labels.length === links.length ? labels : null
|
|
496
518
|
}
|
|
497
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.
|
|
498
522
|
function linkMarkOf(node) {
|
|
499
|
-
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
|
|
500
524
|
}
|
|
501
525
|
|
|
502
526
|
function sameMark(a, b) {
|
package/src/i18n/records.js
CHANGED
|
@@ -187,11 +187,12 @@ function isFieldTranslatable(fieldDef) {
|
|
|
187
187
|
* @param {string} ref - the ref it was resolved from
|
|
188
188
|
* @returns {Object|null} the lowered declaration, or null when there is none
|
|
189
189
|
*/
|
|
190
|
-
export function modelOf(schema, ref) {
|
|
190
|
+
export function modelOf(schema, ref, onError = null) {
|
|
191
191
|
if (!schema) return null
|
|
192
192
|
try {
|
|
193
193
|
return toDataSchemaDeclaration(schema, { name: ref || '@/record', resolveName: (r) => r })
|
|
194
|
-
} catch {
|
|
194
|
+
} catch (err) {
|
|
195
|
+
onError?.(err) // a caller that can say why — `data-models.js`, for a declared block shape
|
|
195
196
|
return null // a schema a push refuses is refused there, and the build keeps its heuristic
|
|
196
197
|
}
|
|
197
198
|
}
|
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
|
@@ -24,7 +24,7 @@ import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-w
|
|
|
24
24
|
import { computeHash } from '../i18n/hash.js'
|
|
25
25
|
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
26
|
import { visitDataBlockStrings } from '../i18n/data-strings.js'
|
|
27
|
-
import { resolveDocForLocale, translatedInline, linkLabels } from '../i18n/merge.js'
|
|
27
|
+
import { resolveDocForLocale, translatedInline, linkLabels, toPlainText } from '../i18n/merge.js'
|
|
28
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
29
|
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
30
30
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
@@ -239,6 +239,30 @@ export function contextKey(context) {
|
|
|
239
239
|
return context?.key ?? `${context?.page}:${context?.section}`
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* A localized PLAIN-TEXT value — a page's title, label, description, keywords and Open Graph
|
|
244
|
+
* texts, and the site's — with each translation as text, as the build renders it
|
|
245
|
+
* (`i18n/merge.js::toPlainText`). The source stays as the author wrote it, as the build leaves it.
|
|
246
|
+
* ⛔ Until 2026-10-16 a push sent a translation's markdown as it was, so a unit shared by a page's
|
|
247
|
+
* title and a link to the page reached a host as `[Sign in](/sign-in)`.
|
|
248
|
+
*
|
|
249
|
+
* @param {object|Array|undefined} value - a `{ locale: text }` map, or a list of them
|
|
250
|
+
* @param {string} sourceLocale
|
|
251
|
+
*/
|
|
252
|
+
export function plainTextTargets(value, sourceLocale) {
|
|
253
|
+
if (Array.isArray(value)) return value.map((item) => plainTextTargets(item, sourceLocale))
|
|
254
|
+
if (!value || typeof value !== 'object') return value
|
|
255
|
+
let out = value
|
|
256
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
257
|
+
if (locale === sourceLocale || typeof text !== 'string') continue
|
|
258
|
+
const plain = toPlainText(text)
|
|
259
|
+
if (plain === text) continue
|
|
260
|
+
if (out === value) out = { ...value }
|
|
261
|
+
out[locale] = plain
|
|
262
|
+
}
|
|
263
|
+
return out
|
|
264
|
+
}
|
|
265
|
+
|
|
242
266
|
/**
|
|
243
267
|
* Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
|
|
244
268
|
* of localized strings") into per-locale form by localizing each element. Container
|
|
@@ -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'
|
|
@@ -54,6 +55,7 @@ import { LINK_MODEL, writeLinkRecords } from './link-records.js'
|
|
|
54
55
|
import { FILE_MODEL, filesToPull } from './file-records.js'
|
|
55
56
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
56
57
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
58
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
57
59
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
58
60
|
|
|
59
61
|
// Single-record source extensions we scan + place (BibTeX is multi-record → out).
|
|
@@ -215,13 +217,13 @@ function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes =
|
|
|
215
217
|
const at = [first]
|
|
216
218
|
const keyType = !own?.has(first) ? keyTypes?.get(first) : null
|
|
217
219
|
if (
|
|
218
|
-
|
|
219
|
-
(keyType &&
|
|
220
|
+
qualifyRef(schemaForPoolDirs(at), scope) === model ||
|
|
221
|
+
(keyType && qualifyRef(keyType, scope) === model)
|
|
220
222
|
) {
|
|
221
223
|
dirs.push(join(recordsRoot, first))
|
|
222
224
|
}
|
|
223
225
|
for (const second of visible(join(recordsRoot, first))) {
|
|
224
|
-
if (
|
|
226
|
+
if (qualifyRef(schemaForPoolDirs([first, second]), scope) === model) {
|
|
225
227
|
dirs.push(join(recordsRoot, first, second))
|
|
226
228
|
}
|
|
227
229
|
}
|
|
@@ -451,7 +453,7 @@ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
|
|
|
451
453
|
for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
|
|
452
454
|
if (schema !== null || own?.has(name)) continue
|
|
453
455
|
const type = keyTypes.get(name)
|
|
454
|
-
const model = type ?
|
|
456
|
+
const model = type ? qualifyRef(type, scope) : null
|
|
455
457
|
if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
|
|
456
458
|
}
|
|
457
459
|
return out
|
|
@@ -527,7 +529,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
527
529
|
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
|
|
528
530
|
* `file` is where the organization lives, as the author would write it
|
|
529
531
|
*/
|
|
530
|
-
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale =
|
|
532
|
+
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = siteSourceLocale(siteRoot) }) {
|
|
531
533
|
const warnings = []
|
|
532
534
|
const { abs, rel: file } = folderYmlPath(siteRoot)
|
|
533
535
|
if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
|
|
@@ -625,7 +627,9 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
625
627
|
* @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
|
|
626
628
|
*/
|
|
627
629
|
export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
|
|
628
|
-
|
|
630
|
+
// The site's language, as its `site.yml` says it — written by the pull of the site's content, which
|
|
631
|
+
// runs first (`./source-locale.js`).
|
|
632
|
+
const { resolveDeclaration, sourceLocale = siteSourceLocale(siteRoot) } = opts
|
|
629
633
|
refuseOrgOption(opts, 'recordsToProject')
|
|
630
634
|
// The scope the push qualified `@/x` with — the site's foundation's — so a
|
|
631
635
|
// `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
|
package/src/uwx/records.js
CHANGED
|
@@ -59,11 +59,13 @@ 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'
|
|
66
67
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
68
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
67
69
|
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
68
70
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
69
71
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
@@ -822,10 +824,7 @@ function resolveDeclaration(schema, modelName) {
|
|
|
822
824
|
const dataSchemas = schema?.dataSchemas || {}
|
|
823
825
|
const m = /^@([^/]+)\/(.+)$/.exec(modelName)
|
|
824
826
|
const org = m ? m[1] : null
|
|
825
|
-
const resolveName = (ref) =>
|
|
826
|
-
typeof ref === 'string' && ref.startsWith('@/') && org
|
|
827
|
-
? `@${org}/${ref.slice(2)}`
|
|
828
|
-
: ref
|
|
827
|
+
const resolveName = (ref) => qualifyRef(ref, org)
|
|
829
828
|
for (const [ref, normalized] of Object.entries(dataSchemas)) {
|
|
830
829
|
if (resolveName(ref) === modelName) {
|
|
831
830
|
return toDataSchemaDeclaration(normalized, { name: modelName, resolveName })
|
|
@@ -972,7 +971,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
972
971
|
opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
|
|
973
972
|
const warnedUnscoped = new Set()
|
|
974
973
|
const modelFor = (schema, where) => {
|
|
975
|
-
const modelName =
|
|
974
|
+
const modelName = qualifyRef(schema, scope)
|
|
976
975
|
// Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
|
|
977
976
|
// throwing (a `status` probe on a never-registered foundation must still count),
|
|
978
977
|
// but say so: the backend's refusal names a missing Model and cannot name this cause.
|
|
@@ -1031,7 +1030,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1031
1030
|
}
|
|
1032
1031
|
|
|
1033
1032
|
const sourceLocale =
|
|
1034
|
-
opts.sourceLocale ||
|
|
1033
|
+
opts.sourceLocale || siteSourceLocale(siteRoot)
|
|
1035
1034
|
|
|
1036
1035
|
// Target locales for wrapping localized record fields per-locale: those with a
|
|
1037
1036
|
// structural-translation file (locales/records/{locale}.json) UNIONED with
|
|
@@ -1277,8 +1276,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1277
1276
|
? !unresolved.has(schema)
|
|
1278
1277
|
: Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema))))
|
|
1279
1278
|
if (!resolved) {
|
|
1280
|
-
if (decl.schemaExplicit) throw unresolvedExplicit(
|
|
1281
|
-
schemaless.push({ name, model:
|
|
1279
|
+
if (decl.schemaExplicit) throw unresolvedExplicit(qualifyRef(schema, scope), name)
|
|
1280
|
+
schemaless.push({ name, model: qualifyRef(schema, scope) })
|
|
1282
1281
|
continue
|
|
1283
1282
|
}
|
|
1284
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-diff.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { entityContentHash } from './records.js'
|
|
50
50
|
import { sectionFileBase, pageDirName, layoutSectionPaths } from './site-project.js'
|
|
51
|
-
import {
|
|
51
|
+
import { documentSourceLocale } from './source-locale.js'
|
|
52
52
|
|
|
53
53
|
// A unit's own content, with the nested collections that are their own units
|
|
54
54
|
// removed — so editing a section never also marks its page (or its parent
|
|
@@ -70,7 +70,7 @@ const ownContent = (record, ...childKeys) => {
|
|
|
70
70
|
*
|
|
71
71
|
* @returns {Map<string, object>} path → the unit's own record
|
|
72
72
|
*/
|
|
73
|
-
export function collectSiteUnits(doc, sourceLocale =
|
|
73
|
+
export function collectSiteUnits(doc, sourceLocale = documentSourceLocale(doc)) {
|
|
74
74
|
const units = new Map()
|
|
75
75
|
walkSiteUnits(doc, (path, record, kind) => {
|
|
76
76
|
units.set(
|
|
@@ -93,7 +93,7 @@ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.
|
|
|
93
93
|
*
|
|
94
94
|
* @param {(path: string, record: object, kind: 'info'|'page'|'section') => void} cb
|
|
95
95
|
*/
|
|
96
|
-
export function walkSiteUnits(doc, cb, sourceLocale =
|
|
96
|
+
export function walkSiteUnits(doc, cb, sourceLocale = documentSourceLocale(doc)) {
|
|
97
97
|
const walkSections = (sections, dir) => {
|
|
98
98
|
for (const record of sections || []) {
|
|
99
99
|
// The projector's own file rule — including an app-created section named from its
|
|
@@ -353,7 +353,7 @@ const KEYED_SECTIONS = {
|
|
|
353
353
|
*
|
|
354
354
|
* @returns {Map<string, { uuid: string|null, record: object }>}
|
|
355
355
|
*/
|
|
356
|
-
export function siteItemsByKey(doc, sourceLocale =
|
|
356
|
+
export function siteItemsByKey(doc, sourceLocale = documentSourceLocale(doc)) {
|
|
357
357
|
const out = new Map()
|
|
358
358
|
const uuidOf = (record) => (typeof record?.$uuid === 'string' && record.$uuid ? record.$uuid : null)
|
|
359
359
|
const units = collectSiteUnits(doc, sourceLocale)
|
package/src/uwx/site-project.js
CHANGED
|
@@ -52,6 +52,7 @@ import { unwritableNodes } from '@uniweb/content-writer'
|
|
|
52
52
|
import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
|
|
53
53
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
54
54
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
55
|
+
import { documentSourceLocale } from './source-locale.js'
|
|
55
56
|
import { siteContentDirs } from './site-dirs.js'
|
|
56
57
|
import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
|
|
57
58
|
import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
@@ -215,7 +216,7 @@ function readAuthoredYaml(filePath) {
|
|
|
215
216
|
* @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
|
|
216
217
|
* write status ('updated' | 'unchanged')
|
|
217
218
|
*/
|
|
218
|
-
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale =
|
|
219
|
+
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), collector, keepAuthoredFoundation = false }) {
|
|
219
220
|
const info = document?.info || {}
|
|
220
221
|
const settingsSection = document?.settings || {}
|
|
221
222
|
|
|
@@ -1272,7 +1273,8 @@ function refuseContentFilesCannotHold(document, sourceLocale) {
|
|
|
1272
1273
|
* @param {object} params
|
|
1273
1274
|
* @param {object} params.document - the `@uniweb/site-content` `$`-document
|
|
1274
1275
|
* @param {string} params.siteRoot
|
|
1275
|
-
* @param {string} [params.sourceLocale]
|
|
1276
|
+
* @param {string} [params.sourceLocale] - the language the document is written in; by default its
|
|
1277
|
+
* own `settings`, by the rule its push wrapped it with (`documentSourceLocale`)
|
|
1276
1278
|
* @param {boolean} [params.prune=false] - delete orphaned pages/sections/layout
|
|
1277
1279
|
* files that have no corresponding incoming item (git-pull-like). Off by
|
|
1278
1280
|
* default; `uniweb pull` opts in. Guarded against wiping a level on an
|
|
@@ -1282,7 +1284,7 @@ function refuseContentFilesCannotHold(document, sourceLocale) {
|
|
|
1282
1284
|
* `declarationsToQueriesYml`, whose default it overrides.
|
|
1283
1285
|
* @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
|
|
1284
1286
|
*/
|
|
1285
|
-
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale =
|
|
1287
|
+
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
|
|
1286
1288
|
// Before anything is written: a pull that would leave content out of the files stops here.
|
|
1287
1289
|
refuseContentFilesCannotHold(document, sourceLocale)
|
|
1288
1290
|
|
package/src/uwx/site.js
CHANGED
|
@@ -73,7 +73,7 @@ import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
|
|
|
73
73
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
74
74
|
import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
|
|
75
75
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
76
|
-
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
76
|
+
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent, plainTextTargets } from './locale-sync.js'
|
|
77
77
|
import { unwrapLocalized } from './backfill.js'
|
|
78
78
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
79
79
|
import { resolveLocaleList } from '../i18n/locales.js'
|
|
@@ -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
|
|
@@ -264,11 +265,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
|
|
|
264
265
|
// build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
|
|
265
266
|
// page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
|
|
266
267
|
// override never reached a backend — nor, until the same day, an entry's default.
|
|
268
|
+
// ⭐ Each is a plain-text slot, so a translation reaches it as text, as the build renders it
|
|
269
|
+
// (`plainTextTargets`).
|
|
267
270
|
function localizePageMeta(page, route, sourceLocale, translations) {
|
|
268
271
|
const context = { page: route, section: '_meta' }
|
|
269
272
|
const again = (value) =>
|
|
270
273
|
value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
|
|
271
|
-
? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
|
|
274
|
+
? plainTextTargets(localizeScalar(value[sourceLocale], sourceLocale, translations, context), sourceLocale)
|
|
272
275
|
: value
|
|
273
276
|
for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
|
|
274
277
|
if (page[key] !== undefined) page[key] = again(page[key])
|
|
@@ -1053,7 +1056,7 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1053
1056
|
// and a store that checks a query's `schema` against the site's declared types refused the
|
|
1054
1057
|
// whole content push for it. An explicit `schema:` that does not resolve never reaches here:
|
|
1055
1058
|
// the records lane refuses it.
|
|
1056
|
-
if (!schemaless?.has(name)) setIf(data, 'schema',
|
|
1059
|
+
if (!schemaless?.has(name)) setIf(data, 'schema', qualifyRef(schema, scope))
|
|
1057
1060
|
// ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
|
|
1058
1061
|
// one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
|
|
1059
1062
|
// → `siteContent.queryFields`), since one that does not refuses a push carrying it.
|
|
@@ -1306,14 +1309,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1306
1309
|
// The site's Open Graph title and description per language, where the deployment's `settings`
|
|
1307
1310
|
// takes them (`uwx/open-graph.js`), in the site's context (`SITE_META_CONTEXT`).
|
|
1308
1311
|
// (Each read inline from `siteYml.seo`, so the emit surface names its source.)
|
|
1309
|
-
|
|
1310
|
-
setIf(settings, '
|
|
1312
|
+
// Plain-text slots, each translation as text (`plainTextTargets`).
|
|
1313
|
+
setIf(settings, 'og_title', plainTextTargets(openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
|
|
1314
|
+
setIf(settings, 'og_description', plainTextTargets(openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
|
|
1311
1315
|
setIf(settings, 'seo', seoBesideOpenGraph(siteYml.seo, settingsFields))
|
|
1312
1316
|
// ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
|
|
1313
1317
|
// (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
|
|
1314
1318
|
// convenience, not because it is a different kind of thing. Localized, so it
|
|
1315
1319
|
// carries the translation collector with it.
|
|
1316
|
-
setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
|
|
1320
|
+
setIf(settings, 'keywords', plainTextTargets(localizeScalarList(siteYml.keywords, sourceLocale, translations), sourceLocale))
|
|
1317
1321
|
|
|
1318
1322
|
// ⛔ NO SERVICE IS HERE. A service's address and options ride its row in the
|
|
1319
1323
|
// `services` Section, whole (`requestedServices`) [Diego, 2026-10-06: "If it's on and
|
|
@@ -1431,7 +1435,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1431
1435
|
// a localized `{lang:value}` map. Genuine content fields (`description` below, page
|
|
1432
1436
|
// title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
|
|
1433
1437
|
info.name = siteYml.name
|
|
1434
|
-
setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
|
|
1438
|
+
setIf(info, 'description', plainTextTargets(localizeScalar(siteYml.description, sourceLocale, translations), sourceLocale))
|
|
1435
1439
|
// Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
|
|
1436
1440
|
// codes included. Sync carries the full working set; only *publish* filters
|
|
1437
1441
|
// (backend projection / static-build filter). The verbatim carry is what
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⭐ THE LANGUAGE A SITE IS WRITTEN IN — the locale every localized value of it is keyed by on the
|
|
3
|
+
* sync wire, its source. One rule for both directions, `resolveDefaultLocale`
|
|
4
|
+
* (`@uniweb/core/locale-config`): `defaultLanguage`, else the first of `languages`, else `en`.
|
|
5
|
+
*
|
|
6
|
+
* - From the files, `site.yml` (`siteSourceLocale`): what a push wraps pages, records and folder
|
|
7
|
+
* labels under.
|
|
8
|
+
* - From a document, its `settings` (`documentSourceLocale`): what a pull unwraps them by, and what
|
|
9
|
+
* keys a document's units. A push sends `default_language` when the site names one, and its
|
|
10
|
+
* `languages` source first (`uwx/site.js`), so the rule over a document gives back the language
|
|
11
|
+
* the push wrapped it in.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ Until 2026-10-16 only the page push asked the site. The record push and every pull assumed
|
|
14
|
+
* `en`, which every site measured until then happened to be written in: a French site with an
|
|
15
|
+
* English translation came back from a pull in English, its own text filed as a translation of it;
|
|
16
|
+
* a French-only clone came back with empty sections; and its records were pushed labelled English,
|
|
17
|
+
* their English translations left out.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import yaml from 'js-yaml'
|
|
22
|
+
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
23
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The language a site's files are written in — `site.yml`'s, by the shared rule.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} siteRoot - the directory holding `site.yml`
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function siteSourceLocale(siteRoot) {
|
|
32
|
+
const path = join(siteRoot, 'site.yml')
|
|
33
|
+
let siteYml = null
|
|
34
|
+
if (existsSync(path)) {
|
|
35
|
+
try {
|
|
36
|
+
siteYml = yaml.load(readFileSync(path, 'utf8'), YAML_OPTIONS)
|
|
37
|
+
} catch {
|
|
38
|
+
siteYml = null // an unreadable site.yml is reported by whoever reads it for more
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return resolveDefaultLocale(siteYml && typeof siteYml === 'object' ? siteYml : {})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The language a `@uniweb/site-content` document's localized values are keyed by — its
|
|
46
|
+
* `settings`, by the shared rule.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} document - the site-content `$`-document
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function documentSourceLocale(document) {
|
|
52
|
+
const settings = document?.settings
|
|
53
|
+
return resolveDefaultLocale({
|
|
54
|
+
defaultLanguage: settings?.default_language,
|
|
55
|
+
languages: settings?.languages,
|
|
56
|
+
})
|
|
57
|
+
}
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -25,6 +25,7 @@ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
|
25
25
|
import { fileRecordUploads } from './file-records.js'
|
|
26
26
|
import { buildFolderEntity } from './folder.js'
|
|
27
27
|
import { siteProjectToDocument } from './site.js'
|
|
28
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
28
29
|
import { siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
29
30
|
import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
|
|
30
31
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
@@ -299,7 +300,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
299
300
|
*/
|
|
300
301
|
export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
301
302
|
const includeSite = opts.includeSite !== false
|
|
302
|
-
|
|
303
|
+
// ⭐ ONE LANGUAGE FOR THE WHOLE EMIT — the site's (`./source-locale.js`): its pages, its records,
|
|
304
|
+
// its folder's labels and the paths its units are keyed by. ⛔ Until 2026-10-16 only the pages asked
|
|
305
|
+
// the site; the rest assumed `en`.
|
|
306
|
+
const sourceLocale = opts.sourceLocale || siteSourceLocale(siteRoot)
|
|
303
307
|
const priorHashes = opts.priorHashes || {}
|
|
304
308
|
const sendAll = !!opts.sendAll
|
|
305
309
|
const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
|
|
@@ -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) {
|