@uniweb/build 0.80.1 → 0.80.2

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.80.1",
3
+ "version": "0.80.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,10 +58,10 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.8",
61
- "@uniweb/semantic-parser": "^1.5.1",
62
- "@uniweb/projections": "^0.7.15",
63
- "@uniweb/schemas": "^0.13.0",
64
61
  "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/schemas": "^0.13.0",
63
+ "@uniweb/projections": "^0.7.15",
64
+ "@uniweb/semantic-parser": "^1.5.1",
65
65
  "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
@@ -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.38.0",
78
- "@uniweb/runtime": "^0.29.4"
77
+ "@uniweb/runtime": "^0.29.4",
78
+ "@uniweb/core": "^0.38.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/i18n/merge.js CHANGED
@@ -443,7 +443,7 @@ function linkRuns(source) {
443
443
  runs.push({ link, marks: [...node.marks], before: gap })
444
444
  }
445
445
  gap = []
446
- } else if (node.type === 'hardBreak' || (node.type === 'text' && !node.marks?.length && !node.text?.trim())) {
446
+ } else if (isGap(node)) {
447
447
  gap.push(node)
448
448
  } else {
449
449
  return null
@@ -452,6 +452,49 @@ function linkRuns(source) {
452
452
  return runs.length ? runs : null
453
453
  }
454
454
 
455
+ // What may sit between two links of an element made only of links: a line break, or spaces.
456
+ function isGap(node) {
457
+ return node.type === 'hardBreak' || (node.type === 'text' && !node.marks?.length && !node.text?.trim())
458
+ }
459
+
460
+ /**
461
+ * The labels of a translated element made only of links — `asLinkLabels` the other way, for a
462
+ * pull: one run of the target's own nodes per link of the source's, each without the marks its
463
+ * link carries. Null when the source is not only links, or the target's links are not the source's
464
+ * own, one to one and in order.
465
+ *
466
+ * ⚠️ It does not ask whether the merge reads the labels back to the target — a caller does
467
+ * (`uwx/locale-sync.js`), since only then are they the same translation.
468
+ *
469
+ * @param {Array} source - the element's source inline content
470
+ * @param {Array} target - the element's translated inline content
471
+ * @returns {Array<Array>|null} each link's label, as inline nodes
472
+ */
473
+ export function linkLabels(source, target) {
474
+ const links = linkRuns(source)
475
+ if (!links) return null
476
+ const labels = []
477
+ let current = null
478
+ for (const node of target || []) {
479
+ if (!node) continue
480
+ const link = linkMarkOf(node)
481
+ if (link) {
482
+ if (!current || !sameMark(current, link)) {
483
+ const next = links[labels.length]
484
+ if (!next || !sameMark(next.link, link)) return null
485
+ labels.push([])
486
+ current = link
487
+ }
488
+ labels[labels.length - 1].push(withoutMarks(node, links[labels.length - 1].marks))
489
+ } else if (isGap(node)) {
490
+ current = null
491
+ } else {
492
+ return null
493
+ }
494
+ }
495
+ return labels.length === links.length ? labels : null
496
+ }
497
+
455
498
  function linkMarkOf(node) {
456
499
  return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
457
500
  }
@@ -493,6 +536,13 @@ function withMarks(node, marks) {
493
536
  return { ...node, marks: [...marks, ...own] }
494
537
  }
495
538
 
539
+ // A label node without its link's marks — `withMarks` the other way.
540
+ function withoutMarks(node, marks) {
541
+ const own = (node.marks || []).filter((mark) => !marks.some((linkMark) => linkMark.type === mark.type))
542
+ const { marks: _linkMarks, ...rest } = node
543
+ return own.length ? { ...rest, marks: own } : rest
544
+ }
545
+
496
546
  /**
497
547
  * Translate the human-readable strings inside a section's TAGGED DATA BLOCKS,
498
548
  * in place. The other half of the fix in `extract.js` — a manifest entry nobody
@@ -24,7 +24,7 @@ import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-w
24
24
  import { computeHash } from '../i18n/hash.js'
25
25
  import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
26
26
  import { visitDataBlockStrings } from '../i18n/data-strings.js'
27
- import { resolveDocForLocale } from '../i18n/merge.js'
27
+ import { resolveDocForLocale, translatedInline, linkLabels } from '../i18n/merge.js'
28
28
  import { computeSourceHash } from '../i18n/freeform-manifest.js'
29
29
  import { sameMarkdownDocument, canonicalJson } from './same-content.js'
30
30
 
@@ -380,7 +380,7 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
380
380
  const srcMd = serializeInlineContent(src[i].content || [])
381
381
  const tgtMd = serializeInlineContent(tgt[i].content || [])
382
382
  if (srcMd === tgtMd) continue // unchanged → untranslated → omit
383
- map[key] = tgtMd
383
+ map[key] = labelsAlone(src[i].content || [], tgt[i].content || [], tgtMd) ?? tgtMd
384
384
  }
385
385
  } catch {
386
386
  // a mark we can't serialize → don't risk a lossy map; store as free-form
@@ -389,6 +389,22 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
389
389
  return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
390
390
  }
391
391
 
392
+ // ⭐ A LINK'S LABEL, NOT THE LINK, when only its words were translated. The merge reads a label onto
393
+ // the source's own link (`merge.js::translatedInline`), so it keeps following that link wherever the
394
+ // source moves it, where `[Donar Ahora](/contact)` would keep `/contact`. Several links are a label a
395
+ // line, as a translator writes them. Written only when the merge reads it back to this very element;
396
+ // a link a translator changed — the Spanish button pointed at a Spanish page — is written whole.
397
+ // ⛔ Until 2026-10-08 the element was always written whole: once the merge read a label (2026-10-01),
398
+ // a pull into the copy that pushed rewrote `"Donar Ahora"` as `"[Donar Ahora](/contact)"`, and split a
399
+ // string a page title and a button share into a default and per-place overrides.
400
+ function labelsAlone(source, target, whole) {
401
+ const labels = linkLabels(source, target)
402
+ if (!labels) return null
403
+ const value = labels.map((nodes) => serializeInlineContent(nodes)).join('\n')
404
+ const back = translatedInline(source, value)
405
+ return back && serializeInlineContent(back) === whole ? value : null
406
+ }
407
+
392
408
  // ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
393
409
  // (`visitDataBlockStrings`: a block's declared shape, else the extractor's heuristic), keyed by their
394
410
  // source text as the build keys them. ⭐ Walked by the same model the push translated with — the pairing