@uniweb/build 0.74.4 → 0.75.1
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 +9 -9
- package/src/generate-entry.js +34 -40
- package/src/i18n/audit.js +18 -20
- package/src/i18n/data-models.js +77 -0
- package/src/i18n/data-strings.js +27 -0
- package/src/i18n/extract.js +15 -11
- package/src/i18n/index.js +9 -4
- package/src/i18n/merge.js +167 -27
- package/src/i18n/model-fields.js +97 -0
- package/src/i18n/records.js +3 -59
- package/src/runtime-schema.js +84 -0
- package/src/schema.js +51 -5
- package/src/site/config.js +4 -0
- package/src/site/plugin.js +8 -1
- package/src/site/preview-directory-index.js +67 -0
- package/src/uwx/index.js +2 -0
- package/src/uwx/locale-sync.js +17 -11
- package/src/uwx/registry-package.js +17 -5
- package/src/uwx/site-project.js +7 -4
- package/src/uwx/site.js +4 -1
package/src/schema.js
CHANGED
|
@@ -18,6 +18,7 @@ import { join, dirname, extname, basename } from 'node:path'
|
|
|
18
18
|
import { pathToFileURL } from 'node:url'
|
|
19
19
|
import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
|
|
20
20
|
import { describeContent, lowerData } from '@uniweb/schemas/content'
|
|
21
|
+
import { normalizeData } from '@uniweb/schemas/foundation'
|
|
21
22
|
import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
|
|
22
23
|
import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
|
|
23
24
|
import {
|
|
@@ -298,7 +299,6 @@ function retiredScopeMessage(pkgPath, scope, name) {
|
|
|
298
299
|
* Contains foundation-wide configuration:
|
|
299
300
|
* - vars: CSS custom properties sites can override
|
|
300
301
|
* - defaultLayout: Default layout name
|
|
301
|
-
* - props: Foundation-wide props
|
|
302
302
|
* - Future: providers, middleware, etc.
|
|
303
303
|
*/
|
|
304
304
|
/**
|
|
@@ -315,7 +315,6 @@ const DEFAULT_ONLY_CAPABILITIES = [
|
|
|
315
315
|
'defaultLayout',
|
|
316
316
|
'defaultSection',
|
|
317
317
|
'viewTransitions',
|
|
318
|
-
'props',
|
|
319
318
|
'defaultInsets',
|
|
320
319
|
'xref',
|
|
321
320
|
'outputs',
|
|
@@ -328,8 +327,9 @@ const DEFAULT_ONLY_CAPABILITIES = [
|
|
|
328
327
|
/**
|
|
329
328
|
* Warn when a capability was written as a named export.
|
|
330
329
|
*
|
|
331
|
-
* `generate-entry.js` builds `capabilities`
|
|
332
|
-
*
|
|
330
|
+
* `generate-entry.js` builds `capabilities` from the DEFAULT export only — the code the
|
|
331
|
+
* runtime calls, referenced, and its data, lean (`extractFoundationRuntime`; until
|
|
332
|
+
* 2026-10-01 it spread the whole default export). A named `export const xref =
|
|
333
333
|
* …` is therefore dropped with no error, no warning, and a build that succeeds.
|
|
334
334
|
* Whatever the capability did simply does not happen, which reads as "the
|
|
335
335
|
* feature is not implemented" rather than "it is declared in the wrong place".
|
|
@@ -474,10 +474,20 @@ export async function loadFoundationConfig(srcDir) {
|
|
|
474
474
|
if (existsSync(legacy)) {
|
|
475
475
|
throw new Error(
|
|
476
476
|
`${legacy} is no longer read — rename it to main.js.\n` +
|
|
477
|
-
` A foundation's declarations (vars, defaultLayout, defaultSection,
|
|
477
|
+
` A foundation's declarations (vars, defaultLayout, defaultSection, name) live in\n` +
|
|
478
478
|
` main.js. Left as foundation.js, the build would continue with none of them.`,
|
|
479
479
|
)
|
|
480
480
|
}
|
|
481
|
+
// ⛔ The same for `main.jsx`, which the entry used to import while this read nothing — the
|
|
482
|
+
// editor saw none of its declarations, the runtime all of them (until 2026-10-01). A main.js
|
|
483
|
+
// may hold JSX: `importFoundationConfig` transpiles it.
|
|
484
|
+
const jsx = join(srcDir, 'main.jsx')
|
|
485
|
+
if (existsSync(jsx)) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`${jsx} is not read — rename it to main.js, which may hold JSX.\n` +
|
|
488
|
+
` A foundation's declarations live in main.js; left as main.jsx, the build would continue with none of them.`,
|
|
489
|
+
)
|
|
490
|
+
}
|
|
481
491
|
return {}
|
|
482
492
|
}
|
|
483
493
|
|
|
@@ -612,8 +622,12 @@ function buildComponentEntry(name, relativePath, meta) {
|
|
|
612
622
|
path: relativePath,
|
|
613
623
|
}
|
|
614
624
|
|
|
625
|
+
// ⭐ `[]` says the component TAKES NO CONTENT — `content: {}` or `content: []` in its `meta.js`
|
|
626
|
+
// [Diego, 2026-10-01] — a component that only reads `content.data`, say. Absent says it
|
|
627
|
+
// declared nothing, which is unknown. ⛔ Until 2026-10-01 both came out absent.
|
|
615
628
|
const content = describeContent(meta)
|
|
616
629
|
if (content.elements.length > 0) entry.content = content.elements
|
|
630
|
+
else if (declaresNoContent(meta.content)) entry.content = []
|
|
617
631
|
else delete entry.content
|
|
618
632
|
|
|
619
633
|
const children = lowerChildren(meta)
|
|
@@ -627,6 +641,12 @@ function buildComponentEntry(name, relativePath, meta) {
|
|
|
627
641
|
return entry
|
|
628
642
|
}
|
|
629
643
|
|
|
644
|
+
/** `content: {}` or `content: []` — the component's way of saying it takes no content. */
|
|
645
|
+
function declaresNoContent(content) {
|
|
646
|
+
if (Array.isArray(content)) return content.length === 0
|
|
647
|
+
return content !== null && typeof content === 'object' && Object.keys(content).length === 0
|
|
648
|
+
}
|
|
649
|
+
|
|
630
650
|
/**
|
|
631
651
|
* Refuse a declaration that was retired, naming what replaces it. A refusal, not a
|
|
632
652
|
* warning: a retired key would otherwise register as if it still meant something.
|
|
@@ -1010,6 +1030,31 @@ function reportSupports(srcDir, authored, derived, emitted) {
|
|
|
1010
1030
|
}
|
|
1011
1031
|
}
|
|
1012
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Warn of a `data:` value `register` will refuse — one the normalized form it sends cannot
|
|
1035
|
+
* write (`@uniweb/schemas/foundation`): an inline field map that is not a valid data schema.
|
|
1036
|
+
* The build goes on; the site renders the map as before. `register` is where it stops, since
|
|
1037
|
+
* a registered schema is written in one spelling per meaning or not at all.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {Object} components - discovered section types, by name
|
|
1040
|
+
* @param {Object} [layouts] - discovered layouts, by name
|
|
1041
|
+
* @param {Object} [foundationConfig] - the foundation's `main.js`, whose `data:` reaches every section
|
|
1042
|
+
*/
|
|
1043
|
+
export function reportUnregistrableData(components, layouts = {}, foundationConfig = {}) {
|
|
1044
|
+
const owners = [
|
|
1045
|
+
...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
|
|
1046
|
+
...Object.entries(layouts || {}).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
|
|
1047
|
+
['the foundation (main.js)', foundationConfig?.data],
|
|
1048
|
+
]
|
|
1049
|
+
for (const [owner, data] of owners) {
|
|
1050
|
+
try {
|
|
1051
|
+
normalizeData(data, { owner })
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
console.warn(`Warning: ${err.message} \`uniweb register\` refuses it until it is one.`)
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1013
1058
|
/**
|
|
1014
1059
|
* Warn about `content` and `children` declarations an editor cannot use — what lowering
|
|
1015
1060
|
* them could not read (`@uniweb/schemas`, the grammar the editor reads them with). The
|
|
@@ -1109,6 +1154,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
1109
1154
|
|
|
1110
1155
|
// Discover layouts from src/layouts/
|
|
1111
1156
|
const layouts = await discoverLayoutsInPath(srcDir)
|
|
1157
|
+
reportUnregistrableData(components, layouts, foundationConfig)
|
|
1112
1158
|
|
|
1113
1159
|
// Determine extension role
|
|
1114
1160
|
const isExtension = !!foundationConfig.extension
|
package/src/site/config.js
CHANGED
|
@@ -36,6 +36,7 @@ import { checkFoundationResolution } from '../utils/foundation-resolution-check.
|
|
|
36
36
|
import { detectFoundationType } from './foundation-ref.js'
|
|
37
37
|
import { readPreview, PREVIEW_ENV } from './preview.js'
|
|
38
38
|
import { previewMediaPlugin } from './preview-media.js'
|
|
39
|
+
import { previewDirectoryIndexPlugin } from './preview-directory-index.js'
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Normalize a base path for Vite compatibility
|
|
@@ -396,6 +397,9 @@ export async function defineSiteConfig(options = {}) {
|
|
|
396
397
|
|
|
397
398
|
if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
|
|
398
399
|
|
|
400
|
+
// `vite preview` answers `/about` with `about/index.html`, as the hosts a build deploys to do.
|
|
401
|
+
plugins.push(previewDirectoryIndexPlugin())
|
|
402
|
+
|
|
399
403
|
// Import map plugin for runtime mode production builds.
|
|
400
404
|
// Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
|
|
401
405
|
// so the browser can resolve bare specifiers in the dynamically-imported foundation.
|
package/src/site/plugin.js
CHANGED
|
@@ -251,6 +251,7 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
|
|
|
251
251
|
}
|
|
252
252
|
import { generateSearchIndex, getSearchIndexFilename } from '@uniweb/projections'
|
|
253
253
|
import { mergeTranslations } from '../i18n/merge.js'
|
|
254
|
+
import { dataBlockModels, NO_DATA_MODELS } from '../i18n/data-models.js'
|
|
254
255
|
import { searchDeclaredOn } from './search-declared.js'
|
|
255
256
|
import { mountDevApi } from '../dev/api-mount.js'
|
|
256
257
|
|
|
@@ -654,6 +655,9 @@ export function siteContentPlugin(options = {}) {
|
|
|
654
655
|
}
|
|
655
656
|
|
|
656
657
|
let siteContent = null
|
|
658
|
+
// What each section type declares its data blocks are — read once per collection of the site.
|
|
659
|
+
let dataModelPromise = null
|
|
660
|
+
const dataModels = () => (dataModelPromise ||= dataBlockModels(resolvedSitePath).catch(() => NO_DATA_MODELS))
|
|
657
661
|
let resolvedSitePath = null
|
|
658
662
|
let resolvedPublicDir = null
|
|
659
663
|
let resolvedOutDir = null
|
|
@@ -791,7 +795,8 @@ export function siteContentPlugin(options = {}) {
|
|
|
791
795
|
return mergeTranslations(siteContent, translations || {}, {
|
|
792
796
|
locale,
|
|
793
797
|
localesDir: join(resolvedSitePath, localesDir),
|
|
794
|
-
freeformEnabled: hasFreeform
|
|
798
|
+
freeformEnabled: hasFreeform,
|
|
799
|
+
dataModel: await dataModels()
|
|
795
800
|
})
|
|
796
801
|
}
|
|
797
802
|
|
|
@@ -951,6 +956,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
951
956
|
// strict on a production build: a mount that contributes no pages is a
|
|
952
957
|
// warning while you author and a broken deploy once you ship.
|
|
953
958
|
siteContent = await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })
|
|
959
|
+
dataModelPromise = null
|
|
954
960
|
headHtml = await loadHeadHtml()
|
|
955
961
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
956
962
|
|
|
@@ -1020,6 +1026,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
1020
1026
|
console.log('[site-content] Content changed, rebuilding...')
|
|
1021
1027
|
try {
|
|
1022
1028
|
siteContent = await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })
|
|
1029
|
+
dataModelPromise = null
|
|
1023
1030
|
headHtml = await loadHeadHtml()
|
|
1024
1031
|
if (materialize) {
|
|
1025
1032
|
queriesConfig = siteContent.config?.queries
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⭐ `vite preview` SERVES A PAGE'S FOLDER FOR ITS URL WITHOUT THE SLASH — as the hosts a site
|
|
3
|
+
* deploys to do. A build writes each page as `<route>/index.html`, and GitHub Pages, Cloudflare
|
|
4
|
+
* Pages, Netlify and Vercel answer `/fr/a-propos` with `fr/a-propos/index.html` (the S3 + CloudFront
|
|
5
|
+
* adapter ships a function for it, `../hosts/s3-cloudfront.js`).
|
|
6
|
+
*
|
|
7
|
+
* `vite preview` answers it with the site's ROOT document instead: its SPA fallback serves
|
|
8
|
+
* `index.html` for any path with no file of its own. That document carries the default locale's
|
|
9
|
+
* content, so a translated page asked for without the slash rendered the not-found page once the
|
|
10
|
+
* runtime took over — the French URL resolved against English content — and every other page got
|
|
11
|
+
* the home page's prerender under its own URL. ⛔ So until 2026-10-01 the preview of a build showed
|
|
12
|
+
* what no host it deploys to does.
|
|
13
|
+
*
|
|
14
|
+
* Only an existing folder with an `index.html` is answered, and a `<route>.html` beside it wins, as
|
|
15
|
+
* on those hosts. Everything else goes to Vite unchanged.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, statSync } from 'node:fs'
|
|
18
|
+
import { join, resolve, sep } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The URL `vite preview` should serve for a request: its folder's `index.html`, or null to leave the
|
|
22
|
+
* request alone.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ method?: string, url?: string }} req
|
|
25
|
+
* @param {string} outDir - the build's output directory, absolute
|
|
26
|
+
* @param {string} [base] - the site's deployment base (`/`, `/docs/`)
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
export function directoryIndexUrl(req, outDir, base = '/') {
|
|
30
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null
|
|
31
|
+
const url = req.url || ''
|
|
32
|
+
const queryAt = url.indexOf('?')
|
|
33
|
+
const pathname = queryAt === -1 ? url : url.slice(0, queryAt)
|
|
34
|
+
const query = queryAt === -1 ? '' : url.slice(queryAt)
|
|
35
|
+
|
|
36
|
+
const mount = base.endsWith('/') ? base : `${base}/`
|
|
37
|
+
if (pathname.endsWith('/') || !pathname.startsWith(mount)) return null
|
|
38
|
+
|
|
39
|
+
let route
|
|
40
|
+
try {
|
|
41
|
+
route = decodeURIComponent(pathname.slice(mount.length - 1))
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dir = join(outDir, route)
|
|
47
|
+
if (dir !== outDir && !dir.startsWith(outDir + sep)) return null
|
|
48
|
+
if (existsSync(`${dir}.html`)) return null
|
|
49
|
+
if (!existsSync(join(dir, 'index.html')) || !statSync(dir).isDirectory()) return null
|
|
50
|
+
return `${pathname}/index.html${query}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The plugin: a middleware ahead of Vite's own, in `vite preview` only. */
|
|
54
|
+
export function previewDirectoryIndexPlugin() {
|
|
55
|
+
return {
|
|
56
|
+
name: 'uniweb:preview-directory-index',
|
|
57
|
+
configurePreviewServer(server) {
|
|
58
|
+
const { root, base, build } = server.config
|
|
59
|
+
const outDir = resolve(root, build.outDir)
|
|
60
|
+
server.middlewares.use((req, _res, next) => {
|
|
61
|
+
const target = directoryIndexUrl(req, outDir, base)
|
|
62
|
+
if (target) req.url = target
|
|
63
|
+
next()
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/uwx/index.js
CHANGED
|
@@ -78,6 +78,8 @@ export { matchStoredRecords } from './record-identity.js'
|
|
|
78
78
|
// The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
|
|
79
79
|
// up front and hands it to the synchronous projections above.
|
|
80
80
|
export { siteSelfScope } from './self-scope.js'
|
|
81
|
+
// What each section type declares its data blocks are — a pull reads a translated block back by it.
|
|
82
|
+
export { dataBlockModels } from '../i18n/data-models.js'
|
|
81
83
|
export {
|
|
82
84
|
siteInfoToConfig,
|
|
83
85
|
sectionRecordToFile,
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -23,7 +23,7 @@ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
|
23
23
|
import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
|
|
24
24
|
import { computeHash } from '../i18n/hash.js'
|
|
25
25
|
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
|
-
import {
|
|
26
|
+
import { visitDataBlockStrings } from '../i18n/data-strings.js'
|
|
27
27
|
import { resolveDocForLocale } from '../i18n/merge.js'
|
|
28
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
29
|
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
@@ -167,8 +167,11 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
|
|
|
167
167
|
* @param {string} sourceLocale
|
|
168
168
|
* @param {string[]} [targetLocales]
|
|
169
169
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
170
|
+
* @param {object} [context] - the section's translation context
|
|
171
|
+
* @param {(tag: string) => object|null} [modelFor] - the section's data-block models by tag
|
|
172
|
+
* (`i18n/data-models.js`), so a block is translated as the build translates it
|
|
170
173
|
*/
|
|
171
|
-
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined) {
|
|
174
|
+
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined, modelFor = () => null) {
|
|
172
175
|
// Non-docs (null, an already-localized map) pass through untouched.
|
|
173
176
|
if (!isProseMirrorDoc(doc)) return doc
|
|
174
177
|
|
|
@@ -178,7 +181,7 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
178
181
|
const table = translations[locale]
|
|
179
182
|
if (!table) continue
|
|
180
183
|
// With the section's context, so a context-specific override resolves as the build resolves it.
|
|
181
|
-
const resolved = resolveDocForLocale(doc, table, context)
|
|
184
|
+
const resolved = resolveDocForLocale(doc, table, context, modelFor)
|
|
182
185
|
if (resolved) result[locale] = resolved
|
|
183
186
|
}
|
|
184
187
|
}
|
|
@@ -363,7 +366,7 @@ export function createTranslationCollector(sourceLocale, { siteRoot = null } = {
|
|
|
363
366
|
* per-locale link hrefs survive. Keyed by the source element's cleaned text, so
|
|
364
367
|
* `computeHash(key)` matches the extractor's unit hash — closing the round trip.
|
|
365
368
|
*/
|
|
366
|
-
function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
369
|
+
function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
|
|
367
370
|
if (!isProseMirrorDoc(sourceDoc) || !isProseMirrorDoc(targetDoc)) return null
|
|
368
371
|
const src = blockElements(sourceDoc)
|
|
369
372
|
const tgt = blockElements(targetDoc)
|
|
@@ -383,15 +386,18 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
|
383
386
|
// a mark we can't serialize → don't risk a lossy map; store as free-form
|
|
384
387
|
return null
|
|
385
388
|
}
|
|
386
|
-
return addDataBlockStrings(map, sourceDoc, targetDoc)
|
|
389
|
+
return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
|
|
387
390
|
}
|
|
388
391
|
|
|
389
392
|
// ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
|
|
390
|
-
// (`
|
|
393
|
+
// (`visitDataBlockStrings`: a block's declared shape, else the extractor's heuristic), keyed by their
|
|
394
|
+
// source text as the build keys them. ⭐ Walked by the same model the push translated with — the pairing
|
|
395
|
+
// is by position, so a declared text field the heuristic skips (a `status`) would otherwise read a
|
|
396
|
+
// faithful translation as another shape.
|
|
391
397
|
// A target that is not the source with only those strings changed — another shape, another `href` —
|
|
392
398
|
// cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
|
|
393
399
|
// was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
|
|
394
|
-
function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
400
|
+
function addDataBlockStrings(map, sourceDoc, targetDoc, modelFor = () => null) {
|
|
395
401
|
const srcBlocks = dataBlockNodes(sourceDoc)
|
|
396
402
|
const tgtBlocks = dataBlockNodes(targetDoc)
|
|
397
403
|
if (srcBlocks.length !== tgtBlocks.length) return null
|
|
@@ -405,7 +411,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
|
405
411
|
}
|
|
406
412
|
const strings = (data) => {
|
|
407
413
|
const out = []
|
|
408
|
-
|
|
414
|
+
visitDataBlockStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value), modelFor(s.tag))
|
|
409
415
|
return out
|
|
410
416
|
}
|
|
411
417
|
const from = strings(s.data)
|
|
@@ -418,7 +424,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
|
418
424
|
pairs.set(from[j], to[j])
|
|
419
425
|
}
|
|
420
426
|
const rebuilt = JSON.parse(JSON.stringify(s.data))
|
|
421
|
-
|
|
427
|
+
visitDataBlockStrings(rebuilt, (value) => pairs.get(value) ?? value, modelFor(s.tag))
|
|
422
428
|
if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
|
|
423
429
|
for (const [from, to] of pairs) map[from.trim()] = to
|
|
424
430
|
}
|
|
@@ -436,7 +442,7 @@ function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
|
436
442
|
* reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
|
|
437
443
|
* doc (no locale wrap) is returned unchanged.
|
|
438
444
|
*/
|
|
439
|
-
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null) {
|
|
445
|
+
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null, modelFor = () => null) {
|
|
440
446
|
if (!isLocalizedContent(content)) return content
|
|
441
447
|
const source = content[sourceLocale]
|
|
442
448
|
for (const [locale, value] of Object.entries(content)) {
|
|
@@ -445,7 +451,7 @@ export function unwrapLocalizedContent(content, sourceLocale, collector, freefor
|
|
|
445
451
|
if (isProseMirrorDoc(value)) {
|
|
446
452
|
// A translation the site keeps as a free-form file stays one, even when it lines up.
|
|
447
453
|
const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
|
|
448
|
-
const map = kept ? null : deriveStructuralMap(source, value)
|
|
454
|
+
const map = kept ? null : deriveStructuralMap(source, value, modelFor)
|
|
449
455
|
if (map) collector?.addStructuralMap?.(locale, map, context)
|
|
450
456
|
else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
|
|
451
457
|
} else {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
|
|
29
|
+
import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
|
|
29
30
|
import { toDataSchemaDeclaration, SHORTHAND_SECTION } from './data-schema.js'
|
|
30
31
|
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
31
32
|
|
|
@@ -81,7 +82,7 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
|
|
|
81
82
|
const foundationEntity = {
|
|
82
83
|
model: FOUNDATION_SCHEMA,
|
|
83
84
|
info: buildInfo(self, org, digest, runtime),
|
|
84
|
-
schema: buildSchemaBlob(schema),
|
|
85
|
+
schema: buildSchemaBlob(schema, org),
|
|
85
86
|
i18n: { locales: loadI18nLocales(foundationDir) },
|
|
86
87
|
'data-schemas': { refs: buildRefs(dataSchemas, scoped) },
|
|
87
88
|
}
|
|
@@ -250,16 +251,27 @@ function buildInfo(self, org, digest, runtime) {
|
|
|
250
251
|
return info
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
// The whole renderable schema.json MINUS identity
|
|
254
|
-
// as one
|
|
255
|
-
|
|
254
|
+
// The foundation schema an editor reads: the whole renderable schema.json MINUS identity
|
|
255
|
+
// and MINUS dataSchemas, shipped as one object the backend stores and serves, and never
|
|
256
|
+
// reads into (custodian).
|
|
257
|
+
//
|
|
258
|
+
// ⭐ IN ITS NORMALIZED FORM — `schemaFormat: 3` (`@uniweb/schemas/foundation`, ruled
|
|
259
|
+
// 2026-10-01 [Diego]): every entry in one spelling per meaning, so the editor needs no
|
|
260
|
+
// framework grammar, and each `@/x` a `data:` key names qualified with the scope the
|
|
261
|
+
// foundation registers under — the name its Model was stored under. ⛔ Until 2026-10-01 the
|
|
262
|
+
// build's format 2 shipped as built, and `data: { team: '@/member' }` reached the editor
|
|
263
|
+
// as written. `dist/meta/schema.json` stays format 2: the build's own readers read it.
|
|
264
|
+
//
|
|
265
|
+
// It throws for a `data:` value that cannot be normalized — an inline field map that is
|
|
266
|
+
// not a valid data schema — naming the component and the key; the build warns of it first.
|
|
267
|
+
function buildSchemaBlob(schema, org) {
|
|
256
268
|
const { dataSchemas: _ds, ...rest } = schema
|
|
257
269
|
// Every key hoisted into `info` is stripped here, so the wire carries each
|
|
258
270
|
// fact ONCE. Two copies of one fact is a drift liability, and the copy inside
|
|
259
271
|
// an opaque blob is the one nobody would think to update.
|
|
260
272
|
const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
|
|
261
273
|
rest._self || {}
|
|
262
|
-
return { ...rest, _self: selfConfig }
|
|
274
|
+
return normalizeFoundationSchema({ ...rest, _self: selfConfig }, { scope: org || undefined })
|
|
263
275
|
}
|
|
264
276
|
|
|
265
277
|
// The data-schemas the foundation renders, by NAME (own + shared), sorted.
|
package/src/uwx/site-project.js
CHANGED
|
@@ -403,7 +403,7 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
403
403
|
* structural maps on a localized `content` field are captured into it
|
|
404
404
|
* @returns {'updated'|'unchanged'}
|
|
405
405
|
*/
|
|
406
|
-
export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null }) {
|
|
406
|
+
export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null, dataModel = null }) {
|
|
407
407
|
// ⛔ A stored `preset` or `input` is not written back: neither has an effect, and the build
|
|
408
408
|
// would only warn about it (2026-09-27).
|
|
409
409
|
// ⛔ No `background` / `theme_override` / `insets` / `fetch`: the site-content Model dropped all
|
|
@@ -416,7 +416,8 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
|
|
|
416
416
|
// target-locale structural maps are captured into the locales/ collector, and any
|
|
417
417
|
// free-form target body is captured with `freeformRelPath` for writing under
|
|
418
418
|
// locales/freeform/. A bare doc (source-only / pre-localization) passes through.
|
|
419
|
-
|
|
419
|
+
// A data block is read back by what the section's type declares it is, as the push translated it.
|
|
420
|
+
const sourceContent = unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates, context, (tag) => dataModel?.(type, tag) ?? null)
|
|
420
421
|
|
|
421
422
|
const { hidden: paramHidden, fetch: paramFetch, ...paramsRest } = params && typeof params === 'object' ? params : {}
|
|
422
423
|
const frontmatter = {}
|
|
@@ -556,6 +557,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
|
|
|
556
557
|
record,
|
|
557
558
|
sourceLocale: ctx?.sourceLocale,
|
|
558
559
|
collector: ctx?.collector,
|
|
560
|
+
dataModel: ctx?.dataModel,
|
|
559
561
|
freeformRelPath,
|
|
560
562
|
freeformCandidates,
|
|
561
563
|
writeId: fileSectionName(filePath) !== stableId,
|
|
@@ -1081,6 +1083,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
|
1081
1083
|
record,
|
|
1082
1084
|
sourceLocale: ctx?.sourceLocale,
|
|
1083
1085
|
collector: ctx?.collector,
|
|
1086
|
+
dataModel: ctx?.dataModel,
|
|
1084
1087
|
writeId: fileSectionName(filePath) !== stableId,
|
|
1085
1088
|
freeformRelPath: stableId ? buildFreeformPath({ stableId }, page) : null,
|
|
1086
1089
|
freeformCandidates: stableId ? freeformPathsFor({ stableId }, page) : null,
|
|
@@ -1116,7 +1119,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
|
1116
1119
|
* `declarationsToQueriesYml`, whose default it overrides.
|
|
1117
1120
|
* @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
|
|
1118
1121
|
*/
|
|
1119
|
-
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
|
|
1122
|
+
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
|
|
1120
1123
|
const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
|
|
1121
1124
|
|
|
1122
1125
|
// Collects target-locale translations of localized scalars as they're projected;
|
|
@@ -1145,7 +1148,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1145
1148
|
// rename detection, build a fresh one as we project, then persist it. Items not
|
|
1146
1149
|
// re-projected (deleted) drop out naturally. `collector` rides along to capture
|
|
1147
1150
|
// localized scalars during the page walk.
|
|
1148
|
-
const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map() }
|
|
1151
|
+
const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map(), dataModel }
|
|
1149
1152
|
// Where the site keeps its localized URLs — `site.yml`'s map, when it has one (`pageSlugToYml`).
|
|
1150
1153
|
const siteMap = readAuthoredYaml(join(siteRoot, 'site.yml'))?.i18n?.routeTranslations
|
|
1151
1154
|
ctx.routes = { siteMap: isPlainRecord(siteMap) && Object.keys(siteMap).length ? siteMap : null, slugs: new Map() }
|
package/src/uwx/site.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
// (favicon/assets — carried out of band).
|
|
46
46
|
|
|
47
47
|
import { readdir, readFile } from 'node:fs/promises'
|
|
48
|
+
import { dataBlockModels } from '../i18n/data-models.js'
|
|
48
49
|
import { existsSync } from 'node:fs'
|
|
49
50
|
import { join, parse, relative } from 'node:path'
|
|
50
51
|
import {
|
|
@@ -245,6 +246,8 @@ export function buildRouteOf(record, parentRoute, sourceLocale) {
|
|
|
245
246
|
// hits the filesystem.
|
|
246
247
|
async function localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot) {
|
|
247
248
|
const freeformBase = localesDir(siteRoot)
|
|
249
|
+
// What each section type declares its data blocks are, so a block is pushed as the build translates it.
|
|
250
|
+
const dataModel = await dataBlockModels(siteRoot)
|
|
248
251
|
|
|
249
252
|
const localizeSection = async (record, page) => {
|
|
250
253
|
if (!record.content) return
|
|
@@ -252,7 +255,7 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
|
|
|
252
255
|
// Resolved in the section's context, as the build resolves it (`translationContext`): a
|
|
253
256
|
// context-specific override applies here and nowhere else. ⛔ Until 2026-09-26 every section was
|
|
254
257
|
// resolved with none, so an override never reached a backend.
|
|
255
|
-
let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route))
|
|
258
|
+
let localized = localizeContentDoc(record.content, sourceLocale, targetLocales, translations, translationContext({ stableId }, page?.route), (tag) => dataModel(record.type, tag))
|
|
256
259
|
if (page) {
|
|
257
260
|
const section = { stableId }
|
|
258
261
|
for (const locale of targetLocales) {
|