@uniweb/build 0.51.0 → 0.52.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/dev/api-mount.js +2 -1
- package/src/dev/plugin.js +15 -11
- package/src/generate-entry.js +29 -2
- package/src/i18n/freeform.js +116 -28
- package/src/i18n/index.js +11 -19
- package/src/i18n/records.js +144 -28
- package/src/prerender.js +4 -13
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +3 -2
- package/src/schema.js +13 -3
- package/src/site/build-site-data.js +11 -5
- package/src/site/config.js +2 -1
- package/src/site/content-collector.js +17 -3
- package/src/site/data-fetcher.js +68 -144
- package/src/site/deploy-config.js +2 -1
- package/src/site/fetch-shapes.js +19 -7
- package/src/site/plugin.js +97 -41
- package/src/site/queries-config.js +15 -4
- package/src/site/query-processor.js +110 -62
- package/src/site/records-config.js +3 -2
- package/src/utils/frontmatter.js +2 -1
- package/src/utils/yaml-schema.js +44 -0
- package/src/uwx/backfill.js +3 -2
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/project-writer.js +3 -2
- package/src/uwx/records-project.js +9 -3
- package/src/uwx/records.js +7 -4
- package/src/uwx/site-project.js +2 -1
- package/src/uwx/site.js +6 -5
- package/src/validate-data.js +9 -8
package/src/i18n/records.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* 3. Legacy — flat field list (fallback within heuristic)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
|
|
13
|
+
import { readFile, writeFile, readdir, mkdir, rm } from 'fs/promises'
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
|
-
import { join } from 'node:path'
|
|
15
|
+
import { join, resolve, sep } 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'
|
|
@@ -23,7 +23,7 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
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
25
|
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
26
|
-
import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
|
|
26
|
+
import { poolDirsForSchema, schemaForPoolDirs, ENTITIES_DIR } from '../site/entity-pool.js'
|
|
27
27
|
import {
|
|
28
28
|
NON_TRANSLATABLE_TYPES,
|
|
29
29
|
HEURISTIC_SKIP_FIELDS,
|
|
@@ -150,6 +150,23 @@ function isFieldTranslatable(fieldDef) {
|
|
|
150
150
|
return 'no'
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
|
|
155
|
+
* either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
|
|
156
|
+
* parametric page's URL names; `$uuid`), and a record's top-level `slug` and `path`
|
|
157
|
+
* are its handle and its placement in `records.yml`. Until 2026-09-14 they became
|
|
158
|
+
* translation units, and a translation whose source happened to equal one rewrote it,
|
|
159
|
+
* so a record's page stopped matching its URL.
|
|
160
|
+
*
|
|
161
|
+
* @param {string|number} key - a field name (an array index is never one)
|
|
162
|
+
* @param {boolean} topLevel - whether the key sits at the record's own level
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function isRecordSystemField(key, topLevel) {
|
|
166
|
+
if (typeof key !== 'string') return false
|
|
167
|
+
return key.startsWith('$') || (topLevel && (key === 'slug' || key === 'path'))
|
|
168
|
+
}
|
|
169
|
+
|
|
153
170
|
// ---------------------------------------------------------------------------
|
|
154
171
|
// Schema-guided extraction
|
|
155
172
|
// ---------------------------------------------------------------------------
|
|
@@ -181,6 +198,7 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
|
|
|
181
198
|
if (!data || typeof data !== 'object') return
|
|
182
199
|
|
|
183
200
|
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
201
|
+
if (isRecordSystemField(fieldName, !pathPrefix)) continue
|
|
184
202
|
const value = data[fieldName]
|
|
185
203
|
if (value === undefined || value === null) continue
|
|
186
204
|
|
|
@@ -253,6 +271,7 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
|
|
|
253
271
|
: (pathPrefix ? `${pathPrefix}.${key}` : key)
|
|
254
272
|
|
|
255
273
|
if (value === undefined || value === null) continue
|
|
274
|
+
if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
|
|
256
275
|
|
|
257
276
|
// Skip ProseMirror content (handled separately)
|
|
258
277
|
if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
|
|
@@ -298,10 +317,11 @@ function translateWithSchema(item, schema, context, translations, includeContent
|
|
|
298
317
|
/**
|
|
299
318
|
* Recursively translate fields guided by schema.
|
|
300
319
|
*/
|
|
301
|
-
function translateItemWithSchema(data, fields, context, translations) {
|
|
320
|
+
function translateItemWithSchema(data, fields, context, translations, topLevel = true) {
|
|
302
321
|
if (!data || typeof data !== 'object') return
|
|
303
322
|
|
|
304
323
|
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
324
|
+
if (isRecordSystemField(fieldName, topLevel)) continue
|
|
305
325
|
const value = data[fieldName]
|
|
306
326
|
if (value === undefined || value === null) continue
|
|
307
327
|
|
|
@@ -313,13 +333,13 @@ function translateItemWithSchema(data, fields, context, translations) {
|
|
|
313
333
|
}
|
|
314
334
|
} else if (translatable === 'recurse') {
|
|
315
335
|
if (fieldDef.type === 'object' && fieldDef.fields && typeof value === 'object' && !Array.isArray(value)) {
|
|
316
|
-
translateItemWithSchema(value, fieldDef.fields, context, translations)
|
|
336
|
+
translateItemWithSchema(value, fieldDef.fields, context, translations, false)
|
|
317
337
|
} else if (fieldDef.type === 'array' && Array.isArray(value)) {
|
|
318
338
|
const itemDef = fieldDef.items
|
|
319
339
|
if (itemDef) {
|
|
320
340
|
value.forEach((elem, i) => {
|
|
321
341
|
if (itemDef.type === 'object' && itemDef.fields && typeof elem === 'object') {
|
|
322
|
-
translateItemWithSchema(elem, itemDef.fields, context, translations)
|
|
342
|
+
translateItemWithSchema(elem, itemDef.fields, context, translations, false)
|
|
323
343
|
} else if (itemDef.type === 'string') {
|
|
324
344
|
const itemTranslatable = isFieldTranslatable(itemDef)
|
|
325
345
|
if (itemTranslatable === 'yes' && typeof elem === 'string') {
|
|
@@ -364,6 +384,7 @@ function translateItemHeuristic(data, context, translations, depth) {
|
|
|
364
384
|
for (const key of keys) {
|
|
365
385
|
const value = data[key]
|
|
366
386
|
if (value === undefined || value === null) continue
|
|
387
|
+
if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
|
|
367
388
|
|
|
368
389
|
// Skip ProseMirror content (handled separately)
|
|
369
390
|
if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
|
|
@@ -451,14 +472,28 @@ export async function extractRecordContent(siteRoot, options = {}) {
|
|
|
451
472
|
// Resolve schema once per collection
|
|
452
473
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
453
474
|
const recordDir = poolDirs.get(queryName) ?? queryName
|
|
454
|
-
|
|
455
|
-
for (const item of items) {
|
|
475
|
+
const extract = (item) => {
|
|
456
476
|
if (schema?.fields) {
|
|
457
477
|
extractWithSchema(item, schema, recordDir, units)
|
|
458
478
|
} else {
|
|
459
479
|
extractHeuristic(item, recordDir, units)
|
|
460
480
|
}
|
|
461
481
|
}
|
|
482
|
+
|
|
483
|
+
for (const item of items) extract(item)
|
|
484
|
+
|
|
485
|
+
// ⭐ AND THE QUERY'S PER-RECORD FILES. A query with `deferred:` fields writes each
|
|
486
|
+
// record whole beside its lean list, and a deferred field is exactly what the list
|
|
487
|
+
// does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
|
|
488
|
+
// one derived from a schema's brief included — never reached the manifest.
|
|
489
|
+
for (const name of await recordFileNames(dataDir, queryName)) {
|
|
490
|
+
try {
|
|
491
|
+
const item = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
492
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) extract(item)
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
462
497
|
} catch (err) {
|
|
463
498
|
// Skip files that can't be parsed
|
|
464
499
|
console.warn(`[i18n] Skipping ${file}: ${err.message}`)
|
|
@@ -472,6 +507,48 @@ export async function extractRecordContent(siteRoot, options = {}) {
|
|
|
472
507
|
}
|
|
473
508
|
}
|
|
474
509
|
|
|
510
|
+
/**
|
|
511
|
+
* The per-record files a query with `deferred:` fields writes beside its list —
|
|
512
|
+
* `public/data/<query>/<slug>.json`, one record whole in each (`writeQueryFiles`).
|
|
513
|
+
*
|
|
514
|
+
* @param {string} dataDir - the directory the query's list is in
|
|
515
|
+
* @param {string} queryName - the list's file name, without `.json`
|
|
516
|
+
* @returns {Promise<string[]>} the file names (`<slug>.json`), sorted; none when the
|
|
517
|
+
* query writes none
|
|
518
|
+
*/
|
|
519
|
+
async function recordFileNames(dataDir, queryName) {
|
|
520
|
+
const dir = join(dataDir, queryName)
|
|
521
|
+
if (!isInside(dir, dataDir) || !existsSync(dir)) return []
|
|
522
|
+
try {
|
|
523
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
524
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
525
|
+
.map((entry) => entry.name)
|
|
526
|
+
.sort()
|
|
527
|
+
} catch {
|
|
528
|
+
return []
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Is `path` strictly inside `dir`? A name read off disk must not lead out of it. */
|
|
533
|
+
function isInside(path, dir) {
|
|
534
|
+
return resolve(path).startsWith(resolve(dir) + sep)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Remove what a locale's record directory holds beyond the query's current record files —
|
|
539
|
+
* files whose source is gone. Build output only, and never outside `dataDir`.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} recordsDir - `<locale>/data/<query>/`
|
|
542
|
+
* @param {string} dataDir - `<locale>/data/`
|
|
543
|
+
* @param {Set<string>} keep - the file names the query's source directory holds
|
|
544
|
+
*/
|
|
545
|
+
async function pruneRecordFiles(recordsDir, dataDir, keep) {
|
|
546
|
+
if (!isInside(recordsDir, dataDir) || !existsSync(recordsDir)) return
|
|
547
|
+
for (const entry of await readdir(recordsDir)) {
|
|
548
|
+
if (!keep.has(entry)) await rm(join(recordsDir, entry), { recursive: true, force: true })
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
475
552
|
// ---------------------------------------------------------------------------
|
|
476
553
|
// ProseMirror extraction helpers (unchanged)
|
|
477
554
|
// ---------------------------------------------------------------------------
|
|
@@ -671,6 +748,30 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
671
748
|
const destPath = join(localeDataDir, file)
|
|
672
749
|
await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
|
|
673
750
|
outputs[locale][queryName] = destPath
|
|
751
|
+
|
|
752
|
+
// ⭐ AND THE QUERY'S PER-RECORD FILES, beside the translated list and with the
|
|
753
|
+
// same translations — `/<locale>/data/<query>/<slug>.json`. A query with
|
|
754
|
+
// `deferred:` fields writes each record whole there, and a parametric page reads
|
|
755
|
+
// its record from that file. ⛔ Until 2026-09-14 they were not written for any
|
|
756
|
+
// locale, so a localized site's records showed their deferred fields — an
|
|
757
|
+
// article's body — in the source language under a translated list.
|
|
758
|
+
const localeRecordsDir = join(localeDataDir, queryName)
|
|
759
|
+
const recordNames = await recordFileNames(dataDir, queryName)
|
|
760
|
+
for (const name of recordNames) {
|
|
761
|
+
try {
|
|
762
|
+
const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
|
|
763
|
+
const translatedRecord = record && typeof record === 'object' && !Array.isArray(record)
|
|
764
|
+
? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform })
|
|
765
|
+
: record
|
|
766
|
+
await mkdir(localeRecordsDir, { recursive: true })
|
|
767
|
+
await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
|
|
768
|
+
} catch (err) {
|
|
769
|
+
console.error(`[i18n] Failed to translate ${queryName}/${name} for ${locale}: ${err.message}`)
|
|
770
|
+
failures.push({ locale, file: `${queryName}/${name}`, message: err.message })
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// A record file the source no longer has is not left behind, translated, in the locale.
|
|
774
|
+
await pruneRecordFiles(localeRecordsDir, localeDataDir, new Set(recordNames))
|
|
674
775
|
} catch (err) {
|
|
675
776
|
// ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
|
|
676
777
|
// hid a real bug for the length of a session: a `ReferenceError` in this
|
|
@@ -708,9 +809,12 @@ async function translateItemAsync(item, recordDir, translations, schema, options
|
|
|
708
809
|
const slug = item.slug || item.id || item.name || 'unknown'
|
|
709
810
|
const context = { record: `${recordDir}/${slug}` }
|
|
710
811
|
|
|
711
|
-
// Check for free-form translation first
|
|
812
|
+
// Check for free-form translation first. ⛔ The loader takes the record's SCHEMA ref
|
|
813
|
+
// and derives the pool path from it (`buildFreeformRecordPath`); handed the pool
|
|
814
|
+
// directory itself (`article`), it derived nothing and found no file — so until
|
|
815
|
+
// 2026-09-14 no free-form record translation applied on this lane.
|
|
712
816
|
if (freeformEnabled && locale && localesDir) {
|
|
713
|
-
const freeform = await loadFreeformRecord(item, recordDir, locale, localesDir)
|
|
817
|
+
const freeform = await loadFreeformRecord(item, schemaForPoolDirs(recordDir.split('/')), locale, localesDir)
|
|
714
818
|
|
|
715
819
|
if (freeform) {
|
|
716
820
|
// Merge free-form data (supports partial: frontmatter only, body only, or both)
|
|
@@ -827,38 +931,50 @@ function lookupTranslation(source, context, translations) {
|
|
|
827
931
|
* Translate a collection's items array for a given locale.
|
|
828
932
|
* Used by dev server middleware for on-the-fly translation.
|
|
829
933
|
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
934
|
+
* ⛔ The record key is derived here exactly as extraction and the localized build
|
|
935
|
+
* derive it — the query's pool directory (`poolDirsByQuery`), not its name. Until
|
|
936
|
+
* 2026-09-14 the free-form branch read a `recordDir` that was never defined (a
|
|
937
|
+
* `ReferenceError`, which the dev middleware caught and logged, leaving the request to
|
|
938
|
+
* fall through untranslated),
|
|
939
|
+
* and the other branch keyed by the query's name, so a translation keyed by the record
|
|
940
|
+
* missed.
|
|
941
|
+
*
|
|
942
|
+
* @param {Array|Object} items - Collection items array — or ONE record, the content of a
|
|
943
|
+
* `deferred:` query's per-record file (`/data/<query>/<slug>.json`)
|
|
944
|
+
* @param {string} queryName - The query the items were compiled for (e.g. 'articles')
|
|
832
945
|
* @param {string} siteRoot - Site root directory
|
|
833
946
|
* @param {Object} options - Translation options
|
|
834
947
|
* @param {string} options.locale - Target locale code
|
|
835
948
|
* @param {string} options.localesDir - Absolute path to locales directory
|
|
836
949
|
* @param {Object} [options.translations={}] - Hash-based translations
|
|
837
950
|
* @param {boolean} [options.freeformEnabled=false] - Enable free-form translations
|
|
838
|
-
* @returns {Promise<Array>} Translated items
|
|
951
|
+
* @returns {Promise<Array|Object>} Translated items, or the translated record
|
|
839
952
|
*/
|
|
840
953
|
export async function translateRecordData(items, queryName, siteRoot, options = {}) {
|
|
841
954
|
const { locale, localesDir, translations = {}, freeformEnabled = false } = options
|
|
842
955
|
|
|
843
|
-
|
|
956
|
+
const one = !!items && typeof items === 'object' && !Array.isArray(items)
|
|
957
|
+
if (!Array.isArray(items) && !one) return items
|
|
958
|
+
const records = one ? [items] : items
|
|
844
959
|
|
|
845
960
|
const schema = await resolveSchema(queryName, siteRoot)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
961
|
+
const recordDir = (await poolDirsByQuery(siteRoot)).get(queryName) ?? queryName
|
|
962
|
+
|
|
963
|
+
const translated = freeformEnabled
|
|
964
|
+
? await Promise.all(
|
|
965
|
+
records.map(item =>
|
|
966
|
+
translateItemAsync(item, recordDir, translations, schema, {
|
|
967
|
+
locale,
|
|
968
|
+
localesDir,
|
|
969
|
+
freeformEnabled
|
|
970
|
+
})
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
: records.map(item =>
|
|
974
|
+
translateItemSync(item, recordDir, translations, schema)
|
|
855
975
|
)
|
|
856
|
-
)
|
|
857
|
-
}
|
|
858
976
|
|
|
859
|
-
return
|
|
860
|
-
translateItemSync(item, queryName, translations, schema)
|
|
861
|
-
)
|
|
977
|
+
return one ? translated[0] : translated
|
|
862
978
|
}
|
|
863
979
|
|
|
864
980
|
// ---------------------------------------------------------------------------
|
package/src/prerender.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
evaluateQuery,
|
|
26
26
|
} from '@uniweb/core'
|
|
27
27
|
import { routePatternToRegex } from '@uniweb/core/route-match'
|
|
28
|
-
import { executeFetch, mergeDataIntoContent, toFetchList
|
|
28
|
+
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
29
29
|
import { shouldSplitContent } from './site/split-content.js'
|
|
30
30
|
import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
31
31
|
import { getAdapter } from './hosts/index.js'
|
|
@@ -480,7 +480,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
480
480
|
|
|
481
481
|
/**
|
|
482
482
|
* Process fetch configs for sections (and subsections recursively)
|
|
483
|
-
* Section-level fetches
|
|
483
|
+
* Section-level fetches fill parsedContent.data, each under its own key (not cascaded).
|
|
484
484
|
*
|
|
485
485
|
* ⭐ RESOLVED BY THE RUNTIME'S RULE, as page fetches are (`resolve`). ⛔ Until
|
|
486
486
|
* 2026-09-13 a section's fetch was executed as authored: a non-default locale baked
|
|
@@ -500,7 +500,7 @@ async function processSectionFetches(sections, { resolve, read, onProgress, reco
|
|
|
500
500
|
if (!sections || !Array.isArray(sections)) return
|
|
501
501
|
|
|
502
502
|
for (const section of sections) {
|
|
503
|
-
// Execute every section-level fetch. Each
|
|
503
|
+
// Execute every section-level fetch. Each lands under its own key, so
|
|
504
504
|
// several accumulate into one `parsedContent.data` — the same keyed map the
|
|
505
505
|
// runtime's EntityStore builds. ⛔ The FIRST binding of a key, as there: each
|
|
506
506
|
// replaced the last until 2026-09-13, so the build delivered the last binding
|
|
@@ -521,8 +521,7 @@ async function processSectionFetches(sections, { resolve, read, onProgress, reco
|
|
|
521
521
|
section.parsedContent = mergeDataIntoContent(
|
|
522
522
|
section.parsedContent || {},
|
|
523
523
|
got.view,
|
|
524
|
-
sectionFetch.as
|
|
525
|
-
sectionFetch.merge
|
|
524
|
+
sectionFetch.as
|
|
526
525
|
)
|
|
527
526
|
}
|
|
528
527
|
}
|
|
@@ -936,11 +935,6 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
936
935
|
// Store fetchedData on siteContent for runtime DataStore pre-population
|
|
937
936
|
siteContent.fetchedData = fetchedData
|
|
938
937
|
|
|
939
|
-
// The build has consumed every build-only fetch key by now (`merge`, read
|
|
940
|
-
// by the section fetches above); what ships in `__SITE_CONTENT__` is the
|
|
941
|
-
// runtime's payload and carries none of them.
|
|
942
|
-
siteContent = stripBuildOnlyFetchKeys(siteContent)
|
|
943
|
-
|
|
944
938
|
// Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
|
|
945
939
|
if (siteContent.pages?.some(p => p.isDynamic)) {
|
|
946
940
|
onProgress('Expanding dynamic routes...')
|
|
@@ -956,9 +950,6 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
956
950
|
// calls, matched against the parametric page it came from), then read by
|
|
957
951
|
// this build's executor. So the page renders complete, and the SPA hydrates
|
|
958
952
|
// the very keys it asks for.
|
|
959
|
-
// ⛔ Appended to `siteContent.fetchedData` — the copy `stripBuildOnlyFetchKeys`
|
|
960
|
-
// made above — never to the raw `fetchedData`, whose configs still carry the
|
|
961
|
-
// build-only `merge` (it leaked into shipped pages that way, measured).
|
|
962
953
|
const baked = await readRouteBoundViews({
|
|
963
954
|
templates,
|
|
964
955
|
pages: siteContent.pages,
|
|
@@ -41,6 +41,7 @@ import { join, resolve, isAbsolute, extname, basename } from 'node:path'
|
|
|
41
41
|
import { pathToFileURL } from 'node:url'
|
|
42
42
|
import { createRequire } from 'node:module'
|
|
43
43
|
import yaml from 'js-yaml'
|
|
44
|
+
import { YAML_OPTIONS } from './utils/yaml-schema.js'
|
|
44
45
|
|
|
45
46
|
import {
|
|
46
47
|
SCHEMA_EXTENSIONS,
|
|
@@ -422,7 +423,7 @@ async function loadSchemaFile(filePath) {
|
|
|
422
423
|
}
|
|
423
424
|
const text = await readFile(filePath, 'utf8')
|
|
424
425
|
if (filePath.endsWith('.json')) return JSON.parse(text)
|
|
425
|
-
return yaml.load(text) // .yml / .yaml
|
|
426
|
+
return yaml.load(text, YAML_OPTIONS) // .yml / .yaml
|
|
426
427
|
}
|
|
427
428
|
|
|
428
429
|
/**
|
package/src/runtime-schema.js
CHANGED
|
@@ -288,8 +288,9 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
288
288
|
} else if (fullMeta.data && typeof fullMeta.data === 'object' && !Array.isArray(fullMeta.data)) {
|
|
289
289
|
for (const [key, value] of Object.entries(fullMeta.data)) {
|
|
290
290
|
// ⛔ A value that is no schema at all declares a key by accident — it would reach the
|
|
291
|
-
// component as `null`, silently. `data: { inherit: [...] }
|
|
292
|
-
// is the shape this refuses: it
|
|
291
|
+
// component as `null`, silently. `data: { inherit: [...] }` — the opt-in to cascaded
|
|
292
|
+
// data from 2026-01-31 until declared keys replaced it — is the shape this refuses: it
|
|
293
|
+
// declared a key named `inherit`.
|
|
293
294
|
if (typeof value !== 'string' && value !== null && (typeof value !== 'object' || Array.isArray(value))) {
|
|
294
295
|
throw new Error(
|
|
295
296
|
`[uniweb] Invalid 'data.${key}' in meta.js: expected a named ref ('@/x'), an inline field map, ` +
|
package/src/schema.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readdir, readFile, rm } from 'node:fs/promises'
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
15
16
|
import { isFontVar } from '@uniweb/theming'
|
|
16
17
|
import { join, dirname, extname, basename } from 'node:path'
|
|
17
18
|
import { pathToFileURL } from 'node:url'
|
|
@@ -40,11 +41,20 @@ const SECTIONS_PATH = 'sections'
|
|
|
40
41
|
export const LAYOUTS_PATH = 'layouts'
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Load a meta.js file via dynamic import
|
|
44
|
+
* Load a meta.js file via dynamic import — as it is on disk now.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ Node caches an ES module by its URL for the life of the process, so a dev server
|
|
47
|
+
* that regenerates the foundation entry after a `meta.js` edit re-imported the file it
|
|
48
|
+
* had started with, and the entry kept the old meta. The entry inlines that meta, and a
|
|
49
|
+
* section receives only the `data:` keys it declares (2026-09-14), so a key added in dev
|
|
50
|
+
* reached no component until the server restarted. Measured in a dev server before this
|
|
51
|
+
* fix. The URL now carries the file's content hash: an unchanged file is the cached
|
|
52
|
+
* module, an edited one is read again. A module the meta.js itself imports is still
|
|
53
|
+
* cached until restart.
|
|
44
54
|
*/
|
|
45
55
|
async function loadMetaFile(filePath) {
|
|
46
|
-
const
|
|
47
|
-
const module = await import(
|
|
56
|
+
const version = createHash('sha1').update(await readFile(filePath)).digest('hex').slice(0, 16)
|
|
57
|
+
const module = await import(`${pathToFileURL(filePath).href}?content=${version}`)
|
|
48
58
|
return module.default
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -24,7 +24,6 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
|
-
import { stripBuildOnlyFetchKeys } from './data-fetcher.js'
|
|
28
27
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
29
28
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
30
29
|
import {
|
|
@@ -62,6 +61,8 @@ import {
|
|
|
62
61
|
* re-derive, so we always ship enough for the ones that do.
|
|
63
62
|
* - `data/<collection>.json` (+ per-record files for `deferred:`
|
|
64
63
|
* collections) — same shape `processQueries` produces today.
|
|
64
|
+
* - `records/<path under entities/>` — the records' co-located assets, where
|
|
65
|
+
* the compiled records point.
|
|
65
66
|
* - `assets/<media>` — processed images / video posters / PDF
|
|
66
67
|
* thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
|
|
67
68
|
*
|
|
@@ -153,6 +154,15 @@ export async function buildSiteData({
|
|
|
153
154
|
if (existsSync(publicDataDir)) {
|
|
154
155
|
await cp(publicDataDir, distDataDir, { recursive: true })
|
|
155
156
|
}
|
|
157
|
+
|
|
158
|
+
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under entities/>`,
|
|
159
|
+
// where the compiled records point (`/records/…`), mirrored the way vite mirrors
|
|
160
|
+
// all of `public/` on the bundle lane. ⛔ Until 2026-09-14 only `public/data` was
|
|
161
|
+
// copied, so a record's image URL named a file this lane's `dist/` did not have.
|
|
162
|
+
const publicRecordsDir = join(resolvedSiteRoot, 'public', 'records')
|
|
163
|
+
if (existsSync(publicRecordsDir)) {
|
|
164
|
+
await cp(publicRecordsDir, join(resolvedDistDir, 'records'), { recursive: true })
|
|
165
|
+
}
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
// 3. Process media assets — images, optional video posters and PDF
|
|
@@ -237,10 +247,6 @@ export async function buildSiteData({
|
|
|
237
247
|
finalContent = { ...finalContent, config: configWithoutIcons }
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
// Build-only fetch keys (`merge`) never reach a shipped payload; this lane runs
|
|
241
|
-
// no prerender, so nothing here reads them either.
|
|
242
|
-
finalContent = stripBuildOnlyFetchKeys(finalContent)
|
|
243
|
-
|
|
244
250
|
// 4. Write `dist/site-content.json` with FULL sections inlined.
|
|
245
251
|
// Important: do NOT strip sections per the split-content rule here.
|
|
246
252
|
// Stripping would silently break split-mode sites on any consumer that
|
package/src/site/config.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs'
|
|
24
24
|
import { resolve, dirname, join } from 'node:path'
|
|
25
25
|
import yaml from 'js-yaml'
|
|
26
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
26
27
|
import {
|
|
27
28
|
generateEntryPoint,
|
|
28
29
|
shouldRegenerateForFile,
|
|
@@ -81,7 +82,7 @@ export function readSiteConfig(siteRoot) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
try {
|
|
84
|
-
return yaml.load(readFileSync(configPath, 'utf8')) || {}
|
|
85
|
+
return yaml.load(readFileSync(configPath, 'utf8'), YAML_OPTIONS) || {}
|
|
85
86
|
} catch (err) {
|
|
86
87
|
console.warn('[site-config] Failed to read site.yml:', err.message)
|
|
87
88
|
return {}
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - preset: Preset configuration name
|
|
14
14
|
* - input: Input field mapping
|
|
15
15
|
* - props: Additional component props (merged with other params)
|
|
16
|
-
* - fetch:
|
|
17
|
-
|
|
16
|
+
* - fetch: The section's binding — names a query (query, as, where, sort, limit, current,
|
|
17
|
+
* prerender, detailPage); `query:` is its shorthand
|
|
18
|
+
*
|
|
18
19
|
* Uses @uniweb/content-reader for markdown → ProseMirror conversion
|
|
19
20
|
* when available, otherwise uses a simplified parser.
|
|
20
21
|
*
|
|
@@ -29,6 +30,7 @@ import { readLayoutFolder, DEFAULT_LAYOUT } from './layout-folder.js'
|
|
|
29
30
|
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
30
31
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
31
32
|
import yaml from 'js-yaml'
|
|
33
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
32
34
|
import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
|
|
33
35
|
import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
|
|
34
36
|
import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
@@ -135,6 +137,11 @@ function extractRouteParam(folderName) {
|
|
|
135
137
|
* never match. A folder that holds something other than a page is named
|
|
136
138
|
* with a leading `_`, which the walk skips.
|
|
137
139
|
*
|
|
140
|
+
* ⛔ And a bracketed name that is no parametric marker (2026-09-14) — `[...slug]`,
|
|
141
|
+
* `[...rest]`, `[...]`, `[my-id]`. It matched neither `[...path]` nor `[name]`, so it
|
|
142
|
+
* built a STATIC page whose route kept the brackets (`/docs/[...slug]`), and the sync
|
|
143
|
+
* push carried it as a parametric page named `...slug` — two answers, neither meant.
|
|
144
|
+
*
|
|
138
145
|
* @param {string} name - the folder's name
|
|
139
146
|
* @param {string} parentRoute - the route of the folder it sits in
|
|
140
147
|
*/
|
|
@@ -146,6 +153,13 @@ function assertRouteFolder(name, parentRoute) {
|
|
|
146
153
|
`variable every parametric page already has.${hint}`
|
|
147
154
|
)
|
|
148
155
|
}
|
|
156
|
+
if (name.startsWith('[') && name.endsWith(']') && !isDynamicRoute(name)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
name.startsWith('[...')
|
|
159
|
+
? `[uniweb] pages: a folder cannot be named \`${name}\` — capturing the rest of the URL is spelled \`[...path]\`.`
|
|
160
|
+
: `[uniweb] pages: a folder cannot be named \`${name}\` — a parametric folder's name is letters, digits and \`_\`, e.g. \`[slug]\`.`
|
|
161
|
+
)
|
|
162
|
+
}
|
|
149
163
|
if (typeof parentRoute === 'string' && /\/:[A-Za-z0-9_-]+\*(\/|$)/.test(parentRoute)) {
|
|
150
164
|
throw new Error(
|
|
151
165
|
`[uniweb] pages: \`${name}\` sits inside a \`[...path]\` folder (${parentRoute}). The catch-all ` +
|
|
@@ -421,7 +435,7 @@ let yamlFailures = []
|
|
|
421
435
|
*/
|
|
422
436
|
function parseYaml(yamlString, source = '<unknown>') {
|
|
423
437
|
try {
|
|
424
|
-
return yaml.load(yamlString) || {}
|
|
438
|
+
return yaml.load(yamlString, YAML_OPTIONS) || {}
|
|
425
439
|
} catch (err) {
|
|
426
440
|
yamlFailures.push({ source, message: err.message })
|
|
427
441
|
console.warn(`[content-collector] YAML parse error in ${source}: ${err.message}`)
|