@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.
@@ -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
- * @param {Array} items - Collection items array
831
- * @param {string} recordDir - The record's pool directory (e.g. 'article')
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
- if (!Array.isArray(items)) return items
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
- if (freeformEnabled) {
848
- return Promise.all(
849
- items.map(item =>
850
- translateItemAsync(item, recordDir, translations, schema, {
851
- locale,
852
- localesDir,
853
- freeformEnabled
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 items.map(item =>
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, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
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 merge data into parsedContent.data (not cascaded).
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 merges under its own key, so
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
  /**
@@ -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: [...] }`, the opt-in of April 2026,
292
- // is the shape this refuses: it declared a key named `inherit`.
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 fileUrl = pathToFileURL(filePath).href
47
- const module = await import(fileUrl)
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
@@ -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: Data fetching configuration (path, url, schema, prerender, merge, transform)
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}`)