@uniweb/build 0.29.1 → 0.30.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 +7 -7
- package/src/content/index.js +6 -6
- package/src/dev-backend.js +31 -31
- package/src/i18n/freeform.js +44 -24
- package/src/i18n/index.js +22 -22
- package/src/i18n/{collections.js → records.js} +114 -51
- package/src/i18n/sync.js +9 -8
- package/src/site/build-site-data.js +9 -12
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +25 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +13 -12
- package/src/site/foundation-ref.js +1 -1
- package/src/site/index.js +4 -4
- package/src/site/plugin.js +58 -63
- package/src/site/queries-config.js +324 -0
- package/src/site/{collection-processor.js → query-processor.js} +180 -95
- package/src/site/records-config.js +299 -0
- package/src/site/schemaless-data.js +2 -2
- package/src/utils/numeric-prefix.js +63 -0
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/data-schema.js +2 -2
- package/src/uwx/entity-source.js +122 -0
- package/src/uwx/folder.js +85 -77
- package/src/uwx/index.js +33 -13
- package/src/uwx/locale-sync.js +2 -2
- package/src/uwx/project-writer.js +36 -10
- package/src/uwx/queries-config.js +11 -0
- package/src/uwx/records-project.js +535 -0
- package/src/uwx/{collections.js → records.js} +152 -69
- package/src/uwx/site-diff.js +6 -6
- package/src/uwx/site-project.js +4 -4
- package/src/uwx/site.js +143 -22
- package/src/uwx/sync-package.js +32 -18
- package/src/validate-data.js +17 -19
- package/src/site/collections-config.js +0 -260
- package/src/uwx/collection-source.js +0 -180
- package/src/uwx/collections-config.js +0 -9
- package/src/uwx/collections-project.js +0 -335
- /package/src/search/{collections.js → records-index.js} +0 -0
|
@@ -16,12 +16,14 @@ import { join } from 'node:path'
|
|
|
16
16
|
import { pathToFileURL } from 'node:url'
|
|
17
17
|
import { DATA_DIR } from '@uniweb/core'
|
|
18
18
|
import { computeHash } from './hash.js'
|
|
19
|
-
import {
|
|
19
|
+
import { loadFreeformRecord } from './freeform.js'
|
|
20
20
|
// The heuristic judgement about which strings inside structured data are prose.
|
|
21
21
|
// It lives in its own module because the page lane needs exactly the same
|
|
22
22
|
// answer for a tagged data block's payload — a `label` is prose and an `href`
|
|
23
23
|
// is not, wherever the value came from. Moved rather than copied: two tuned
|
|
24
24
|
// denylists would drift, and drift here is silent.
|
|
25
|
+
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
26
|
+
import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
|
|
25
27
|
import {
|
|
26
28
|
NON_TRANSLATABLE_TYPES,
|
|
27
29
|
HEURISTIC_SKIP_FIELDS,
|
|
@@ -29,13 +31,19 @@ import {
|
|
|
29
31
|
isStructuralString,
|
|
30
32
|
} from './data-strings.js'
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
// ⛔ `records`, NOT `collections`. The manifest holds translations of RECORDS —
|
|
35
|
+
// what a site stores — and a record has nothing to do with any query that
|
|
36
|
+
// selects it. The old name kept the two conflated, and the conflation was live:
|
|
37
|
+
// contexts were keyed by the QUERY, so two queries over one schema produced two
|
|
38
|
+
// entries for one record, each invisible from the other, and a renamed query
|
|
39
|
+
// orphaned every translation under it.
|
|
40
|
+
export const RECORDS_DIR = 'records'
|
|
33
41
|
|
|
34
42
|
// ---------------------------------------------------------------------------
|
|
35
43
|
// Schema resolution
|
|
36
44
|
// ---------------------------------------------------------------------------
|
|
37
45
|
|
|
38
|
-
/** Cache for resolved schemas (
|
|
46
|
+
/** Cache for resolved schemas (query name → schema or null) */
|
|
39
47
|
const schemaCache = new Map()
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -46,13 +54,13 @@ const schemaCache = new Map()
|
|
|
46
54
|
* 2. Standard schema: @uniweb/schemas by collection name (with naive singularization)
|
|
47
55
|
* 3. null (no schema found → heuristic fallback)
|
|
48
56
|
*
|
|
49
|
-
* @param {string}
|
|
57
|
+
* @param {string} queryName
|
|
50
58
|
* @param {string} siteRoot
|
|
51
59
|
* @returns {Promise<Object|null>}
|
|
52
60
|
*/
|
|
53
|
-
async function resolveSchema(
|
|
54
|
-
if (schemaCache.has(
|
|
55
|
-
return schemaCache.get(
|
|
61
|
+
async function resolveSchema(queryName, siteRoot) {
|
|
62
|
+
if (schemaCache.has(queryName)) {
|
|
63
|
+
return schemaCache.get(queryName)
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
let schema = null
|
|
@@ -64,12 +72,12 @@ async function resolveSchema(collectionName, siteRoot) {
|
|
|
64
72
|
// an author to write into it was the one remaining place the framework
|
|
65
73
|
// contradicted its own rule that `collections/` is the only way to provide
|
|
66
74
|
// structured data. The schema describes the source, so it lives with it.
|
|
67
|
-
const companionPath = join(siteRoot,
|
|
75
|
+
const companionPath = join(siteRoot, ENTITIES_DIR, `${queryName}.schema.js`)
|
|
68
76
|
if (existsSync(companionPath)) {
|
|
69
77
|
try {
|
|
70
78
|
const mod = await import(pathToFileURL(companionPath).href)
|
|
71
79
|
schema = mod.default || mod
|
|
72
|
-
schemaCache.set(
|
|
80
|
+
schemaCache.set(queryName, schema)
|
|
73
81
|
return schema
|
|
74
82
|
} catch (err) {
|
|
75
83
|
console.warn(`[i18n] Failed to load companion schema ${companionPath}: ${err.message}`)
|
|
@@ -79,7 +87,7 @@ async function resolveSchema(collectionName, siteRoot) {
|
|
|
79
87
|
// 2. Standard schema from @uniweb/schemas (try exact name + singularized)
|
|
80
88
|
try {
|
|
81
89
|
const schemasModule = await import('@uniweb/schemas')
|
|
82
|
-
const names = [
|
|
90
|
+
const names = [queryName, singularize(queryName)]
|
|
83
91
|
|
|
84
92
|
for (const name of names) {
|
|
85
93
|
if (schemasModule.schemas?.[name]) {
|
|
@@ -91,7 +99,7 @@ async function resolveSchema(collectionName, siteRoot) {
|
|
|
91
99
|
// @uniweb/schemas not installed — that's fine
|
|
92
100
|
}
|
|
93
101
|
|
|
94
|
-
schemaCache.set(
|
|
102
|
+
schemaCache.set(queryName, schema)
|
|
95
103
|
return schema
|
|
96
104
|
}
|
|
97
105
|
|
|
@@ -151,12 +159,12 @@ function isFieldTranslatable(fieldDef) {
|
|
|
151
159
|
*
|
|
152
160
|
* @param {Object} item - Data item
|
|
153
161
|
* @param {Object} schema - Schema with `fields`
|
|
154
|
-
* @param {string}
|
|
162
|
+
* @param {string} queryName
|
|
155
163
|
* @param {Object} units - Accumulator
|
|
156
164
|
*/
|
|
157
|
-
function extractWithSchema(item, schema,
|
|
165
|
+
function extractWithSchema(item, schema, recordDir, units) {
|
|
158
166
|
const slug = item.slug || item.id || item.name || 'unknown'
|
|
159
|
-
const context = {
|
|
167
|
+
const context = { record: `${recordDir}/${slug}` }
|
|
160
168
|
|
|
161
169
|
extractFromItemWithSchema(item, schema.fields, '', context, units)
|
|
162
170
|
|
|
@@ -216,9 +224,9 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
|
|
|
216
224
|
* Extract translatable fields from an item using heuristics.
|
|
217
225
|
* Recursively walks the data, extracting strings that look like human-readable text.
|
|
218
226
|
*/
|
|
219
|
-
function extractHeuristic(item,
|
|
227
|
+
function extractHeuristic(item, recordDir, units) {
|
|
220
228
|
const slug = item.slug || item.id || item.name || 'unknown'
|
|
221
|
-
const context = {
|
|
229
|
+
const context = { record: `${recordDir}/${slug}` }
|
|
222
230
|
|
|
223
231
|
extractFromItemHeuristic(item, '', context, units, 0)
|
|
224
232
|
|
|
@@ -376,13 +384,42 @@ function translateItemHeuristic(data, context, translations, depth) {
|
|
|
376
384
|
// Main extraction entry point
|
|
377
385
|
// ---------------------------------------------------------------------------
|
|
378
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Query name → the pool directory its records live in (`article`, `std/person`).
|
|
389
|
+
*
|
|
390
|
+
* ⛔ A TRANSLATION BELONGS TO A RECORD, NOT TO A QUERY. The manifest is keyed by
|
|
391
|
+
* the record's pool identity for exactly the reason the freeform tree is: two
|
|
392
|
+
* queries can cover one schema, so keying by the query would ask an author to
|
|
393
|
+
* translate the same record once per query, find neither from the other, and
|
|
394
|
+
* lose both when a query is renamed. A record's identity is a fact about the
|
|
395
|
+
* site; a query's name is a choice.
|
|
396
|
+
*
|
|
397
|
+
* Falls back to the query name for a source with no local pool (a remote `url:`),
|
|
398
|
+
* where there is no record on disk to be identified.
|
|
399
|
+
*/
|
|
400
|
+
async function poolDirsByQuery(siteRoot) {
|
|
401
|
+
const out = new Map()
|
|
402
|
+
try {
|
|
403
|
+
const { declarations } = await resolveQueriesConfig(siteRoot)
|
|
404
|
+
for (const [name, decl] of Object.entries(declarations || {})) {
|
|
405
|
+
const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
|
|
406
|
+
out.set(name, dirs ? dirs.join('/') : name)
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// No resolvable config — every record keys by its query name, which is what
|
|
410
|
+
// the extractor did before records existed.
|
|
411
|
+
}
|
|
412
|
+
return out
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
379
416
|
/**
|
|
380
417
|
* Extract translatable content from all collections
|
|
381
418
|
* @param {string} siteRoot - Site root directory
|
|
382
419
|
* @param {Object} options - Options
|
|
383
420
|
* @returns {Promise<Object>} Manifest with translation units
|
|
384
421
|
*/
|
|
385
|
-
export async function
|
|
422
|
+
export async function extractRecordContent(siteRoot, options = {}) {
|
|
386
423
|
const dataDir = join(siteRoot, 'public', DATA_DIR)
|
|
387
424
|
|
|
388
425
|
if (!existsSync(dataDir)) {
|
|
@@ -390,6 +427,7 @@ export async function extractCollectionContent(siteRoot, options = {}) {
|
|
|
390
427
|
}
|
|
391
428
|
|
|
392
429
|
const units = {}
|
|
430
|
+
const poolDirs = await poolDirsByQuery(siteRoot)
|
|
393
431
|
|
|
394
432
|
let files
|
|
395
433
|
try {
|
|
@@ -401,7 +439,7 @@ export async function extractCollectionContent(siteRoot, options = {}) {
|
|
|
401
439
|
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
|
402
440
|
|
|
403
441
|
for (const file of jsonFiles) {
|
|
404
|
-
const
|
|
442
|
+
const queryName = file.replace('.json', '')
|
|
405
443
|
const filePath = join(dataDir, file)
|
|
406
444
|
|
|
407
445
|
try {
|
|
@@ -411,18 +449,19 @@ export async function extractCollectionContent(siteRoot, options = {}) {
|
|
|
411
449
|
if (!Array.isArray(items)) continue
|
|
412
450
|
|
|
413
451
|
// Resolve schema once per collection
|
|
414
|
-
const schema = await resolveSchema(
|
|
452
|
+
const schema = await resolveSchema(queryName, siteRoot)
|
|
453
|
+
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
415
454
|
|
|
416
455
|
for (const item of items) {
|
|
417
456
|
if (schema?.fields) {
|
|
418
|
-
extractWithSchema(item, schema,
|
|
457
|
+
extractWithSchema(item, schema, recordDir, units)
|
|
419
458
|
} else {
|
|
420
|
-
extractHeuristic(item,
|
|
459
|
+
extractHeuristic(item, recordDir, units)
|
|
421
460
|
}
|
|
422
461
|
}
|
|
423
462
|
} catch (err) {
|
|
424
463
|
// Skip files that can't be parsed
|
|
425
|
-
console.warn(`[i18n] Skipping
|
|
464
|
+
console.warn(`[i18n] Skipping ${file}: ${err.message}`)
|
|
426
465
|
}
|
|
427
466
|
}
|
|
428
467
|
|
|
@@ -512,10 +551,8 @@ function addUnit(units, source, field, context) {
|
|
|
512
551
|
if (units[hash]) {
|
|
513
552
|
const existingContexts = units[hash].contexts || []
|
|
514
553
|
units[hash].contexts = existingContexts
|
|
515
|
-
const contextKey =
|
|
516
|
-
const exists = existingContexts.some(
|
|
517
|
-
c => `${c.collection}:${c.item}` === contextKey
|
|
518
|
-
)
|
|
554
|
+
const contextKey = context.record
|
|
555
|
+
const exists = existingContexts.some((c) => c.record === contextKey)
|
|
519
556
|
if (!exists) {
|
|
520
557
|
existingContexts.push({ ...context })
|
|
521
558
|
}
|
|
@@ -539,11 +576,11 @@ function addUnit(units, source, field, context) {
|
|
|
539
576
|
* @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
|
|
540
577
|
* @returns {Promise<Object>} Map of locale to output paths
|
|
541
578
|
*/
|
|
542
|
-
export async function
|
|
579
|
+
export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
543
580
|
const {
|
|
544
581
|
locales = [],
|
|
545
582
|
outputDir = join(siteRoot, 'dist'),
|
|
546
|
-
|
|
583
|
+
recordLocalesDir = join(siteRoot, 'locales', RECORDS_DIR),
|
|
547
584
|
localesDir = join(siteRoot, 'locales'),
|
|
548
585
|
freeformEnabled = true
|
|
549
586
|
} = options
|
|
@@ -567,11 +604,20 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
567
604
|
return {}
|
|
568
605
|
}
|
|
569
606
|
|
|
607
|
+
// ⚠️ The translate side derives the record key the SAME way extraction does.
|
|
608
|
+
// These two agreeing is the whole contract, and a mismatch is SILENT: lookups
|
|
609
|
+
// simply miss and every string falls back to its source. (This was missing for
|
|
610
|
+
// a while and the failure was swallowed by the per-file catch below — the build
|
|
611
|
+
// stayed green while nothing was translated.)
|
|
612
|
+
const poolDirs = await poolDirsByQuery(siteRoot)
|
|
613
|
+
|
|
570
614
|
const outputs = {}
|
|
615
|
+
// Reported rather than only logged — see the catch below.
|
|
616
|
+
const failures = []
|
|
571
617
|
|
|
572
618
|
for (const locale of locales) {
|
|
573
619
|
// Load translations for this locale
|
|
574
|
-
const localePath = join(
|
|
620
|
+
const localePath = join(recordLocalesDir, `${locale}.json`)
|
|
575
621
|
let translations = {}
|
|
576
622
|
if (existsSync(localePath)) {
|
|
577
623
|
try {
|
|
@@ -592,7 +638,7 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
592
638
|
outputs[locale] = {}
|
|
593
639
|
|
|
594
640
|
for (const file of jsonFiles) {
|
|
595
|
-
const
|
|
641
|
+
const queryName = file.replace('.json', '')
|
|
596
642
|
const sourcePath = join(dataDir, file)
|
|
597
643
|
|
|
598
644
|
try {
|
|
@@ -603,17 +649,18 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
603
649
|
// Copy as-is if not an array
|
|
604
650
|
const destPath = join(localeDataDir, file)
|
|
605
651
|
await writeFile(destPath, raw)
|
|
606
|
-
outputs[locale][
|
|
652
|
+
outputs[locale][queryName] = destPath
|
|
607
653
|
continue
|
|
608
654
|
}
|
|
609
655
|
|
|
610
656
|
// Resolve schema once per collection
|
|
611
|
-
const schema = await resolveSchema(
|
|
657
|
+
const schema = await resolveSchema(queryName, siteRoot)
|
|
658
|
+
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
612
659
|
|
|
613
660
|
// Translate each item (with free-form support)
|
|
614
661
|
const translatedItems = await Promise.all(
|
|
615
662
|
items.map(item =>
|
|
616
|
-
translateItemAsync(item,
|
|
663
|
+
translateItemAsync(item, recordDir, translations, schema, {
|
|
617
664
|
locale,
|
|
618
665
|
localesDir,
|
|
619
666
|
freeformEnabled: hasFreeform
|
|
@@ -623,13 +670,28 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
623
670
|
|
|
624
671
|
const destPath = join(localeDataDir, file)
|
|
625
672
|
await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
|
|
626
|
-
outputs[locale][
|
|
673
|
+
outputs[locale][queryName] = destPath
|
|
627
674
|
} catch (err) {
|
|
628
|
-
console.warn
|
|
675
|
+
// ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
|
|
676
|
+
// hid a real bug for the length of a session: a `ReferenceError` in this
|
|
677
|
+
// lane — a programming error, not bad data — was caught by a handler
|
|
678
|
+
// meant for an unparseable file, downgraded to a warning, and the
|
|
679
|
+
// locale's output silently omitted. The build stayed green while NOTHING
|
|
680
|
+
// was translated.
|
|
681
|
+
//
|
|
682
|
+
// ⇒ It is an ERROR, and it is reported in the RESULT. A caller cannot act
|
|
683
|
+
// on a line of stderr it did not read; `failures` is the thing a build can
|
|
684
|
+
// count and refuse on. Still not thrown, because one unparseable data file
|
|
685
|
+
// must not take down a whole multi-locale build.
|
|
686
|
+
console.error(`[i18n] Failed to translate ${file} for ${locale}: ${err.message}`)
|
|
687
|
+
failures.push({ locale, file, message: err.message })
|
|
629
688
|
}
|
|
630
689
|
}
|
|
631
690
|
}
|
|
632
691
|
|
|
692
|
+
// ⚠️ Attached rather than merged into the locale map, so an existing reader
|
|
693
|
+
// that indexes `outputs[locale][name]` is unaffected while a new one can ask.
|
|
694
|
+
if (failures.length) Object.defineProperty(outputs, 'failures', { value: failures, enumerable: false })
|
|
633
695
|
return outputs
|
|
634
696
|
}
|
|
635
697
|
|
|
@@ -640,15 +702,15 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
|
|
|
640
702
|
* 1. Check for free-form translation (complete or partial replacement)
|
|
641
703
|
* 2. Fall back to hash-based translation (schema-guided or heuristic)
|
|
642
704
|
*/
|
|
643
|
-
async function translateItemAsync(item,
|
|
705
|
+
async function translateItemAsync(item, recordDir, translations, schema, options = {}) {
|
|
644
706
|
const { locale, localesDir, freeformEnabled } = options
|
|
645
707
|
const translated = { ...item }
|
|
646
708
|
const slug = item.slug || item.id || item.name || 'unknown'
|
|
647
|
-
const context = {
|
|
709
|
+
const context = { record: `${recordDir}/${slug}` }
|
|
648
710
|
|
|
649
711
|
// Check for free-form translation first
|
|
650
712
|
if (freeformEnabled && locale && localesDir) {
|
|
651
|
-
const freeform = await
|
|
713
|
+
const freeform = await loadFreeformRecord(item, recordDir, locale, localesDir)
|
|
652
714
|
|
|
653
715
|
if (freeform) {
|
|
654
716
|
// Merge free-form data (supports partial: frontmatter only, body only, or both)
|
|
@@ -668,16 +730,16 @@ async function translateItemAsync(item, collectionName, translations, schema, op
|
|
|
668
730
|
}
|
|
669
731
|
|
|
670
732
|
// Fall back to hash-based translation
|
|
671
|
-
return translateItemSync(translated,
|
|
733
|
+
return translateItemSync(translated, recordDir, translations, schema)
|
|
672
734
|
}
|
|
673
735
|
|
|
674
736
|
/**
|
|
675
737
|
* Apply translations to a collection item (sync, hash-based only)
|
|
676
738
|
*/
|
|
677
|
-
function translateItemSync(item,
|
|
739
|
+
function translateItemSync(item, recordDir, translations, schema) {
|
|
678
740
|
const translated = { ...item }
|
|
679
741
|
const slug = item.slug || item.id || item.name || 'unknown'
|
|
680
|
-
const context = {
|
|
742
|
+
const context = { record: `${recordDir}/${slug}` }
|
|
681
743
|
|
|
682
744
|
if (schema?.fields) {
|
|
683
745
|
return translateWithSchema(translated, schema, context, translations)
|
|
@@ -744,7 +806,8 @@ function lookupTranslation(source, context, translations) {
|
|
|
744
806
|
}
|
|
745
807
|
|
|
746
808
|
if (typeof translation === 'object' && translation !== null) {
|
|
747
|
-
|
|
809
|
+
// Same key shape the manifest writes — a record's identity.
|
|
810
|
+
const contextKey = context.record
|
|
748
811
|
if (translation.overrides?.[contextKey]) {
|
|
749
812
|
return translation.overrides[contextKey]
|
|
750
813
|
}
|
|
@@ -765,7 +828,7 @@ function lookupTranslation(source, context, translations) {
|
|
|
765
828
|
* Used by dev server middleware for on-the-fly translation.
|
|
766
829
|
*
|
|
767
830
|
* @param {Array} items - Collection items array
|
|
768
|
-
* @param {string}
|
|
831
|
+
* @param {string} recordDir - The record's pool directory (e.g. 'article')
|
|
769
832
|
* @param {string} siteRoot - Site root directory
|
|
770
833
|
* @param {Object} options - Translation options
|
|
771
834
|
* @param {string} options.locale - Target locale code
|
|
@@ -774,17 +837,17 @@ function lookupTranslation(source, context, translations) {
|
|
|
774
837
|
* @param {boolean} [options.freeformEnabled=false] - Enable free-form translations
|
|
775
838
|
* @returns {Promise<Array>} Translated items
|
|
776
839
|
*/
|
|
777
|
-
export async function
|
|
840
|
+
export async function translateRecordData(items, queryName, siteRoot, options = {}) {
|
|
778
841
|
const { locale, localesDir, translations = {}, freeformEnabled = false } = options
|
|
779
842
|
|
|
780
843
|
if (!Array.isArray(items)) return items
|
|
781
844
|
|
|
782
|
-
const schema = await resolveSchema(
|
|
845
|
+
const schema = await resolveSchema(queryName, siteRoot)
|
|
783
846
|
|
|
784
847
|
if (freeformEnabled) {
|
|
785
848
|
return Promise.all(
|
|
786
849
|
items.map(item =>
|
|
787
|
-
translateItemAsync(item,
|
|
850
|
+
translateItemAsync(item, recordDir, translations, schema, {
|
|
788
851
|
locale,
|
|
789
852
|
localesDir,
|
|
790
853
|
freeformEnabled
|
|
@@ -794,7 +857,7 @@ export async function translateCollectionData(items, collectionName, siteRoot, o
|
|
|
794
857
|
}
|
|
795
858
|
|
|
796
859
|
return items.map(item =>
|
|
797
|
-
translateItemSync(item,
|
|
860
|
+
translateItemSync(item, queryName, translations, schema)
|
|
798
861
|
)
|
|
799
862
|
}
|
|
800
863
|
|
|
@@ -807,12 +870,12 @@ export async function translateCollectionData(items, collectionName, siteRoot, o
|
|
|
807
870
|
* @param {string} localesPath - Path to locales directory
|
|
808
871
|
* @returns {Promise<string[]>} Array of locale codes
|
|
809
872
|
*/
|
|
810
|
-
export async function
|
|
811
|
-
const
|
|
812
|
-
if (!existsSync(
|
|
873
|
+
export async function getRecordLocales(localesPath) {
|
|
874
|
+
const recordLocalesDir = join(localesPath, RECORDS_DIR)
|
|
875
|
+
if (!existsSync(recordLocalesDir)) return []
|
|
813
876
|
|
|
814
877
|
try {
|
|
815
|
-
const files = await readdir(
|
|
878
|
+
const files = await readdir(recordLocalesDir)
|
|
816
879
|
return files
|
|
817
880
|
.filter(f => f.endsWith('.json') && f !== 'manifest.json')
|
|
818
881
|
.map(f => f.replace('.json', ''))
|
package/src/i18n/sync.js
CHANGED
|
@@ -98,8 +98,9 @@ function contextsEqual(contexts1, contexts2) {
|
|
|
98
98
|
const c2 = contexts2 || []
|
|
99
99
|
if (c1.length !== c2.length) return false
|
|
100
100
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
101
|
+
const key = (c) => (c.record ? `record:${c.record}` : `${c.page}:${c.section}`)
|
|
102
|
+
const set1 = new Set(c1.map(key))
|
|
103
|
+
const set2 = new Set(c2.map(key))
|
|
103
104
|
|
|
104
105
|
if (set1.size !== set2.size) return false
|
|
105
106
|
for (const key of set1) {
|
|
@@ -113,14 +114,14 @@ function contextsEqual(contexts1, contexts2) {
|
|
|
113
114
|
* Returns the previous unit info if found
|
|
114
115
|
*/
|
|
115
116
|
function findMatchingContext(currentContexts, previousUnits) {
|
|
117
|
+
// One key shape for both lanes: a page/section pair, or a record's identity.
|
|
118
|
+
const key = (c) => (c.record ? `record:${c.record}` : `${c.page}:${c.section}`)
|
|
116
119
|
for (const context of currentContexts) {
|
|
117
|
-
const contextKey =
|
|
120
|
+
const contextKey = key(context)
|
|
118
121
|
|
|
119
122
|
for (const [hash, unit] of Object.entries(previousUnits)) {
|
|
120
123
|
const unitContexts = unit.contexts || []
|
|
121
|
-
const hasContext = unitContexts.some(
|
|
122
|
-
c => `${c.page || c.collection}:${c.section || c.item}` === contextKey
|
|
123
|
-
)
|
|
124
|
+
const hasContext = unitContexts.some((c) => key(c) === contextKey)
|
|
124
125
|
if (hasContext) {
|
|
125
126
|
return { hash, source: unit.source, contexts: unit.contexts }
|
|
126
127
|
}
|
|
@@ -198,8 +199,8 @@ export function formatSyncReport(report) {
|
|
|
198
199
|
*/
|
|
199
200
|
function formatContext(context) {
|
|
200
201
|
if (!context) return ''
|
|
201
|
-
const location = context.page || context.
|
|
202
|
-
const section = context.section ||
|
|
202
|
+
const location = context.page || context.record || ''
|
|
203
|
+
const section = context.section || ''
|
|
203
204
|
if (!location && !section) return ''
|
|
204
205
|
return `(${location}:${section})`
|
|
205
206
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* path (where the static-host extras — sitemap, robots, search-index,
|
|
14
14
|
* `_pages/*` for split content — are actually consumed). Both paths use
|
|
15
15
|
* the same underlying building blocks (`collectSiteContent`,
|
|
16
|
-
* `
|
|
16
|
+
* `processQueries`, `processAssets`, etc.), so behavior stays
|
|
17
17
|
* consistent without forcing one path through the other's lifecycle.
|
|
18
18
|
*/
|
|
19
19
|
|
|
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
23
23
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
|
-
import {
|
|
26
|
+
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
27
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
28
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
29
|
import {
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
* ⇒ The rule stands on its own: we do not know which consumers
|
|
61
61
|
* re-derive, so we always ship enough for the ones that do.
|
|
62
62
|
* - `data/<collection>.json` (+ per-record files for `deferred:`
|
|
63
|
-
* collections) — same shape `
|
|
63
|
+
* collections) — same shape `processQueries` produces today.
|
|
64
64
|
* - `assets/<media>` — processed images / video posters / PDF
|
|
65
65
|
* thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
|
|
66
66
|
*
|
|
@@ -129,7 +129,7 @@ export async function buildSiteData({
|
|
|
129
129
|
let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath, strict: true })
|
|
130
130
|
|
|
131
131
|
// 2. Compile content collections (file-based markdown/yaml/json).
|
|
132
|
-
// `
|
|
132
|
+
// `writeQueryFiles` lands them under `<siteRoot>/public/data/`;
|
|
133
133
|
// in the vite plugin path that's fine because vite copies
|
|
134
134
|
// `public/` into `dist/` at build time. The link-mode pipeline
|
|
135
135
|
// has no vite, so we mirror that copy ourselves into
|
|
@@ -137,17 +137,14 @@ export async function buildSiteData({
|
|
|
137
137
|
// publish time. (This named `uniweb deploy::collectDataFiles` until
|
|
138
138
|
// 2026-08-18; no such function has existed for some time.) Same output bytes, same paths, just
|
|
139
139
|
// without the vite intermediary.
|
|
140
|
-
if (siteContent.config?.
|
|
141
|
-
const
|
|
142
|
-
? resolve(resolvedSiteRoot, siteContent.config.paths.collections)
|
|
143
|
-
: null
|
|
144
|
-
const collections = await processCollections(
|
|
140
|
+
if (siteContent.config?.queries) {
|
|
141
|
+
const byQuery = await processQueries(
|
|
145
142
|
resolvedSiteRoot,
|
|
146
|
-
siteContent.config.
|
|
147
|
-
|
|
143
|
+
siteContent.config.queries,
|
|
144
|
+
siteContent.config?.paths?.entities,
|
|
148
145
|
basePath
|
|
149
146
|
)
|
|
150
|
-
await
|
|
147
|
+
await writeQueryFiles(resolvedSiteRoot, byQuery, siteContent.config.queries)
|
|
151
148
|
|
|
152
149
|
const publicDataDir = join(resolvedSiteRoot, 'public', DATA_DIR)
|
|
153
150
|
const distDataDir = join(resolvedDistDir, DATA_DIR)
|
package/src/site/config.js
CHANGED
|
@@ -412,7 +412,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
412
412
|
const allowed = ['..']
|
|
413
413
|
const parentDir = resolve(siteRoot, '..')
|
|
414
414
|
const paths = siteConfig.paths || {}
|
|
415
|
-
for (const key of ['pages', 'layout', '
|
|
415
|
+
for (const key of ['pages', 'layout', 'entities']) {
|
|
416
416
|
if (paths[key]) {
|
|
417
417
|
const resolved = resolve(siteRoot, paths[key])
|
|
418
418
|
if (!resolved.startsWith(parentDir)) {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
27
|
-
import {
|
|
27
|
+
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
28
|
+
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
28
29
|
import { join, parse, resolve, sep } from 'node:path'
|
|
29
30
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
30
31
|
import yaml from 'js-yaml'
|
|
@@ -578,13 +579,9 @@ function resolveDisplayTitle(declaredTitle, segment, sections) {
|
|
|
578
579
|
return extractH1(sections[0]?.content) || prettifySlug(segment)
|
|
579
580
|
}
|
|
580
581
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
return { prefix: match[1], name: match[2] || match[1] }
|
|
585
|
-
}
|
|
586
|
-
return { prefix: null, name: filename }
|
|
587
|
-
}
|
|
582
|
+
// ⭐ The rule now lives in `utils/numeric-prefix.js` — `records.yml` needs the
|
|
583
|
+
// identical one, and a records reader cannot import this module without closing
|
|
584
|
+
// a cycle. Imported at the top; still re-exported below for existing callers.
|
|
588
585
|
|
|
589
586
|
/**
|
|
590
587
|
* Compare filenames for sorting by numeric prefix.
|
|
@@ -593,23 +590,7 @@ function parseNumericPrefix(filename) {
|
|
|
593
590
|
function compareFilenames(a, b) {
|
|
594
591
|
const nameA = isChildSection(parse(a).name) ? stripAtPrefix(parse(a).name) : parse(a).name
|
|
595
592
|
const nameB = isChildSection(parse(b).name) ? stripAtPrefix(parse(b).name) : parse(b).name
|
|
596
|
-
|
|
597
|
-
const { prefix: prefixB } = parseNumericPrefix(nameB)
|
|
598
|
-
|
|
599
|
-
if (!prefixA && !prefixB) return a.localeCompare(b)
|
|
600
|
-
if (!prefixA) return 1
|
|
601
|
-
if (!prefixB) return -1
|
|
602
|
-
|
|
603
|
-
const partsA = prefixA.split('.').map(Number)
|
|
604
|
-
const partsB = prefixB.split('.').map(Number)
|
|
605
|
-
|
|
606
|
-
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
607
|
-
const numA = partsA[i] ?? 0
|
|
608
|
-
const numB = partsB[i] ?? 0
|
|
609
|
-
if (numA !== numB) return numA - numB
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return 0
|
|
593
|
+
return compareByNumericPrefix(nameA, nameB)
|
|
613
594
|
}
|
|
614
595
|
|
|
615
596
|
/**
|
|
@@ -839,12 +820,12 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
839
820
|
const insets = extractInsets(proseMirrorContent)
|
|
840
821
|
|
|
841
822
|
// Support 'data:' shorthand for collection fetch
|
|
842
|
-
// data: team → fetch: {
|
|
843
|
-
// data: [team, articles] → fetch: {
|
|
823
|
+
// data: team → fetch: { query: team }
|
|
824
|
+
// data: [team, articles] → fetch: { query: team } (first item, others via inheritData)
|
|
844
825
|
let resolvedFetch = fetch
|
|
845
826
|
if (!fetch && data) {
|
|
846
|
-
const
|
|
847
|
-
resolvedFetch = {
|
|
827
|
+
const queryName = Array.isArray(data) ? data[0] : data
|
|
828
|
+
resolvedFetch = { query: queryName }
|
|
848
829
|
}
|
|
849
830
|
|
|
850
831
|
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
|
@@ -1426,10 +1407,10 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1426
1407
|
|
|
1427
1408
|
// Data fetching
|
|
1428
1409
|
// Support 'data:' shorthand at page level
|
|
1429
|
-
// data: team → fetch: {
|
|
1410
|
+
// data: team → fetch: { query: team }
|
|
1430
1411
|
fetch: parseFetchConfig(
|
|
1431
1412
|
pageConfig.fetch || (pageConfig.data
|
|
1432
|
-
? {
|
|
1413
|
+
? { query: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
|
|
1433
1414
|
: undefined)
|
|
1434
1415
|
),
|
|
1435
1416
|
|
|
@@ -2173,16 +2154,20 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2173
2154
|
// Read site config and raw theme config
|
|
2174
2155
|
const siteConfig = await readYamlFile(join(sitePath, configFile))
|
|
2175
2156
|
|
|
2176
|
-
//
|
|
2177
|
-
//
|
|
2178
|
-
//
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
|
|
2183
|
-
|
|
2157
|
+
// Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,
|
|
2158
|
+
// the latter winning per key — and resolving them is one question with one
|
|
2159
|
+
// answer. The site build used to read `site.yml` alone while the sync lane
|
|
2160
|
+
// merged both, so a declaration in the second file was invisible here: never
|
|
2161
|
+
// compiled, `data: <name>` delivering nothing, while sync pushed it fine.
|
|
2162
|
+
//
|
|
2163
|
+
// ⭐ `config.queries` — framework's own payload key, framework's own readers.
|
|
2164
|
+
// The backend's projector never emits it (their measurement: 17 `config` keys,
|
|
2165
|
+
// not this one), and hosting renders with framework's code. There was nobody to
|
|
2166
|
+
// coordinate with, which is exactly why it had no excuse to stay wrong.
|
|
2167
|
+
const byQuery = toConfigQueries(
|
|
2168
|
+
(await resolveQueriesConfig(sitePath, { siteYml: siteConfig })).declarations
|
|
2184
2169
|
)
|
|
2185
|
-
if (
|
|
2170
|
+
if (byQuery) siteConfig.queries = byQuery
|
|
2186
2171
|
|
|
2187
2172
|
// Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
|
|
2188
2173
|
// config so every consumer reads one value. Prerender sets website.basePath
|