@uniweb/build 1.3.6 → 1.3.8
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 +6 -6
- 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/site/entity-pool.js +16 -6
- package/src/uwx/backfill.js +7 -1
- package/src/uwx/data-schema.js +2 -5
- package/src/uwx/locale-sync.js +11 -11
- package/src/uwx/project-writer.js +9 -7
- package/src/uwx/records-project.js +88 -10
- 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.8",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@uniweb/content-reader": "~1.4.0",
|
|
60
60
|
"@uniweb/content-writer": "~1.1.2",
|
|
61
61
|
"@uniweb/projections": "~1.1.1",
|
|
62
|
-
"@uniweb/
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/schemas": "~1.3.1",
|
|
63
|
+
"@uniweb/semantic-parser": "~1.8.1",
|
|
64
|
+
"@uniweb/theming": "~1.2.0"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@uniweb/runtime": "~1.1.4"
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"@tailwindcss/vite": "^4.0.0",
|
|
74
74
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
75
75
|
"vite-plugin-svgr": "^4.0.0",
|
|
76
|
-
"@uniweb/
|
|
77
|
-
"@uniweb/
|
|
76
|
+
"@uniweb/core": "~1.2.2",
|
|
77
|
+
"@uniweb/runtime": "~1.1.4"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
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/site/entity-pool.js
CHANGED
|
@@ -53,8 +53,7 @@
|
|
|
53
53
|
// there is no population to carry): a leftover `entities/` beside `records/` would
|
|
54
54
|
// otherwise hold records nothing reads, silently.
|
|
55
55
|
|
|
56
|
-
import {
|
|
57
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
56
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
58
57
|
import { join, extname, basename, resolve } from 'node:path'
|
|
59
58
|
import yaml from 'js-yaml'
|
|
60
59
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
@@ -263,6 +262,17 @@ export function poolPathReadings(dirs) {
|
|
|
263
262
|
* leaves the backend's folder alone for a site with no records directory.
|
|
264
263
|
*/
|
|
265
264
|
export async function readEntityPool(siteRoot, opts = {}) {
|
|
265
|
+
return readEntityPoolSync(siteRoot, opts)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `readEntityPool`, for a caller that cannot wait — the pull's projection, which asks what a
|
|
270
|
+
* `folder.yml` already says (`records-project.js`). One walk, which the async form returns.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} siteRoot
|
|
273
|
+
* @param {object} [opts] - as `readEntityPool`'s
|
|
274
|
+
*/
|
|
275
|
+
export function readEntityPoolSync(siteRoot, opts = {}) {
|
|
266
276
|
let rel
|
|
267
277
|
if (opts.dir) {
|
|
268
278
|
rel = String(opts.dir).replace(/\/+$/, '')
|
|
@@ -280,10 +290,10 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
280
290
|
// A file record is named by its stem, so two files of one stem cannot both be one.
|
|
281
291
|
const fileStems = new Map()
|
|
282
292
|
|
|
283
|
-
const walk =
|
|
293
|
+
const walk = (dir, dirs) => {
|
|
284
294
|
let listing
|
|
285
295
|
try {
|
|
286
|
-
listing =
|
|
296
|
+
listing = readdirSync(dir, { withFileTypes: true })
|
|
287
297
|
} catch {
|
|
288
298
|
return
|
|
289
299
|
}
|
|
@@ -304,7 +314,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
304
314
|
)
|
|
305
315
|
continue
|
|
306
316
|
}
|
|
307
|
-
|
|
317
|
+
walk(full, [...dirs, e.name])
|
|
308
318
|
continue
|
|
309
319
|
}
|
|
310
320
|
if (!e.isFile()) continue
|
|
@@ -356,7 +366,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
356
366
|
}
|
|
357
367
|
}
|
|
358
368
|
|
|
359
|
-
|
|
369
|
+
walk(base, [])
|
|
360
370
|
return { entities, errors, exists: true, dir: rel }
|
|
361
371
|
}
|
|
362
372
|
|
package/src/uwx/backfill.js
CHANGED
|
@@ -296,7 +296,13 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
296
296
|
if (document?.$disabled === true) record.draft = true
|
|
297
297
|
|
|
298
298
|
if (format === 'json') return JSON.stringify(record, null, 2) + '\n'
|
|
299
|
-
if (format === 'md')
|
|
299
|
+
if (format === 'md') {
|
|
300
|
+
// ⭐ Written as a section is (`project-writer.js::assembleSection`): a blank line after the front
|
|
301
|
+
// matter, and a final newline. ⛔ Until 2026-10-17 it had neither, so a pull that rewrote an
|
|
302
|
+
// edited record took both out of the author's file.
|
|
303
|
+
const body = String(dec.body ?? '').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '')
|
|
304
|
+
return `---\n${yaml.dump(record)}---\n${body ? `\n${body}\n` : ''}`
|
|
305
|
+
}
|
|
300
306
|
return yaml.dump(record) // yaml / yaml
|
|
301
307
|
}
|
|
302
308
|
|
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)
|
|
@@ -418,14 +418,16 @@ function restates(authored, decl) {
|
|
|
418
418
|
* (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
|
|
419
419
|
*
|
|
420
420
|
* ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
|
|
421
|
-
* other projected config
|
|
422
|
-
*
|
|
423
|
-
* There is also nothing a shallow merge could mean here — the file is a sequence,
|
|
424
|
-
* and merging two lists either duplicates entries or silently drops them.
|
|
421
|
+
* other projected config: the file is a sequence, and merging two lists either
|
|
422
|
+
* duplicates entries or silently drops them.
|
|
425
423
|
*
|
|
426
|
-
* The caller decides whether there is anything to write: a
|
|
427
|
-
*
|
|
428
|
-
*
|
|
424
|
+
* The caller decides whether there is anything to write: a file that already says
|
|
425
|
+
* what the pulled folder says — its patterns included — is left alone
|
|
426
|
+
* (`records-project.js::organizesAlike`), and a folder with no sub-folders is no file
|
|
427
|
+
* at all (`folderToFolderYml` removes it), since missing and empty mean the same.
|
|
428
|
+
* ⛔ *Until 2026-10-17 this called the file a mirror — "concrete refs on both sides,
|
|
429
|
+
* nothing to invert" — and every pull wrote the folder's paths over a developer's
|
|
430
|
+
* patterns.* ⚠️ *Until 2026-09-21 this said an empty file was destructive
|
|
429
431
|
* on the next push; that stopped being true when the directory, not this file,
|
|
430
432
|
* became what decides which records the folder holds.*
|
|
431
433
|
*
|
|
@@ -43,10 +43,12 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
43
43
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
44
|
import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
45
45
|
import { dataKeyTypes } from './data-key-types.js'
|
|
46
|
-
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
47
|
-
import { folderYmlPath } from '../site/records-config.js'
|
|
46
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir, readEntityPoolSync } from '../site/entity-pool.js'
|
|
47
|
+
import { folderYmlPath, resolveFolder } from '../site/records-config.js'
|
|
48
|
+
import { canonicalJson } from './same-content.js'
|
|
48
49
|
import { contentBodyTarget } from './record-layout.js'
|
|
49
|
-
import {
|
|
50
|
+
import { qualifyRef } from '@uniweb/schemas/refs'
|
|
51
|
+
import { unresolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
|
|
50
52
|
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
51
53
|
import { renderEntityDocument } from './backfill.js'
|
|
52
54
|
import { labelForFile } from '../site/entry-label.js'
|
|
@@ -216,13 +218,13 @@ function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes =
|
|
|
216
218
|
const at = [first]
|
|
217
219
|
const keyType = !own?.has(first) ? keyTypes?.get(first) : null
|
|
218
220
|
if (
|
|
219
|
-
|
|
220
|
-
(keyType &&
|
|
221
|
+
qualifyRef(schemaForPoolDirs(at), scope) === model ||
|
|
222
|
+
(keyType && qualifyRef(keyType, scope) === model)
|
|
221
223
|
) {
|
|
222
224
|
dirs.push(join(recordsRoot, first))
|
|
223
225
|
}
|
|
224
226
|
for (const second of visible(join(recordsRoot, first))) {
|
|
225
|
-
if (
|
|
227
|
+
if (qualifyRef(schemaForPoolDirs([first, second]), scope) === model) {
|
|
226
228
|
dirs.push(join(recordsRoot, first, second))
|
|
227
229
|
}
|
|
228
230
|
}
|
|
@@ -452,7 +454,7 @@ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
|
|
|
452
454
|
for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
|
|
453
455
|
if (schema !== null || own?.has(name)) continue
|
|
454
456
|
const type = keyTypes.get(name)
|
|
455
|
-
const model = type ?
|
|
457
|
+
const model = type ? qualifyRef(type, scope) : null
|
|
456
458
|
if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
|
|
457
459
|
}
|
|
458
460
|
return out
|
|
@@ -500,9 +502,14 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
500
502
|
* Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
|
|
501
503
|
* directory (`records/folder.yml`, or under `site.yml::paths.records`).
|
|
502
504
|
*
|
|
503
|
-
* ⭐ THE FOLDER
|
|
504
|
-
* `
|
|
505
|
-
*
|
|
505
|
+
* ⭐ A FILE THAT SAYS WHAT THE PULLED FOLDER SAYS IS LEFT AS WRITTEN — its patterns
|
|
506
|
+
* (`member/*.md`), its comments, its order (`organizesAlike`). Otherwise the folder
|
|
507
|
+
* is written as the paths the backend's folder places, which is what it says.
|
|
508
|
+
* ⛔ *This read "the folder round-trips trivially: `folder.yml` holds concrete paths
|
|
509
|
+
* on both sides, so there is nothing to invert" until 2026-10-17 — read as "the file
|
|
510
|
+
* holds no patterns", which it does — and every pull with a folder wrote its paths
|
|
511
|
+
* over the developer's patterns: a record added afterwards fell outside them.* The
|
|
512
|
+
* old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
|
|
506
513
|
* collections — and inverting a macro is not possible in general. Taking queries
|
|
507
514
|
* out of the folder is what dissolved that.
|
|
508
515
|
*
|
|
@@ -599,6 +606,16 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
599
606
|
|
|
600
607
|
const entries = walk(folderDoc.contents, false)
|
|
601
608
|
if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
|
|
609
|
+
if (existsSync(abs) && organizesAlike({ abs, entries, siteRoot, poolPathByUuid })) {
|
|
610
|
+
return { status: 'unchanged', entries, warnings, file }
|
|
611
|
+
}
|
|
612
|
+
// Said when it replaces a developer's pattern: a record they add from here is placed by nothing.
|
|
613
|
+
if (existsSync(abs) && holdsPattern(abs)) {
|
|
614
|
+
warnings.push(
|
|
615
|
+
`${file}: the folder no longer places its records as its patterns say, so it was written as their ` +
|
|
616
|
+
`paths. A record you add from here sits at the top of the folder until ${file} places it.`
|
|
617
|
+
)
|
|
618
|
+
}
|
|
602
619
|
if (entries.length === 0) {
|
|
603
620
|
if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
|
|
604
621
|
unlinkSync(abs)
|
|
@@ -607,6 +624,67 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
607
624
|
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
|
|
608
625
|
}
|
|
609
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Does the `folder.yml` on disk say what the pulled folder says — read by the rule a push reads it
|
|
629
|
+
* by (`resolveFolder`), over the records directory as this pull left it?
|
|
630
|
+
*
|
|
631
|
+
* ⭐ About the records the backend's folder holds, every word: the folders, by name, label and tags,
|
|
632
|
+
* in order; and each record's folder, its tags and label, and its place among the others. Records
|
|
633
|
+
* the backend does not hold yet — a file the developer added and has not pushed — are theirs, so a
|
|
634
|
+
* pattern placing one is not a difference; nor is a folder holding only those. So `member/*.md` is
|
|
635
|
+
* kept while it places exactly the members the backend's folder has there, and goes on placing the
|
|
636
|
+
* next one the developer adds.
|
|
637
|
+
*
|
|
638
|
+
* @returns {boolean}
|
|
639
|
+
*/
|
|
640
|
+
function organizesAlike({ abs, entries, siteRoot, poolPathByUuid }) {
|
|
641
|
+
let authored
|
|
642
|
+
try {
|
|
643
|
+
authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
|
|
644
|
+
} catch {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
if (!Array.isArray(authored)) return false
|
|
648
|
+
const pool = readEntityPoolSync(siteRoot).entities
|
|
649
|
+
const pulled = new Set(poolPathByUuid.values())
|
|
650
|
+
const held = new Set(pool.filter((e) => pulled.has(e.poolPath)).map((e) => e.id))
|
|
651
|
+
const mine = resolveFolder(authored, pool)
|
|
652
|
+
const theirs = resolveFolder(entries, pool)
|
|
653
|
+
if (mine.errors.length || theirs.errors.length) return false
|
|
654
|
+
const theirFolders = new Set()
|
|
655
|
+
const said = (n) => ({ ...(n.label != null ? { label: n.label } : {}), ...(n.tags ? { tags: n.tags } : {}) })
|
|
656
|
+
// A tree as it reads for the held records. `keep(path, holds)` decides whether a folder stays.
|
|
657
|
+
const shape = (nodes, at, keep) =>
|
|
658
|
+
(nodes || []).flatMap((n) => {
|
|
659
|
+
if (n.kind === 'ref') return held.has(n.$entityId) ? [{ record: n.$entityId, ...said(n) }] : []
|
|
660
|
+
if (n.kind !== 'branch') return []
|
|
661
|
+
const path = [...at, n.name]
|
|
662
|
+
const records = shape(n.$children, path, keep)
|
|
663
|
+
return keep(path.join('/'), records.length > 0) ? [{ folder: n.name, ...said(n), records }] : []
|
|
664
|
+
})
|
|
665
|
+
// Every folder the backend has stays, empty or not; one of the file's stays when it holds a held
|
|
666
|
+
// record or the backend has it too — else it is the developer's, not pushed yet.
|
|
667
|
+
const theirShape = shape(theirs.nodes, [], (path) => (theirFolders.add(path), true))
|
|
668
|
+
const myShape = shape(mine.nodes, [], (path, holds) => holds || theirFolders.has(path))
|
|
669
|
+
return canonicalJson(myShape) === canonicalJson(theirShape)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Does the `folder.yml` on disk place records by a pattern (`member/*.md`) anywhere?
|
|
673
|
+
function holdsPattern(abs) {
|
|
674
|
+
let authored
|
|
675
|
+
try {
|
|
676
|
+
authored = yaml.load(readFileSync(abs, 'utf8'), YAML_OPTIONS)
|
|
677
|
+
} catch {
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
680
|
+
const has = (entries) =>
|
|
681
|
+
(Array.isArray(entries) ? entries : []).some((entry) => {
|
|
682
|
+
const path = typeof entry === 'string' ? entry : entry?.path
|
|
683
|
+
return (typeof path === 'string' && /[*?]/.test(path)) || has(entry?.records)
|
|
684
|
+
})
|
|
685
|
+
return has(authored)
|
|
686
|
+
}
|
|
687
|
+
|
|
610
688
|
/**
|
|
611
689
|
* Project a pulled folder + its record entities to `records/**` files.
|
|
612
690
|
*
|
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) {
|