@uniweb/build 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,15 +56,15 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
- "@uniweb/schemas": "~1.6.5",
60
- "@uniweb/semantic-parser": "~1.12.2",
61
- "@uniweb/projections": "~1.1.6",
59
+ "@uniweb/content-reader": "~1.5.5",
60
+ "@uniweb/projections": "~1.1.7",
62
61
  "@uniweb/content-writer": "~1.3.0",
63
- "@uniweb/content-reader": "~1.5.4",
62
+ "@uniweb/schemas": "~1.6.5",
63
+ "@uniweb/semantic-parser": "~1.13.0",
64
64
  "@uniweb/theming": "~1.2.1"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.15"
67
+ "@uniweb/runtime": "~1.1.18"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,8 +73,8 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/core": "~1.4.0",
77
- "@uniweb/runtime": "~1.1.15"
76
+ "@uniweb/core": "~1.4.3",
77
+ "@uniweb/runtime": "~1.1.18"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -1011,11 +1011,12 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
1011
1011
  }
1012
1012
 
1013
1013
  // ⭐ AND THE QUERY'S PER-RECORD FILES, beside the translated list and with the
1014
- // same translations — `/<locale>/data/<query>/<slug>.json`. A query with
1015
- // `deferred:` fields writes each record whole there, and a parametric page reads
1016
- // its record from that file. ⛔ Until 2026-09-14 they were not written for any
1017
- // locale, so a localized site's records showed their deferred fields — an
1018
- // article's body — in the source language under a translated list.
1014
+ // same translations — `/<locale>/data/<query>/<$name>.json`. Every query writes each
1015
+ // record whole there (2026-09-27), and a parametric page reads its record from that
1016
+ // file. ⛔ Until 2026-09-14 they were not written for any locale, so a localized
1017
+ // site's records showed their whole fields — an article's body — in the source
1018
+ // language under a translated list. ⛔ *This said only a `deferred:` query had them
1019
+ // until 2026-10-24; `deferred:` was removed on 2026-09-27.*
1019
1020
  // They are translated first: a lean list's derived excerpt is derived from its record's
1020
1021
  // translated body (`EXCERPT`).
1021
1022
  const localeRecordsDir = join(localeDataDir, queryName)
package/src/prerender.js CHANGED
@@ -28,6 +28,7 @@ import DataStore from '@uniweb/core/datastore'
28
28
  import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
29
29
  import { createFileTransport } from './site/file-transport.js'
30
30
  import { shouldSplitContent } from './site/split-content.js'
31
+ import { jsonForScript, splitPageSections } from '@uniweb/core/data-script'
31
32
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
32
33
  import { getAdapter } from './hosts/index.js'
33
34
  import { detectCiContext } from './hosts/detect-ci-context.js'
@@ -724,18 +725,10 @@ export function injectBuildData(html, siteContent, { splitContent = false, curre
724
725
  delete contentForJson.theme.links
725
726
  }
726
727
 
727
- // Split mode: strip sections from all pages except the current one.
728
- // Dynamic templates (isDynamic) keep their sections — needed by _createDynamicPage().
728
+ // Split mode: sections for the current page and every dynamic template only — the rule a host
729
+ // writes its `__DATA__` by too (`@uniweb/core/data-script`). Every other page's are in `_pages/`.
729
730
  if (splitContent) {
730
- contentForJson = {
731
- ...contentForJson,
732
- pages: contentForJson.pages.map(page => {
733
- if (page.route === currentRoute) return page
734
- if (page.isDynamic) return page
735
- const { sections, ...metadata } = page
736
- return metadata
737
- })
738
- }
731
+ contentForJson = { ...contentForJson, pages: splitPageSections(contentForJson.pages, currentRoute) }
739
732
  }
740
733
 
741
734
  // Scope fetched (collection / API) data to this page's cascade in split mode,
@@ -748,7 +741,7 @@ export function injectBuildData(html, siteContent, { splitContent = false, curre
748
741
  }
749
742
  }
750
743
 
751
- const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson).replace(/</g, '\\u003c')}</script>`
744
+ const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${jsonForScript(contentForJson)}</script>`
752
745
  if (result.includes('__SITE_CONTENT__')) {
753
746
  // Replace existing site content with updated version (includes expanded dynamic routes)
754
747
  result = result.replace(
@@ -764,7 +757,7 @@ export function injectBuildData(html, siteContent, { splitContent = false, curre
764
757
 
765
758
  // Inject icon cache so client can render icons immediately without CDN fetches
766
759
  if (siteContent._iconCache) {
767
- const iconScript = `<script id="__ICON_CACHE__" type="application/json">${JSON.stringify(siteContent._iconCache).replace(/</g, '\\u003c')}</script>`
760
+ const iconScript = `<script id="__ICON_CACHE__" type="application/json">${jsonForScript(siteContent._iconCache)}</script>`
768
761
  if (result.includes('__ICON_CACHE__')) {
769
762
  result = result.replace(
770
763
  /<script[^>]*id="__ICON_CACHE__"[^>]*>[\s\S]*?<\/script>/,
@@ -241,6 +241,22 @@ function detectVersions(folderNames) {
241
241
  return versions
242
242
  }
243
243
 
244
+ /**
245
+ * An id an author wrote — a page's `id:` in `page.yml`, a section's in its frontmatter — as the
246
+ * string it names. YAML reads `id: 404` as a number, and an id is never one: a `page:404` link
247
+ * names the string, and a store holds a stable id as text.
248
+ *
249
+ * ⛔ *Until 2026-10-24 the parsed value was kept, so a page with `id: 404` matched no `page:`
250
+ * link, and a push sent its stable id as a number, which a backend refuses (measured that day:
251
+ * HTTP 400, "item.data.stable_id must be a string").*
252
+ *
253
+ * @param {*} value
254
+ * @returns {string|null} null for an id not written, or written empty
255
+ */
256
+ export function authoredId(value) {
257
+ return value === undefined || value === null || value === '' ? null : String(value)
258
+ }
259
+
244
260
  /**
245
261
  * `query:` — the shorthand for `fetch: { query }`, at every level that declares
246
262
  * data: a section's frontmatter, `page.yml`, `folder.yml` and `site.yml`, on the
@@ -1168,7 +1184,7 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
1168
1184
 
1169
1185
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
1170
1186
  // This ID is stable across reordering (unlike the positional id)
1171
- const stableId = frontmatterId || defaultStableId || null
1187
+ const stableId = authoredId(frontmatterId) || defaultStableId || null
1172
1188
 
1173
1189
  const section = {
1174
1190
  id,
@@ -1434,7 +1450,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1434
1450
  page: {
1435
1451
  route,
1436
1452
  sourcePath: parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`,
1437
- id: pageConfig.id || null,
1453
+ id: authoredId(pageConfig.id),
1438
1454
  isIndex,
1439
1455
  title: resolveDisplayTitle(pageConfig.title, pageName, null),
1440
1456
  description: pageConfig.description || '',
@@ -1460,7 +1476,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1460
1476
  page: {
1461
1477
  route,
1462
1478
  sourcePath: parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`,
1463
- id: pageConfig.id || null,
1479
+ id: authoredId(pageConfig.id),
1464
1480
  isIndex,
1465
1481
  title: resolveDisplayTitle(pageConfig.title, pageName, null),
1466
1482
  description: pageConfig.description || '',
@@ -1731,7 +1747,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1731
1747
  page: {
1732
1748
  route,
1733
1749
  sourcePath, // Original folder-based path (for ancestor checking in navigation)
1734
- id: pageConfig.id || null, // Stable page ID for page: links (survives reorganization)
1750
+ id: authoredId(pageConfig.id), // Stable page ID for page: links (survives reorganization)
1735
1751
  isIndex, // Marks this page as the index for its parent route
1736
1752
  title: resolveDisplayTitle(pageConfig.title, pageName, hierarchicalSections),
1737
1753
  description: pageConfig.description || '',
package/src/site/index.js CHANGED
@@ -12,9 +12,10 @@ export { defineSiteConfig, readSiteConfig, default } from './config.js'
12
12
  export { PREVIEW_ENV, encodePreview, readPreview } from './preview.js'
13
13
  export { collectSiteContent } from './content-collector.js'
14
14
  // The `agents:` vocabulary lives in @uniweb/projections (which owns the block);
15
- // re-exported so the CLI can validate an author's spelling without taking a
16
- // direct dependency on it. Same shape as the search re-exports.
17
- export { AGENTS_KEYS } from '@uniweb/projections'
15
+ // re-exported so the CLI can validate an author's spelling, and read an
16
+ // `expectedOrigins` entry by its rule, without taking a direct dependency on it.
17
+ // Same shape as the search re-exports.
18
+ export { AGENTS_KEYS, originOf } from '@uniweb/projections'
18
19
  export { buildSiteData } from './build-site-data.js'
19
20
  export { shouldSplitContent } from './split-content.js'
20
21
  export {
@@ -53,6 +53,9 @@ import { processQueries, writeQueryFiles } from './query-processor.js'
53
53
  import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
54
54
  import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
55
55
  import { shouldSplitContent } from './split-content.js'
56
+ import { jsonForScript } from '@uniweb/core/data-script'
57
+ // A favicon's MIME type by its extension — the rule a host's page shell types it by too.
58
+ import { faviconType } from '@uniweb/core/page-shell'
56
59
  import { FONT_LINKS_MARKER } from './head-markers.js'
57
60
  import { collectExtensionAssets, isBrowserAsset } from './emit-extensions.js'
58
61
  import { extensionNameOf, findSiteExtension } from './workspace-extension.js'
@@ -86,14 +89,6 @@ function extensionContentType(file) {
86
89
  return EXTENSION_CONTENT_TYPES[file.split('.').pop()?.toLowerCase()] || 'application/octet-stream'
87
90
  }
88
91
 
89
- function faviconTypeFor(href) {
90
- const ext = href.split('?')[0].split('.').pop()?.toLowerCase()
91
- if (ext === 'svg') return 'image/svg+xml'
92
- if (ext === 'ico') return 'image/x-icon'
93
- if (ext === 'png') return 'image/png'
94
- return null
95
- }
96
-
97
92
  /**
98
93
  * Resolve a favicon href for index.html injection.
99
94
  * - If config.favicon is a string, use it as-is.
@@ -1572,14 +1567,14 @@ export function siteContentPlugin(options = {}) {
1572
1567
  // Source 2: auto-detect public/favicon.{ico,svg,png}
1573
1568
  const faviconHref = resolveFaviconHref(contentToInject?.config?.favicon, resolvedPublicDir, basePath)
1574
1569
  if (faviconHref) {
1575
- const type = faviconTypeFor(faviconHref)
1570
+ const type = faviconType(faviconHref)
1576
1571
  const typeAttr = type ? ` type="${type}"` : ''
1577
1572
  headInjection += ` <link rel="icon"${typeAttr} href="${faviconHref}">\n`
1578
1573
  }
1579
1574
 
1580
1575
  // Inject content as JSON script tag
1581
1576
  if (inject) {
1582
- headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(contentToInject).replace(/</g, '\\u003c')}</script>\n`
1577
+ headInjection += ` <script type="application/json" id="${variableName}">${jsonForScript(contentToInject)}</script>\n`
1583
1578
  }
1584
1579
 
1585
1580
  let result = html
@@ -36,7 +36,8 @@ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
36
36
  import { readRegisteredFoundation } from './registered-foundation.js'
37
37
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
38
38
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
39
- import { buildSchema } from '../schema.js'
39
+ import { buildSchema, loadFoundationConfig } from '../schema.js'
40
+ import { defaultSectionType } from '@uniweb/core/section-type'
40
41
  import { refuseQueryRoute, refuseLimit, refuseFilter } from './data-fetcher.js'
41
42
  import { readFile } from 'node:fs/promises'
42
43
  import yaml from 'js-yaml'
@@ -466,6 +467,30 @@ export async function foundationSections(siteRoot, siteYml = null) {
466
467
  return foundationSchemaJson(siteRoot, yml)
467
468
  }
468
469
 
470
+ /**
471
+ * The type a section that names none renders with on this site — its foundation's
472
+ * `defaultSection`, else `Section` (`@uniweb/core/section-type`, the rule the site's render
473
+ * asks). A push fills it into such a section, since a store requires a type, and a pull leaves it
474
+ * out of the section's file again. Read like `foundationSections`: from the foundation's source
475
+ * when it is local, else from its built or registered `schema.json`, else `Section`.
476
+ *
477
+ * @param {string} siteRoot
478
+ * @param {object} [siteYml] - an already-read site.yml
479
+ * @returns {Promise<string>}
480
+ */
481
+ export async function siteDefaultSectionType(siteRoot, siteYml = null) {
482
+ const yml = siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
483
+ const srcDir = localFoundationSrcDir(siteRoot, yml)
484
+ if (srcDir) {
485
+ try {
486
+ return defaultSectionType(await loadFoundationConfig(srcDir))
487
+ } catch {
488
+ // a foundation whose source does not read is reported by the build that builds it
489
+ }
490
+ }
491
+ return defaultSectionType(foundationSchemaJson(siteRoot, yml)?._self)
492
+ }
493
+
469
494
  /** The data schemas a site's foundation declares, or null when unresolvable. */
470
495
  export function foundationDataSchemas(siteRoot, siteYml) {
471
496
  return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
package/src/uwx/index.js CHANGED
@@ -83,6 +83,9 @@ export { matchStoredRecords } from './record-identity.js'
83
83
  export { siteSelfScope } from './self-scope.js'
84
84
  // What each section type declares its data blocks are — a pull reads a translated block back by it.
85
85
  export { dataBlockModels } from '../i18n/data-models.js'
86
+ // The type a section that names none renders with — a push fills it in, and a pull, handed it up
87
+ // front like the scope above, leaves it out of the section's file.
88
+ export { siteDefaultSectionType } from '../site/queries-config.js'
86
89
  export {
87
90
  siteInfoToConfig,
88
91
  sectionRecordToFile,
@@ -44,7 +44,8 @@ import yaml from 'js-yaml'
44
44
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
45
45
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
46
46
  import { declarationsToQueriesYml } from './records-project.js'
47
- import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
47
+ import { buildRouteOf } from './site.js'
48
+ import { DEFAULT_SECTION_TYPE } from '@uniweb/core/section-type'
48
49
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
49
50
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
50
51
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, isProseMirrorDoc } from './locale-sync.js'
@@ -381,9 +382,11 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
381
382
  * field to (its source doc → the `.md` body)
382
383
  * @param {object} [params.collector] - translation collector; target-locale
383
384
  * structural maps on a localized `content` field are captured into it
385
+ * @param {string} [params.impliedType] - the type a section that names none renders with on this
386
+ * site (`siteDefaultSectionType`), which a file leaves out
384
387
  * @returns {'updated'|'unchanged'}
385
388
  */
386
- export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null, dataModel = null }) {
389
+ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, freeformRelPath, freeformCandidates = null, writeId = true, context = null, dataModel = null, impliedType = DEFAULT_SECTION_TYPE }) {
387
390
  // ⛔ A stored `preset` or `input` is not written back: neither has an effect, and the build
388
391
  // would only warn about it (2026-09-27).
389
392
  // ⛔ No `background` / `theme_override` / `insets` / `fetch`: the site-content Model dropped all
@@ -416,14 +419,15 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
416
419
  // for names alone (`authorableDeclaration`, the inverse of the build's resolution), and
417
420
  // removed from the file of a section that declares none. ⛔ Until 2026-10-16 a file's own
418
421
  // declaration was kept over the backend's, and the next edit to the section pushed it back.
419
- // ⛔ Not `type: Content` into a file that names no type: it is what the push sent for it, and
420
- // absent, the foundation's `defaultSection` renders it. Until 2026-09-26 a pull wrote it back.
422
+ // ⛔ Not the implied type into a file that names no type: it is what the push sent for it, and
423
+ // absent, the same type renders it (`siteDefaultSectionType`). Until 2026-09-26 a pull wrote it
424
+ // back; until 2026-10-23 the type was `Content`, which the push sent and no foundation rendered.
421
425
  return writeSectionFile({
422
426
  filePath,
423
427
  content: sourceContent,
424
428
  params: frontmatter,
425
429
  declaration: paramFetch ?? null,
426
- implied: { type: FILLED_SECTION_TYPE },
430
+ implied: { type: impliedType },
427
431
  })
428
432
  }
429
433
 
@@ -542,6 +546,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
542
546
  sourceLocale: ctx?.sourceLocale,
543
547
  collector: ctx?.collector,
544
548
  dataModel: ctx?.dataModel,
549
+ impliedType: ctx?.sectionType,
545
550
  freeformRelPath,
546
551
  freeformCandidates,
547
552
  writeId: fileSectionName(filePath) !== stableId,
@@ -1242,6 +1247,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
1242
1247
  sourceLocale: ctx?.sourceLocale,
1243
1248
  collector: ctx?.collector,
1244
1249
  dataModel: ctx?.dataModel,
1250
+ impliedType: ctx?.sectionType,
1245
1251
  writeId: fileSectionName(filePath) !== stableId,
1246
1252
  freeformRelPath: stableId ? buildFreeformPath({ stableId }, page) : null,
1247
1253
  freeformCandidates: stableId ? freeformPathsFor({ stableId }, page) : null,
@@ -1329,9 +1335,12 @@ function refuseContentFilesCannotHold(document, sourceLocale) {
1329
1335
  * @param {string|null} [params.scope] - the scope the push qualified a query's
1330
1336
  * `@/x` schema with, so it is written back as `@/x` — see
1331
1337
  * `declarationsToQueriesYml`, whose default it overrides.
1338
+ * @param {string} [params.sectionType] - the type a section that names none renders with on this
1339
+ * site, which the push filled in and a section's file leaves out — `siteDefaultSectionType`,
1340
+ * read by the caller since it reads the foundation's source
1332
1341
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
1333
1342
  */
1334
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
1343
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), prune = false, keepAuthoredFoundation = false, scope, dataModel = null, sectionType = DEFAULT_SECTION_TYPE }) {
1335
1344
  // Before anything is written: a pull that would leave content out of the files stops here.
1336
1345
  refuseContentFilesCannotHold(document, sourceLocale)
1337
1346
 
@@ -1363,7 +1372,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1363
1372
  // rename detection, build a fresh one as we project, then persist it. Items not
1364
1373
  // re-projected (deleted) drop out naturally. `collector` rides along to capture
1365
1374
  // localized scalars during the page walk.
1366
- const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map(), dataModel }
1375
+ const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale, sectionFiles: new Map(), dataModel, sectionType }
1367
1376
  // Where the site keeps its localized URLs — `site.yml`'s map, when it has one (`pageSlugToYml`).
1368
1377
  const siteMap = readAuthoredYaml(join(siteRoot, 'site.yml'))?.i18n?.routeTranslations
1369
1378
  ctx.routes = { siteMap: isPlainRecord(siteMap) && Object.keys(siteMap).length ? siteMap : null, slugs: new Map() }
package/src/uwx/site.js CHANGED
@@ -71,10 +71,12 @@ import {
71
71
  rootOrderConfig,
72
72
  composeLocalizedRoute,
73
73
  isValidSlugSegment,
74
+ authoredId,
74
75
  } from '../site/content-collector.js'
75
76
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
76
77
  import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
77
78
  import { normalizeHideIn } from '../site/nav-visibility.js'
79
+ import { siteDefaultSectionType } from '../site/queries-config.js'
78
80
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
79
81
  import { emitEntitySyncPackage } from './entity-document.js'
80
82
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent, plainTextTargets } from './locale-sync.js'
@@ -116,10 +118,6 @@ function scalarString(value) {
116
118
  // processMarkdownFile takes only type/hidden/props/fetch/query/data/id out of the
117
119
  // frontmatter (and drops preset/input), so `background:` and `theme:` stay
118
120
  // inside section.params — and ride there on the wire too (`mapSectionData`).
119
- // The `type` a push sends for a section whose file names none — the Model requires one. A pull does
120
- // not write it back into a file that names none (`sectionRecordToFile`): absent, the foundation's
121
- // `defaultSection` renders it, which is not always `Content`.
122
- export const FILLED_SECTION_TYPE = 'Content'
123
121
 
124
122
  const AUTHORED_SLUG = Symbol('authoredSlug')
125
123
  const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -291,7 +289,8 @@ function mapSectionData(section) {
291
289
  // the `fetch` field beside the params, equal; a pull still reads a stored field where `params`
292
290
  // has no `fetch` (`sectionRecordToFile`). uwx-format.md § A page section's fields.
293
291
  if (section.fetch) params.fetch = section.fetch
294
- const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
292
+ // A section whose file names no type gets one once the tree is collected (`fillSectionTypes`).
293
+ const data = { type: section.type }
295
294
  setIf(data, 'stable_id', section.stableId ?? undefined)
296
295
  // ⭐ A draft section — the store's own `hidden`, which an editor also writes [2026-09-28].
297
296
  if (section.hidden === true) data.hidden = true
@@ -314,7 +313,8 @@ function buildPageData(config, ctx) {
314
313
  // `localizeRouteSlugs`, once the whole tree is known. `mode` is the plain
315
314
  // delivery mode.
316
315
  const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
317
- setIf(data, 'stable_id', config.id)
316
+ // An authored id is a string (`authoredId`): `id: 404` reads as a number, which a store refuses.
317
+ setIf(data, 'stable_id', authoredId(config.id) ?? undefined)
318
318
  setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
319
319
  setIf(data, 'description', localizeScalar(config.description, sourceLocale, translations))
320
320
  setIf(data, 'label', localizeScalar(config.label, sourceLocale, translations))
@@ -522,6 +522,26 @@ function withIdentity(id, fields) {
522
522
  return Object.assign({ $id: id }, fields)
523
523
  }
524
524
 
525
+ // ⭐ A SECTION WHOSE FILE NAMES NO TYPE IS SENT THE TYPE IT RENDERS WITH — the site's foundation's
526
+ // `defaultSection`, else `Section` (`siteDefaultSectionType`, by `@uniweb/core/section-type`, the
527
+ // rule the site's own render asks). The store requires a type, and a host renders what the store
528
+ // holds. ⛔ Until 2026-10-23 this sent `Content`, which no starter template's foundation has: every
529
+ // such section — every page of a mounted docs folder among them — read "Component not found:
530
+ // Content" on a hosted site while the files rendered `Section`.
531
+ function fillSectionTypes(records, type) {
532
+ for (const record of records || []) {
533
+ if (!record.type) record.type = type // the collector says `null` for a file that names none
534
+ fillSectionTypes(record.$children, type)
535
+ }
536
+ }
537
+
538
+ function fillPageSectionTypes(pages, type) {
539
+ for (const page of pages || []) {
540
+ fillSectionTypes(page.page_sections, type)
541
+ fillPageSectionTypes(page.$children, type)
542
+ }
543
+ }
544
+
525
545
  // One collected section → its `$`-record, recursing `subsections` into `$children`
526
546
  // (page_sections is self_nesting). `$id` is the section's stableId (rename-stable),
527
547
  // falling back to a positional handle only for a truly anonymous section.
@@ -1596,6 +1616,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1596
1616
  }
1597
1617
 
1598
1618
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
1619
+ // Before anything reads a section's type — localizing a data block asks it for the block's model.
1620
+ const sectionType = await siteDefaultSectionType(siteRoot, siteYml)
1621
+ fillPageSectionTypes(pages, sectionType)
1622
+ fillSectionTypes(layoutSections, sectionType)
1599
1623
 
1600
1624
  // Wrap each section's content into its per-locale form (source doc + target
1601
1625
  // structural maps from locales/{locale}.json, or a free-form body override from