@uniweb/build 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
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/content-reader": "~1.5.4",
60
+ "@uniweb/projections": "~1.1.7",
59
61
  "@uniweb/schemas": "~1.6.5",
60
62
  "@uniweb/semantic-parser": "~1.12.2",
61
- "@uniweb/projections": "~1.1.6",
62
- "@uniweb/content-writer": "~1.3.0",
63
- "@uniweb/content-reader": "~1.5.4",
64
- "@uniweb/theming": "~1.2.1"
63
+ "@uniweb/theming": "~1.2.1",
64
+ "@uniweb/content-writer": "~1.3.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "~1.1.15"
67
+ "@uniweb/runtime": "~1.1.16"
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.1",
77
+ "@uniweb/runtime": "~1.1.16"
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>/,
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
@@ -75,6 +75,7 @@ import {
75
75
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
76
76
  import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
77
77
  import { normalizeHideIn } from '../site/nav-visibility.js'
78
+ import { siteDefaultSectionType } from '../site/queries-config.js'
78
79
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
79
80
  import { emitEntitySyncPackage } from './entity-document.js'
80
81
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent, plainTextTargets } from './locale-sync.js'
@@ -116,10 +117,6 @@ function scalarString(value) {
116
117
  // processMarkdownFile takes only type/hidden/props/fetch/query/data/id out of the
117
118
  // frontmatter (and drops preset/input), so `background:` and `theme:` stay
118
119
  // 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
120
 
124
121
  const AUTHORED_SLUG = Symbol('authoredSlug')
125
122
  const isPlainRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -291,7 +288,8 @@ function mapSectionData(section) {
291
288
  // the `fetch` field beside the params, equal; a pull still reads a stored field where `params`
292
289
  // has no `fetch` (`sectionRecordToFile`). uwx-format.md § A page section's fields.
293
290
  if (section.fetch) params.fetch = section.fetch
294
- const data = { type: section.type || FILLED_SECTION_TYPE } // entity type requires `type`
291
+ // A section whose file names no type gets one once the tree is collected (`fillSectionTypes`).
292
+ const data = { type: section.type }
295
293
  setIf(data, 'stable_id', section.stableId ?? undefined)
296
294
  // ⭐ A draft section — the store's own `hidden`, which an editor also writes [2026-09-28].
297
295
  if (section.hidden === true) data.hidden = true
@@ -522,6 +520,26 @@ function withIdentity(id, fields) {
522
520
  return Object.assign({ $id: id }, fields)
523
521
  }
524
522
 
523
+ // ⭐ A SECTION WHOSE FILE NAMES NO TYPE IS SENT THE TYPE IT RENDERS WITH — the site's foundation's
524
+ // `defaultSection`, else `Section` (`siteDefaultSectionType`, by `@uniweb/core/section-type`, the
525
+ // rule the site's own render asks). The store requires a type, and a host renders what the store
526
+ // holds. ⛔ Until 2026-10-23 this sent `Content`, which no starter template's foundation has: every
527
+ // such section — every page of a mounted docs folder among them — read "Component not found:
528
+ // Content" on a hosted site while the files rendered `Section`.
529
+ function fillSectionTypes(records, type) {
530
+ for (const record of records || []) {
531
+ if (!record.type) record.type = type // the collector says `null` for a file that names none
532
+ fillSectionTypes(record.$children, type)
533
+ }
534
+ }
535
+
536
+ function fillPageSectionTypes(pages, type) {
537
+ for (const page of pages || []) {
538
+ fillSectionTypes(page.page_sections, type)
539
+ fillPageSectionTypes(page.$children, type)
540
+ }
541
+ }
542
+
525
543
  // One collected section → its `$`-record, recursing `subsections` into `$children`
526
544
  // (page_sections is self_nesting). `$id` is the section's stableId (rename-stable),
527
545
  // falling back to a positional handle only for a truly anonymous section.
@@ -1596,6 +1614,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1596
1614
  }
1597
1615
 
1598
1616
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
1617
+ // Before anything reads a section's type — localizing a data block asks it for the block's model.
1618
+ const sectionType = await siteDefaultSectionType(siteRoot, siteYml)
1619
+ fillPageSectionTypes(pages, sectionType)
1620
+ fillSectionTypes(layoutSections, sectionType)
1599
1621
 
1600
1622
  // Wrap each section's content into its per-locale form (source doc + target
1601
1623
  // structural maps from locales/{locale}.json, or a free-form body override from