@uniweb/build 0.80.0 → 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 +4 -4
- package/src/i18n/merge.js +51 -1
- package/src/site/queries-config.js +4 -2
- package/src/uwx/locale-sync.js +18 -2
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/records-project.js +25 -18
- package/src/uwx/site-project.js +10 -27
- package/src/uwx/yaml-edit.js +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.80.
|
|
3
|
+
"version": "0.80.2",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/
|
|
61
|
-
"@uniweb/projections": "^0.7.15",
|
|
60
|
+
"@uniweb/content-reader": "^1.2.8",
|
|
62
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
62
|
"@uniweb/schemas": "^0.13.0",
|
|
64
|
-
"@uniweb/
|
|
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": {
|
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 (
|
|
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
|
|
@@ -78,8 +78,10 @@ export const QUERIES_YML_RELPATH = 'queries.yml'
|
|
|
78
78
|
// Identity has no boundary to get wrong, and an author wanting a different schema
|
|
79
79
|
// name writes `schema:`, which is one line and says what it means.
|
|
80
80
|
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
81
|
+
// ⛔ A pull does not drop a `schema:` equal to this default: it writes the schema the site holds,
|
|
82
|
+
// in its one spelling (`uwx/records-project.js::declToFileShape`). Until 2026-10-08 it did, keeping
|
|
83
|
+
// a projected queries.yml as terse as the author left it — and a clone, with no author's file to
|
|
84
|
+
// look at, wrote `series: { schema: '@/series' }` back as `series: {}`.
|
|
83
85
|
export function defaultSchema(name) {
|
|
84
86
|
return `@/${name}`
|
|
85
87
|
}
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -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
|
|
@@ -41,7 +41,7 @@ import yaml from 'js-yaml'
|
|
|
41
41
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
42
42
|
import { parseFrontmatter } from './entity-source.js'
|
|
43
43
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
44
|
-
import {
|
|
44
|
+
import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
|
|
45
45
|
import { dataKeyTypes } from './data-key-types.js'
|
|
46
46
|
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
47
47
|
import { folderYmlPath } from '../site/records-config.js'
|
|
@@ -277,8 +277,8 @@ function setIf(obj, key, value) {
|
|
|
277
277
|
// - `path:` is written VERBATIM, and omitted entirely when it equals the default
|
|
278
278
|
// (the query's own name under the pool).
|
|
279
279
|
// - `url:` (remote source) and a bare `source:` object are carried as-is.
|
|
280
|
-
// - `schema:` is
|
|
281
|
-
//
|
|
280
|
+
// - `schema:` is written as the site holds it — the query-name default included —
|
|
281
|
+
// except a query typed by a data key, kept short where its type can be read again.
|
|
282
282
|
//
|
|
283
283
|
// ⛔ THE `collections/`-PREFIX STRIP AND THE site.yml ROUTING ARE BOTH GONE, and
|
|
284
284
|
// they went together. They existed because `collections.yml` sat INSIDE
|
|
@@ -342,24 +342,31 @@ function declToFileShape(wire, scope = null, own = null, keyTypes = null, author
|
|
|
342
342
|
decl.source = source
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// `
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
345
|
+
// ⭐ THE SCHEMA THE SITE HOLDS IS WRITTEN — one spelling per meaning [Diego, 2026-10-08]. A
|
|
346
|
+
// query left to its name's default (`series` → `@/series`) comes back with that schema stated:
|
|
347
|
+
// `series: {}` and `series: { schema: '@/series' }` read the same records, and a clone, with no
|
|
348
|
+
// file of the author's to look at, could not tell which was written. ⛔ Until 2026-10-08 a schema
|
|
349
|
+
// equal to the default stayed unwritten unless the local file stated it, so a clone turned an
|
|
350
|
+
// author's `series: { schema: '@/series' }` into `series: {}`.
|
|
351
|
+
//
|
|
352
|
+
// ⛔ EXCEPT A QUERY TYPED BY A DATA KEY, which is not a second spelling of anything: `team:` reads
|
|
353
|
+
// its own folder, `records/team/`, as the type the foundation's data key declares
|
|
354
|
+
// (`data-key-types.js`), where `team: { schema: '@/member' }` would read `records/member/`. It
|
|
355
|
+
// stays as written — short — but only where this project can read it as that type again: the
|
|
356
|
+
// foundation's types (its own, or the registered version a clone keeps) give its name the schema
|
|
357
|
+
// the site holds. Then the push's mark says so (`typed_by_data_key`), or the author's file
|
|
358
|
+
// declares it short, or — the foundation in this project — the file does not declare it. An
|
|
359
|
+
// author's stated schema is never taken for one. ⛔ With no types to read, short would mean
|
|
360
|
+
// `@/team`: the schema is written, and the records go to its folder, so the copy still pushes it.
|
|
355
361
|
const written = name ? authored?.get(name) : undefined
|
|
356
|
-
const
|
|
357
|
-
const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
|
|
362
|
+
const keyType = name && !own?.has(name) ? keyTypes?.get(name) : null
|
|
358
363
|
// In the author's form: the registered version names the type qualified (`@acme/member`).
|
|
359
364
|
const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
const typedByKey =
|
|
366
|
+
keyDefault !== null &&
|
|
367
|
+
d.schema === keyDefault &&
|
|
368
|
+
(wire.typed_by_data_key === true || written === null || (written === undefined && !markedOnly))
|
|
369
|
+
if (d.schema && !typedByKey) decl.schema = d.schema
|
|
363
370
|
setIf(decl, 'sort', d.sort)
|
|
364
371
|
setIf(decl, 'where', d.where)
|
|
365
372
|
setIf(decl, 'limit', d.limit)
|
package/src/uwx/site-project.js
CHANGED
|
@@ -47,8 +47,7 @@ import { declarationsToQueriesYml } from './records-project.js'
|
|
|
47
47
|
import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
|
|
48
48
|
import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
|
|
49
49
|
import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
50
|
-
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent
|
|
51
|
-
import { resolveLocaleList } from '../i18n/locales.js'
|
|
50
|
+
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
|
|
52
51
|
import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
|
|
53
52
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
54
53
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
@@ -214,7 +213,7 @@ function readAuthoredYaml(filePath) {
|
|
|
214
213
|
* @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
|
|
215
214
|
* write status ('updated' | 'unchanged')
|
|
216
215
|
*/
|
|
217
|
-
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false
|
|
216
|
+
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
|
|
218
217
|
const info = document?.info || {}
|
|
219
218
|
const settingsSection = document?.settings || {}
|
|
220
219
|
|
|
@@ -325,18 +324,14 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
325
324
|
}
|
|
326
325
|
}
|
|
327
326
|
|
|
328
|
-
// ⭐ `languages`
|
|
329
|
-
//
|
|
330
|
-
// files make (`uwx/site.js::settingsNested`), and
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
delete siteChanges.languages
|
|
336
|
-
}
|
|
337
|
-
|
|
327
|
+
// ⭐ `languages` IS WRITTEN AS THE SITE HOLDS IT — one spelling per meaning [Diego, 2026-10-08]:
|
|
328
|
+
// "It's definitely good for the case of languages." A site whose `site.yml` declared none sent the
|
|
329
|
+
// list its translation files make (`uwx/site.js::settingsNested`), and that list comes back stated,
|
|
330
|
+
// so a clone and the copy that pushed it say the same thing. ⛔ Until 2026-10-08 the list was held
|
|
331
|
+
// back where the translation files the pull wrote made the same one, so a clone of a site that
|
|
332
|
+
// declared `languages: [en, fr]` had no `languages:` at all — and followed its translation files
|
|
333
|
+
// where the site it came from had a fixed list.
|
|
338
334
|
const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
|
|
339
|
-
if (pendingLanguages !== undefined) result.pendingLanguages = pendingLanguages
|
|
340
335
|
|
|
341
336
|
// theme (whole object) → theme.yml.
|
|
342
337
|
if (settingsSection.theme && typeof settingsSection.theme === 'object') {
|
|
@@ -1121,7 +1116,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1121
1116
|
backend ? readBackendState(siteRoot, backend).assets || {} : {}
|
|
1122
1117
|
)
|
|
1123
1118
|
|
|
1124
|
-
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation
|
|
1119
|
+
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
|
|
1125
1120
|
report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
|
|
1126
1121
|
|
|
1127
1122
|
// The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
|
|
@@ -1162,18 +1157,6 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1162
1157
|
report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
|
|
1163
1158
|
// Target-locale FREE-FORM bodies → locales/freeform/{locale}/<relpath> + manifest.
|
|
1164
1159
|
report.freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
|
|
1165
|
-
// The site's languages, for a site.yml that declares none (`siteInfoToConfig`) — written only where
|
|
1166
|
-
// the translation files, now written, would not make the same list, or where `publishLanguages`
|
|
1167
|
-
// needs a declared list to draw from: the build refuses one without it.
|
|
1168
|
-
if (report.config?.pendingLanguages !== undefined) {
|
|
1169
|
-
const pulled = report.config.pendingLanguages
|
|
1170
|
-
delete report.config.pendingLanguages
|
|
1171
|
-
const site = readAuthoredYaml(join(siteRoot, 'site.yml')) || {}
|
|
1172
|
-
const source = site.defaultLanguage || sourceLocale
|
|
1173
|
-
const made = new Set([source, ...resolveLocaleList(undefined, localesDir(siteRoot))])
|
|
1174
|
-
const same = Array.isArray(pulled) && pulled.length === made.size && pulled.every((l) => made.has(l))
|
|
1175
|
-
if (!same || site.publishLanguages !== undefined) writeSiteConfig(siteRoot, { languages: pulled })
|
|
1176
|
-
}
|
|
1177
1160
|
writePullIndex(siteRoot, ctx.newIndex)
|
|
1178
1161
|
return report
|
|
1179
1162
|
}
|
package/src/uwx/yaml-edit.js
CHANGED
|
@@ -45,9 +45,18 @@ function columnOf(text, key) {
|
|
|
45
45
|
return /^ *$/.test(lead) ? lead.length : null
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Where a pair's text ends: after its value, its trailing comment and its newline.
|
|
50
|
+
*
|
|
51
|
+
* ⛔ A SCALAR ENDS WHERE ITS OWN TEXT DOES — `range[1]`, then the rest of that line. Its
|
|
52
|
+
* `range[2]` also spans the comment lines that follow it, and for an empty value (`team:`)
|
|
53
|
+
* every comment and blank line up to the next key, at any indentation: those belong to what
|
|
54
|
+
* comes next. ⛔ Until 2026-10-08 this read `range[2]`, and a pull that gave a bare `team:` its
|
|
55
|
+
* schema deleted the blank line and the `# Build options` heading under it in `site.yml`.
|
|
56
|
+
*/
|
|
49
57
|
function pairEnd(text, pair) {
|
|
50
|
-
|
|
58
|
+
const node = pair.value ?? pair.key
|
|
59
|
+
let end = isScalar(node) ? node.range[1] : node.range[2]
|
|
51
60
|
if (text[end - 1] !== '\n') {
|
|
52
61
|
const nl = text.indexOf('\n', end)
|
|
53
62
|
const rest = nl === -1 ? text.slice(end) : text.slice(end, nl)
|
|
@@ -169,6 +178,17 @@ function editMap(text, map, before, after, edits, dump) {
|
|
|
169
178
|
continue
|
|
170
179
|
}
|
|
171
180
|
}
|
|
181
|
+
// ⭐ A BARE KEY GIVEN A VALUE KEEPS ITS LINE — and any comment the author wrote on it — and
|
|
182
|
+
// the value goes under it: `team: # the people` gains ` schema: '@/team'` beneath.
|
|
183
|
+
const bare = isScalar(value) && value.value === null && value.range[0] === value.range[1]
|
|
184
|
+
if (bare && (isPlainObject(next) || Array.isArray(next))) {
|
|
185
|
+
const whole = indented(dump({ [key]: next }), col, false)
|
|
186
|
+
const nl = whole.indexOf('\n')
|
|
187
|
+
if (nl !== -1 && nl < whole.length - 1) {
|
|
188
|
+
edits.push({ start: stop, end: stop, text: (text[stop - 1] === '\n' ? '' : '\n') + whole.slice(nl + 1) })
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
}
|
|
172
192
|
// Written whole — in flow style when the author wrote it that way (`tags: [a, b]`).
|
|
173
193
|
const flow = (isMap(value) || isSeq(value)) && value.flow
|
|
174
194
|
edits.push({
|