@uniweb/build 1.3.3 → 1.3.5

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": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,15 +56,15 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
+ "@uniweb/content-reader": "~1.4.0",
59
60
  "@uniweb/projections": "~1.1.1",
60
- "@uniweb/theming": "~1.2.0",
61
- "@uniweb/semantic-parser": "~1.8.0",
62
61
  "@uniweb/content-writer": "~1.1.2",
63
62
  "@uniweb/schemas": "~1.3.0",
64
- "@uniweb/content-reader": "~1.3.2"
63
+ "@uniweb/semantic-parser": "~1.8.1",
64
+ "@uniweb/theming": "~1.2.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.3"
67
+ "@uniweb/runtime": "~1.1.4"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -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/core": "~1.2.2",
77
- "@uniweb/runtime": "~1.1.3"
76
+ "@uniweb/runtime": "~1.1.4",
77
+ "@uniweb/core": "~1.2.2"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/i18n/merge.js CHANGED
@@ -392,7 +392,7 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
392
392
  export function translatedInline(source, value) {
393
393
  const fragment = inlineMarkdownToFragment(value)
394
394
  if (!fragment) return null
395
- return asLinkLabels(source, fragment) || fragment
395
+ return asLinkLabels(source, fragment, value) || fragment
396
396
  }
397
397
 
398
398
  /**
@@ -410,13 +410,13 @@ export function translationKeepsMarkup(markup, value) {
410
410
  }
411
411
 
412
412
  // The source's links with the fragment as their labels — the whole fragment for one link, a line
413
- // each for several — or null: the fragment names a link of its own, the source is not only links,
414
- // or the lines do not match the links one to one.
415
- function asLinkLabels(source, fragment) {
413
+ // of the value each for several — or null: the fragment names a link of its own, the source is not
414
+ // only links, or the lines do not match the links one to one.
415
+ function asLinkLabels(source, fragment, value) {
416
416
  if (fragment.some((node) => linkMarkOf(node))) return null
417
417
  const links = linkRuns(source)
418
418
  if (!links) return null
419
- const labels = links.length === 1 ? [trimInline(fragment)] : inlineLines(fragment)
419
+ const labels = links.length === 1 ? [trimInline(fragment)] : valueLines(value)
420
420
  if (labels.length !== links.length || labels.some((label) => !label.length)) return null
421
421
  const out = []
422
422
  links.forEach((link, i) => {
@@ -503,6 +503,16 @@ function sameMark(a, b) {
503
503
  return Boolean(a && b) && a.type === b.type && JSON.stringify(a.attrs || {}) === JSON.stringify(b.attrs || {})
504
504
  }
505
505
 
506
+ // A value's lines as the translator wrote them, each read as inline markdown and split again at a
507
+ // line break written in it (`<br>`), each trimmed; blank lines dropped. ⭐ Split BEFORE it is read:
508
+ // a plain newline inside a paragraph reads as a space (2026-10-16), so the value, read whole, has no
509
+ // lines left. ⛔ Until then the read value was split, at the newlines it kept.
510
+ function valueLines(value) {
511
+ return String(value ?? '')
512
+ .split('\n')
513
+ .flatMap((line) => inlineLines(inlineMarkdownToFragment(line) || []))
514
+ }
515
+
506
516
  // A fragment's lines, split at line breaks, each trimmed; blank lines dropped.
507
517
  function inlineLines(fragment) {
508
518
  const lines = [[]]
@@ -1321,7 +1321,9 @@ function normalizeLayoutConfig(raw) {
1321
1321
  const out = {}
1322
1322
  if (typeof raw.name === 'string' && raw.name) out.name = raw.name
1323
1323
  if (Array.isArray(raw.hide)) out.hide = raw.hide
1324
- if (raw.params && typeof raw.params === 'object') out.params = raw.params
1324
+ // A map, never an array: `params: []` passed a bare `typeof` check and replaced the inherited
1325
+ // params with a list nothing reads.
1326
+ if (raw.params && typeof raw.params === 'object' && !Array.isArray(raw.params)) out.params = raw.params
1325
1327
  return out
1326
1328
  }
1327
1329
 
@@ -23,27 +23,23 @@ import { renderEntityDocument } from './backfill.js'
23
23
  import { queriesYmlPath } from './queries-config.js'
24
24
  import { folderYmlPath } from '../site/records-config.js'
25
25
  import { editYamlText, YAML_DUMP_OPTS } from './yaml-edit.js'
26
- import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
26
+ import { isSectionKey } from '@uniweb/schemas/section'
27
+ import { DECLARATION_KEYS, authorableDeclaration } from '../site/fetch-shapes.js'
27
28
 
28
- // Frontmatter keys that belong to the CCA framework / the developer's local
29
- // authoring, not to externally-editable params. On a section write an existing
30
- // reserved key is preserved and never overwritten by incoming params, so a
31
- // projection doesn't churn fields it didn't author (the surgical-update bar).
29
+ // Frontmatter keys a section write keeps from the file when the file has them.
32
30
  //
33
- // ⭐ The declaration keys — `query`, `fetch`, and the retired `data` — are one
34
- // group: a section that declares its data locally, under any of them, keeps that
35
- // declaration, and an incoming one under a different key does not land beside it
36
- // (a file holding two is refused by the build).
31
+ // ⛔ Not `type`, `query`, `fetch` or the retired `data`, kept here as the developer's own from
32
+ // the start (2026-05-30) until 2026-10-16. A pull is a checkout: a teammate's push changes
33
+ // them, and an editor changes a section's data declaration. A pull that kept the file's
34
+ // value still recorded the backend's version as taken, so the next edit to the section
35
+ // pushed the file's value back over the backend's, with no refusal (measured with a
36
+ // section's `fetch:`). The declaration is written by `writeSectionFile`'s `declaration`.
37
+ // ⛔ Not `hidden` either: an editor toggles it, so a pull must carry the store's value into
38
+ // the file (2026-09-28).
37
39
  export const DEFAULT_RESERVED_FRONTMATTER = new Set([
38
- 'type',
39
40
  'preset',
40
41
  'input',
41
- 'query',
42
- 'fetch',
43
- 'data',
44
42
  'nest',
45
- // ⛔ Not `hidden`, which was here from the start (2026-05-30) with no reason recorded: an
46
- // editor toggles it, so a pull must carry the store's value into the file (2026-09-28).
47
43
  ])
48
44
 
49
45
  /**
@@ -154,22 +150,30 @@ function assembleSection(frontmatter, body) {
154
150
  }
155
151
 
156
152
  /**
157
- * Write a section's `.md` file: merge incoming `params` into the existing
158
- * frontmatter (preserving reserved keys), serialize `content` (ProseMirror) to
159
- * the markdown body, and write idempotently. When the file is new there is no
160
- * existing frontmatter/body to preserve. When `content` is omitted, the existing
153
+ * Write a section's `.md` file as the backend holds the section: its `params` and its
154
+ * data `declaration` replace the file's, `content` (ProseMirror) becomes the markdown
155
+ * body, and the file is written only when that changes it. When the file is new there
156
+ * is no existing frontmatter/body to preserve. When `content` is omitted, the existing
161
157
  * body is kept (a params-only update).
162
158
  *
159
+ * ⭐ `params` is the section's whole set: a param the file holds and `params` does not —
160
+ * one a push would send (`isSectionKey`, the collector's own test) — is removed. ⛔ Until
161
+ * 2026-10-16 incoming params were merged into the file's, so a param removed elsewhere
162
+ * stayed in the file, and the next edit to the section pushed it back.
163
+ *
163
164
  * @param {object} opts
164
165
  * @param {string} opts.filePath
165
166
  * @param {object} [opts.content] - ProseMirror document for the body
166
- * @param {object} [opts.params] - incoming frontmatter params to merge
167
+ * @param {object} [opts.params] - the section's frontmatter params as the backend holds them
168
+ * @param {object|object[]|null} [opts.declaration] - the section's `fetch` off the wire, written
169
+ * under the declaration key the file uses (`authorableDeclaration`); `null` when the
170
+ * section declares none, which removes the file's. Left out, the file's declaration stays
167
171
  * @param {Set<string>} [opts.reserved] - keys preserved when already present
168
172
  * @param {object} [opts.implied] - `{ key: value }` a file means by leaving `key` out; an incoming
169
173
  * value equal to it is not written into an existing file that leaves the key out
170
174
  * @returns {'updated'|'unchanged'}
171
175
  */
172
- export function writeSectionFile({ filePath, content, params, reserved = DEFAULT_RESERVED_FRONTMATTER, implied = null }) {
176
+ export function writeSectionFile({ filePath, content, params, declaration, reserved = DEFAULT_RESERVED_FRONTMATTER, implied = null }) {
173
177
  let existing = ''
174
178
  try {
175
179
  existing = readFileSync(filePath, 'utf8')
@@ -179,21 +183,29 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
179
183
  const { frontmatter, body: existingBody } = parseFrontmatter(existing, filePath)
180
184
 
181
185
  const nextFrontmatter = { ...frontmatter }
182
- const declaresLocally = DECLARATION_KEYS.some((k) => k in frontmatter)
183
186
  if (params) {
184
187
  for (const [key, value] of Object.entries(params)) {
185
- // A reserved key is preserved only when it already exists locally (the
186
- // developer owns it; an incoming edit must not clobber it). On a new file
187
- // there is nothing to protect, so the incoming value fills it — that's
188
- // how a newly-projected section gets its `type`/`nest`/etc.
188
+ // A reserved key is preserved only when it already exists locally. On a new file there is
189
+ // nothing to protect, so the incoming value fills it.
189
190
  if (reserved.has(key) && key in frontmatter) continue
190
- if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
191
191
  // A value the file implies by leaving the key out (`implied`) is not written into a file that
192
192
  // exists — a new file has nothing to imply it with.
193
193
  if (implied && existing && !(key in frontmatter) && implied[key] === value) continue
194
194
  if (value === null || value === undefined) delete nextFrontmatter[key]
195
195
  else nextFrontmatter[key] = value
196
196
  }
197
+ for (const key of Object.keys(frontmatter)) {
198
+ if (key in params || reserved.has(key) || key === 'fetch' || isSectionKey(key)) continue
199
+ delete nextFrontmatter[key]
200
+ }
201
+ }
202
+ // ⭐ The declaration keys — `query`, `fetch`, and the retired `data` — are one group: exactly one
203
+ // is written, under the key the file already uses where it can say the declaration, and the
204
+ // others are removed (a file holding two is refused by the build).
205
+ if (declaration !== undefined) {
206
+ const incoming = declaration === null ? null : authorableDeclaration(declaration, frontmatter)
207
+ for (const key of DECLARATION_KEYS) if (key !== incoming?.key) delete nextFrontmatter[key]
208
+ if (incoming) nextFrontmatter[incoming.key] = incoming.value
197
209
  }
198
210
 
199
211
  // ⭐ AN UNCHANGED BODY KEEPS THE AUTHOR'S OWN TEXT. A body that parses to the document the pull brought
@@ -405,20 +405,23 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
405
405
  // `params`, so one stored there counts when the field is absent.
406
406
  frontmatter.hidden = (typeof hidden === 'boolean' ? hidden : paramHidden === true) ? true : undefined
407
407
  Object.assign(frontmatter, paramsRest)
408
- // Invert the build's resolution rather than copy it — see fetch-shapes.js. A new
409
- // file gets `query:` for a declaration of names alone; a section that declares
410
- // its data locally keeps it (`writeSectionFile`, the declaration keys).
411
- // ⭐ From `params.fetch` — where a section carries its data since 2026-09-28.
412
- if (paramFetch !== undefined) {
413
- const { key, value } = authorableDeclaration(paramFetch)
414
- frontmatter[key] = value
415
- }
416
408
  // `id:` only where the file's name does not already give it (`writeId`, `pageSectionsToFiles`).
417
409
  if (stable_id !== undefined && writeId) frontmatter.id = stable_id
418
410
 
411
+ // ⭐ The data declaration, from `params.fetch` — where a section carries its data since
412
+ // 2026-09-28 — is the backend's: written under the key the file already uses, else `query:`
413
+ // for names alone (`authorableDeclaration`, the inverse of the build's resolution), and
414
+ // removed from the file of a section that declares none. ⛔ Until 2026-10-16 a file's own
415
+ // declaration was kept over the backend's, and the next edit to the section pushed it back.
419
416
  // ⛔ Not `type: Content` into a file that names no type: it is what the push sent for it, and
420
417
  // absent, the foundation's `defaultSection` renders it. Until 2026-09-26 a pull wrote it back.
421
- return writeSectionFile({ filePath, content: sourceContent, params: frontmatter, implied: { type: FILLED_SECTION_TYPE } })
418
+ return writeSectionFile({
419
+ filePath,
420
+ content: sourceContent,
421
+ params: frontmatter,
422
+ declaration: paramFetch ?? null,
423
+ implied: { type: FILLED_SECTION_TYPE },
424
+ })
422
425
  }
423
426
 
424
427
  // ---------------------------------------------------------------------------