@uniweb/build 0.63.4 → 0.64.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.63.4",
3
+ "version": "0.64.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-writer": "^0.3.4",
61
- "@uniweb/projections": "^0.7.3",
62
- "@uniweb/schemas": "^0.7.0",
63
60
  "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/projections": "^0.7.4",
64
63
  "@uniweb/semantic-parser": "^1.4.1",
64
+ "@uniweb/schemas": "^0.7.1",
65
65
  "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.4"
68
+ "@uniweb/runtime": "^0.27.6"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.8",
78
- "@uniweb/runtime": "^0.27.4"
77
+ "@uniweb/core": "^0.30.1",
78
+ "@uniweb/runtime": "^0.27.6"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -30,6 +30,9 @@ try {
30
30
  export function extractTranslatableContent(siteContent) {
31
31
  const units = {}
32
32
 
33
+ // The site's own Open Graph title and description (`site.yml` `seo:`), in the site's context.
34
+ extractFromSiteMeta(siteContent.config, units)
35
+
33
36
  for (const page of siteContent.pages || []) {
34
37
  const pageRoute = page.route || '/'
35
38
 
@@ -82,6 +85,22 @@ export function extractTranslatableContent(siteContent) {
82
85
  * @param {string} pageRoute - Page route
83
86
  * @param {Object} units - Units accumulator
84
87
  */
88
+ /**
89
+ * The context of the site's own metadata — its Open Graph title and description (`site.yml` `seo:`),
90
+ * which a push sends per language as `settings.og_title` / `og_description` (`uwx/open-graph.js`).
91
+ * A page's is `<route>:_meta`; the site's is this one.
92
+ */
93
+ export const SITE_META_CONTEXT = Object.freeze({ page: '/', section: '_site' })
94
+
95
+ function extractFromSiteMeta(config, units) {
96
+ const seo = config?.seo
97
+ if (!seo || typeof seo !== 'object') return
98
+ if (typeof seo.ogTitle === 'string' && seo.ogTitle) addUnit(units, seo.ogTitle, 'site.seo.ogTitle', SITE_META_CONTEXT)
99
+ if (typeof seo.ogDescription === 'string' && seo.ogDescription) {
100
+ addUnit(units, seo.ogDescription, 'site.seo.ogDescription', SITE_META_CONTEXT)
101
+ }
102
+ }
103
+
85
104
  function extractFromPageMeta(page, pageRoute, units) {
86
105
  // Use special section identifier for page-level metadata
87
106
  const context = { page: pageRoute, section: '_meta' }
@@ -127,6 +146,23 @@ function extractFromPageMeta(page, pageRoute, units) {
127
146
  }
128
147
  }
129
148
 
149
+ /**
150
+ * Where a translation applies — `{ page, section }`, the key a context-specific override is written
151
+ * under (`"<page>:<section>"`): the page's route and the section's STABLE id. One rule for the
152
+ * extractor, the build (`merge.js`), the push and the pull.
153
+ *
154
+ * ⛔ The section was its build `id` until 2026-09-26 — a numbered file's number (`/about:1` for
155
+ * `1-hero.md`) — so an override moved to another section when files were renumbered, and a clone,
156
+ * whose files are not numbered, could not key it at all.
157
+ *
158
+ * @param {Object} section - a section with `stableId` (and the build `id` as a fallback)
159
+ * @param {string} pageRoute - the route of the page (or layout area) it is on
160
+ * @returns {{ page: string, section: string }}
161
+ */
162
+ export function translationContext(section, pageRoute) {
163
+ return { page: pageRoute, section: section?.stableId || section?.id || 'unknown' }
164
+ }
165
+
130
166
  /**
131
167
  * Extract translatable content from a section
132
168
  * @param {Object} section - Section data
@@ -134,8 +170,7 @@ function extractFromPageMeta(page, pageRoute, units) {
134
170
  * @param {Object} units - Units accumulator
135
171
  */
136
172
  function extractFromSection(section, pageRoute, units) {
137
- const sectionId = section.id || 'unknown'
138
- const context = { page: pageRoute, section: sectionId }
173
+ const context = translationContext(section, pageRoute)
139
174
 
140
175
  // Extract from parsed semantic content if available
141
176
  // The section.content is ProseMirror doc, but we need parsed content
@@ -173,10 +208,11 @@ function extractFromSection(section, pageRoute, units) {
173
208
  * than tidy: `deriveStructuralMap` recovers a pulled translation by walking
174
209
  * block elements, so a data string translated on the sync wire would be
175
210
  * invisible to it — neither captured in the map nor counted as divergence — and
176
- * would be silently lost on the next pull. So this lane is the BUILD lane only
177
- * (the manifest, and `dist/{locale}/`), which is where the reported bug lives.
178
- * Carrying data-block translations across the sync wire needs a representation
179
- * that contract does not have yet.
211
+ * would be silently lost on the next pull. ⭐ Since 2026-09-26 both halves exist:
212
+ * the push translates data blocks (`merge.js::resolveDocForLocale`) and the pull
213
+ * reads their strings back pairwise (`uwx/locale-sync.js::deriveStructuralMap`),
214
+ * as the same per-string entries this lane writes — the block-element keying
215
+ * contract is untouched.
180
216
  */
181
217
  function extractFromDataBlocks(doc, context, units) {
182
218
  for (const node of dataBlockNodes(doc)) {
@@ -192,7 +228,7 @@ function extractFromDataBlocks(doc, context, units) {
192
228
  /**
193
229
  * Every tagged data block in a doc, including any nested inside a container.
194
230
  */
195
- function dataBlockNodes(doc) {
231
+ export function dataBlockNodes(doc) {
196
232
  const out = []
197
233
  const walk = (nodes) => {
198
234
  for (const node of nodes || []) {
@@ -25,6 +25,7 @@ import yaml from 'js-yaml'
25
25
  import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entity-pool.js'
26
26
  import { parseFrontmatter } from '../utils/frontmatter.js'
27
27
  import { computeSourceHash } from './freeform-manifest.js'
28
+ import { layoutAreaRoute } from '../site/layout-folder.js'
28
29
 
29
30
  // Try to import content-reader for markdown → ProseMirror conversion
30
31
  let markdownToProseMirror
@@ -327,7 +328,7 @@ function freeformSections(siteContent) {
327
328
  if (!areas || typeof areas !== 'object') continue
328
329
  for (const [areaKey, layoutPage] of Object.entries(areas)) {
329
330
  if (!layoutPage?.sections) continue
330
- const route = layoutPage.route || `/layout/${layoutName === 'default' ? '' : layoutName + '/'}${areaKey}`
331
+ const route = layoutPage.route || layoutAreaRoute(layoutName, areaKey)
331
332
  visit(layoutPage.sections, { ...layoutPage, route })
332
333
  }
333
334
  }
package/src/i18n/merge.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
- import { elementText, blockElements } from './extract.js'
17
+ import { elementText, blockElements, translationContext, SITE_META_CONTEXT } from './extract.js'
18
18
  import { visitDataStrings } from './data-strings.js'
19
19
 
20
20
  // Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
@@ -71,6 +71,7 @@ export function mergeTranslations(siteContent, translations, options = {}) {
71
71
  function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
72
72
  // Deep clone to avoid mutating original
73
73
  const translated = JSON.parse(JSON.stringify(siteContent))
74
+ translateSiteMeta(translated.config, translations, fallbackToSource)
74
75
 
75
76
  for (const page of translated.pages || []) {
76
77
  const pageRoute = page.route || '/'
@@ -120,6 +121,7 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
120
121
 
121
122
  // Deep clone to avoid mutating original
122
123
  const translated = JSON.parse(JSON.stringify(siteContent))
124
+ translateSiteMeta(translated.config, translations, fallbackToSource)
123
125
 
124
126
  for (const page of translated.pages || []) {
125
127
  const pageRoute = page.route || '/'
@@ -222,6 +224,18 @@ function toPlainText(value) {
222
224
  * Every field here is a PLAIN-TEXT slot — `<title>`, a nav label, a meta
223
225
  * description — so each value is flattened. See `toPlainText`.
224
226
  */
227
+ // The site's own Open Graph title and description (`config.seo`), in the site's context — as a page's
228
+ // are in its own (`translatePageMeta`).
229
+ function translateSiteMeta(config, translations, fallbackToSource) {
230
+ const seo = config?.seo
231
+ if (!seo || typeof seo !== 'object') return
232
+ for (const key of ['ogTitle', 'ogDescription']) {
233
+ if (typeof seo[key] === 'string' && seo[key]) {
234
+ seo[key] = lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource)
235
+ }
236
+ }
237
+ }
238
+
225
239
  function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
226
240
  const context = { page: pageRoute, section: '_meta' }
227
241
  const translate = (value) =>
@@ -268,8 +282,7 @@ function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
268
282
  * Translate a section's content (synchronous, hash-based only)
269
283
  */
270
284
  function translateSectionSync(section, pageRoute, translations, fallbackToSource) {
271
- const sectionId = section.id || 'unknown'
272
- const context = { page: pageRoute, section: sectionId }
285
+ const context = translationContext(section, pageRoute)
273
286
 
274
287
  if (section.content?.type === 'doc') {
275
288
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
@@ -292,8 +305,7 @@ function translateSectionSync(section, pageRoute, translations, fallbackToSource
292
305
  async function translateSectionAsync(section, page, translations, options) {
293
306
  const { fallbackToSource, locale, localesDir } = options
294
307
  const pageRoute = page.route || '/'
295
- const sectionId = section.id || 'unknown'
296
- const context = { page: pageRoute, section: sectionId }
308
+ const context = translationContext(section, pageRoute)
297
309
 
298
310
  // Check for free-form translation first
299
311
  const freeform = await loadFreeformTranslation(section, page, locale, localesDir)
@@ -355,23 +367,27 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
355
367
  * in place. The other half of the fix in `extract.js` — a manifest entry nobody
356
368
  * applies is worse than no entry, because a translator has already done the work.
357
369
  *
358
- * Called from the two BUILD-lane section walks only, deliberately, and NOT from
359
- * `resolveDocForLocale`. See `extractFromDataBlocks` for why: the sync wire's
360
- * structural map is derived by walking block elements, so a translated data
361
- * payload there would be neither captured nor flagged as divergent, and would be
362
- * dropped on the next pull. Losing a translation silently is worse than not
363
- * carrying one yet.
370
+ * Called from the build's two section walks and from `resolveDocForLocale`, the
371
+ * push's. ⛔ The push was left out deliberately until 2026-09-26, because the
372
+ * pull could not read a translated data block back and would have dropped it
373
+ * silently; it now does (`uwx/locale-sync.js::deriveStructuralMap`), so the two
374
+ * halves travel together.
375
+ *
376
+ * @returns {boolean} whether any value was translated
364
377
  */
365
378
  function translateDataBlocks(doc, context, translations, fallbackToSource) {
379
+ let changed = false
366
380
  const walk = (nodes) => {
367
381
  for (const node of nodes || []) {
368
382
  if (!node) continue
369
383
  if (node.type === 'dataBlock') {
370
384
  const data = node.attrs?.data
371
385
  if (data && typeof data === 'object') {
372
- visitDataStrings(data, (value) =>
373
- lookupTranslation(value, context, translations, fallbackToSource)
374
- )
386
+ visitDataStrings(data, (value) => {
387
+ const translated = lookupTranslation(value, context, translations, fallbackToSource)
388
+ if (translated !== value) changed = true
389
+ return translated
390
+ })
375
391
  }
376
392
  } else if (Array.isArray(node.content)) {
377
393
  walk(node.content)
@@ -379,6 +395,7 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
379
395
  }
380
396
  }
381
397
  walk(doc?.content)
398
+ return changed
382
399
  }
383
400
 
384
401
  /**
@@ -393,8 +410,9 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
393
410
  export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }) {
394
411
  if (!sourceDoc || sourceDoc.type !== 'doc' || !table) return null
395
412
  const doc = JSON.parse(JSON.stringify(sourceDoc))
396
- const changed = translateProseMirrorDoc(doc, context, table, true)
397
- return changed ? doc : null
413
+ const elements = translateProseMirrorDoc(doc, context, table, true)
414
+ const data = translateDataBlocks(doc, context, table, true)
415
+ return elements || data ? doc : null
398
416
  }
399
417
 
400
418
  // Parse an inline-markdown translation value into a ProseMirror inline fragment.
@@ -440,7 +458,8 @@ function lookupTranslation(source, context, translations, fallbackToSource) {
440
458
 
441
459
  // Handle translation with overrides
442
460
  if (typeof translation === 'object') {
443
- const contextKey = `${context.page}:${context.section}`
461
+ // A record's context is its identity (`context.key`, `records.js`); a section's, its page and id.
462
+ const contextKey = context?.key ?? `${context.page}:${context.section}`
444
463
 
445
464
  // Check for context-specific override
446
465
  if (translation.overrides?.[contextKey]) {