@uniweb/build 0.63.3 → 0.64.0

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.3",
3
+ "version": "0.64.0",
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-reader": "^1.2.5",
61
- "@uniweb/content-writer": "^0.3.4",
60
+ "@uniweb/projections": "^0.7.4",
62
61
  "@uniweb/semantic-parser": "^1.4.1",
63
- "@uniweb/projections": "^0.7.3",
64
62
  "@uniweb/schemas": "^0.7.0",
65
- "@uniweb/theming": "^0.1.15"
63
+ "@uniweb/theming": "^0.1.15",
64
+ "@uniweb/content-writer": "^0.3.4",
65
+ "@uniweb/content-reader": "^1.2.5"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.4"
68
+ "@uniweb/runtime": "^0.27.5"
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.0",
78
+ "@uniweb/runtime": "^0.27.5"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -127,6 +127,23 @@ function extractFromPageMeta(page, pageRoute, units) {
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * Where a translation applies — `{ page, section }`, the key a context-specific override is written
132
+ * under (`"<page>:<section>"`): the page's route and the section's STABLE id. One rule for the
133
+ * extractor, the build (`merge.js`), the push and the pull.
134
+ *
135
+ * ⛔ The section was its build `id` until 2026-09-26 — a numbered file's number (`/about:1` for
136
+ * `1-hero.md`) — so an override moved to another section when files were renumbered, and a clone,
137
+ * whose files are not numbered, could not key it at all.
138
+ *
139
+ * @param {Object} section - a section with `stableId` (and the build `id` as a fallback)
140
+ * @param {string} pageRoute - the route of the page (or layout area) it is on
141
+ * @returns {{ page: string, section: string }}
142
+ */
143
+ export function translationContext(section, pageRoute) {
144
+ return { page: pageRoute, section: section?.stableId || section?.id || 'unknown' }
145
+ }
146
+
130
147
  /**
131
148
  * Extract translatable content from a section
132
149
  * @param {Object} section - Section data
@@ -134,8 +151,7 @@ function extractFromPageMeta(page, pageRoute, units) {
134
151
  * @param {Object} units - Units accumulator
135
152
  */
136
153
  function extractFromSection(section, pageRoute, units) {
137
- const sectionId = section.id || 'unknown'
138
- const context = { page: pageRoute, section: sectionId }
154
+ const context = translationContext(section, pageRoute)
139
155
 
140
156
  // Extract from parsed semantic content if available
141
157
  // The section.content is ProseMirror doc, but we need parsed content
@@ -173,10 +189,11 @@ function extractFromSection(section, pageRoute, units) {
173
189
  * than tidy: `deriveStructuralMap` recovers a pulled translation by walking
174
190
  * block elements, so a data string translated on the sync wire would be
175
191
  * 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.
192
+ * would be silently lost on the next pull. ⭐ Since 2026-09-26 both halves exist:
193
+ * the push translates data blocks (`merge.js::resolveDocForLocale`) and the pull
194
+ * reads their strings back pairwise (`uwx/locale-sync.js::deriveStructuralMap`),
195
+ * as the same per-string entries this lane writes — the block-element keying
196
+ * contract is untouched.
180
197
  */
181
198
  function extractFromDataBlocks(doc, context, units) {
182
199
  for (const node of dataBlockNodes(doc)) {
@@ -192,7 +209,7 @@ function extractFromDataBlocks(doc, context, units) {
192
209
  /**
193
210
  * Every tagged data block in a doc, including any nested inside a container.
194
211
  */
195
- function dataBlockNodes(doc) {
212
+ export function dataBlockNodes(doc) {
196
213
  const out = []
197
214
  const walk = (nodes) => {
198
215
  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/index.js CHANGED
@@ -7,11 +7,12 @@
7
7
  * import { extractManifest, syncManifest, mergeLocale } from '@uniweb/build/i18n'
8
8
  */
9
9
 
10
- import { readFile, writeFile, mkdir, readdir } from 'fs/promises'
10
+ import { readFile, writeFile, mkdir } from 'fs/promises'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join, dirname } from 'node:path'
13
13
 
14
14
  import { computeHash, normalizeText } from './hash.js'
15
+ import { availableLocales, resolveLocaleList } from './locales.js'
15
16
  import { extractTranslatableContent } from './extract.js'
16
17
  import { syncManifests, formatSyncReport } from './sync.js'
17
18
  import { mergeTranslations, generateAllLocales } from './merge.js'
@@ -118,7 +119,6 @@ const DEFAULTS = {
118
119
  /**
119
120
  * Reserved files in the locales directory (not locale translation files)
120
121
  */
121
- const RESERVED_FILES = new Set(['manifest.json', '_memory.json'])
122
122
 
123
123
  /**
124
124
  * Get available locales by scanning the locales directory for *.json files
@@ -126,19 +126,7 @@ const RESERVED_FILES = new Set(['manifest.json', '_memory.json'])
126
126
  * @returns {Promise<string[]>} Array of locale codes found
127
127
  */
128
128
  async function getAvailableLocales(localesPath) {
129
- if (!existsSync(localesPath)) {
130
- return []
131
- }
132
-
133
- try {
134
- const files = await readdir(localesPath)
135
- return files
136
- .filter(f => f.endsWith('.json') && !RESERVED_FILES.has(f))
137
- .map(f => f.replace('.json', ''))
138
- .sort()
139
- } catch {
140
- return []
141
- }
129
+ return availableLocales(localesPath)
142
130
  }
143
131
 
144
132
  /**
@@ -154,23 +142,8 @@ async function getAvailableLocales(localesPath) {
154
142
  * @returns {Promise<string[]>} Resolved array of locale codes
155
143
  */
156
144
  async function resolveLocales(configLocales, localesPath) {
157
- // Explicit list of locales
158
- if (Array.isArray(configLocales) && configLocales.length > 0) {
159
- // Check for '*' in array (e.g., locales: ['*'])
160
- if (configLocales.includes('*')) {
161
- return getAvailableLocales(localesPath)
162
- }
163
- // Normalize: support both string codes and objects ({code, label})
164
- return configLocales.map(l => typeof l === 'string' ? l : l.code)
165
- }
166
-
167
- // String value '*' means all available
168
- if (configLocales === '*') {
169
- return getAvailableLocales(localesPath)
170
- }
171
-
172
- // undefined, null, or empty array → all available
173
- return getAvailableLocales(localesPath)
145
+ // The one rule, shared with the push (`uwx/site.js`) — see `./locales.js`.
146
+ return resolveLocaleList(configLocales, localesPath)
174
147
  }
175
148
 
176
149
  /**
@@ -0,0 +1,51 @@
1
+ // ⭐ WHICH LOCALES A SITE HAS — one rule, for the build and for the push.
2
+ //
3
+ // An explicit `languages:` list is the set, as written. `'*'`, or no `languages:` at all, is every
4
+ // translation file in the locales directory. ⛔ Until 2026-09-25 the push read an explicit
5
+ // `languages:` alone, so a site whose set comes from its files — the `international` template,
6
+ // which has none — was pushed as a one-language site, and none of its translations left the
7
+ // machine, while the build of the same site produced every one of them.
8
+ //
9
+ // A leaf, with nothing but `node:fs` beneath it, so the sync lane can use it without pulling in
10
+ // the rest of the i18n module.
11
+
12
+ import { existsSync, readdirSync } from 'node:fs'
13
+
14
+ // Files in the locales directory that are not a locale's translations.
15
+ export const RESERVED_LOCALE_FILES = new Set(['manifest.json', '_memory.json'])
16
+
17
+ /**
18
+ * The locales a directory holds translations for — one per `<locale>.json` — sorted.
19
+ *
20
+ * @param {string} localesPath
21
+ * @returns {string[]}
22
+ */
23
+ export function availableLocales(localesPath) {
24
+ if (!existsSync(localesPath)) return []
25
+ try {
26
+ return readdirSync(localesPath)
27
+ .filter((f) => f.endsWith('.json') && !RESERVED_LOCALE_FILES.has(f))
28
+ .map((f) => f.slice(0, -'.json'.length))
29
+ .sort()
30
+ } catch {
31
+ return []
32
+ }
33
+ }
34
+
35
+ /**
36
+ * A site's locales, from its `languages:` and its locales directory.
37
+ *
38
+ * - `['es', 'fr']` → those, as written (an entry may be `{ code, label }`)
39
+ * - `'*'`, a list holding `'*'`, or nothing → every locale the directory holds
40
+ *
41
+ * @param {string[]|string|undefined} configLocales - `site.yml::languages`
42
+ * @param {string} localesPath
43
+ * @returns {string[]}
44
+ */
45
+ export function resolveLocaleList(configLocales, localesPath) {
46
+ if (Array.isArray(configLocales) && configLocales.length > 0) {
47
+ if (configLocales.includes('*')) return availableLocales(localesPath)
48
+ return configLocales.map((l) => (typeof l === 'string' ? l : l.code))
49
+ }
50
+ return availableLocales(localesPath)
51
+ }
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 } 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
@@ -268,8 +268,7 @@ function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
268
268
  * Translate a section's content (synchronous, hash-based only)
269
269
  */
270
270
  function translateSectionSync(section, pageRoute, translations, fallbackToSource) {
271
- const sectionId = section.id || 'unknown'
272
- const context = { page: pageRoute, section: sectionId }
271
+ const context = translationContext(section, pageRoute)
273
272
 
274
273
  if (section.content?.type === 'doc') {
275
274
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
@@ -292,8 +291,7 @@ function translateSectionSync(section, pageRoute, translations, fallbackToSource
292
291
  async function translateSectionAsync(section, page, translations, options) {
293
292
  const { fallbackToSource, locale, localesDir } = options
294
293
  const pageRoute = page.route || '/'
295
- const sectionId = section.id || 'unknown'
296
- const context = { page: pageRoute, section: sectionId }
294
+ const context = translationContext(section, pageRoute)
297
295
 
298
296
  // Check for free-form translation first
299
297
  const freeform = await loadFreeformTranslation(section, page, locale, localesDir)
@@ -355,23 +353,27 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
355
353
  * in place. The other half of the fix in `extract.js` — a manifest entry nobody
356
354
  * applies is worse than no entry, because a translator has already done the work.
357
355
  *
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.
356
+ * Called from the build's two section walks and from `resolveDocForLocale`, the
357
+ * push's. ⛔ The push was left out deliberately until 2026-09-26, because the
358
+ * pull could not read a translated data block back and would have dropped it
359
+ * silently; it now does (`uwx/locale-sync.js::deriveStructuralMap`), so the two
360
+ * halves travel together.
361
+ *
362
+ * @returns {boolean} whether any value was translated
364
363
  */
365
364
  function translateDataBlocks(doc, context, translations, fallbackToSource) {
365
+ let changed = false
366
366
  const walk = (nodes) => {
367
367
  for (const node of nodes || []) {
368
368
  if (!node) continue
369
369
  if (node.type === 'dataBlock') {
370
370
  const data = node.attrs?.data
371
371
  if (data && typeof data === 'object') {
372
- visitDataStrings(data, (value) =>
373
- lookupTranslation(value, context, translations, fallbackToSource)
374
- )
372
+ visitDataStrings(data, (value) => {
373
+ const translated = lookupTranslation(value, context, translations, fallbackToSource)
374
+ if (translated !== value) changed = true
375
+ return translated
376
+ })
375
377
  }
376
378
  } else if (Array.isArray(node.content)) {
377
379
  walk(node.content)
@@ -379,6 +381,7 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
379
381
  }
380
382
  }
381
383
  walk(doc?.content)
384
+ return changed
382
385
  }
383
386
 
384
387
  /**
@@ -393,8 +396,9 @@ function translateDataBlocks(doc, context, translations, fallbackToSource) {
393
396
  export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }) {
394
397
  if (!sourceDoc || sourceDoc.type !== 'doc' || !table) return null
395
398
  const doc = JSON.parse(JSON.stringify(sourceDoc))
396
- const changed = translateProseMirrorDoc(doc, context, table, true)
397
- return changed ? doc : null
399
+ const elements = translateProseMirrorDoc(doc, context, table, true)
400
+ const data = translateDataBlocks(doc, context, table, true)
401
+ return elements || data ? doc : null
398
402
  }
399
403
 
400
404
  // Parse an inline-markdown translation value into a ProseMirror inline fragment.
@@ -440,7 +444,8 @@ function lookupTranslation(source, context, translations, fallbackToSource) {
440
444
 
441
445
  // Handle translation with overrides
442
446
  if (typeof translation === 'object') {
443
- const contextKey = `${context.page}:${context.section}`
447
+ // A record's context is its identity (`context.key`, `records.js`); a section's, its page and id.
448
+ const contextKey = context?.key ?? `${context.page}:${context.section}`
444
449
 
445
450
  // Check for context-specific override
446
451
  if (translation.overrides?.[contextKey]) {