@uniweb/build 0.14.12 → 0.14.14

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": "0.14.12",
3
+ "version": "0.14.14",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,22 +59,22 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.3",
63
- "@uniweb/content-writer": "0.2.5"
62
+ "@uniweb/content-writer": "0.2.5",
63
+ "@uniweb/theming": "0.1.4"
64
64
  },
65
65
  "optionalDependencies": {
66
66
  "@uniweb/content-reader": "1.1.12",
67
- "@uniweb/schemas": "0.2.3",
68
- "@uniweb/runtime": "0.8.17"
67
+ "@uniweb/runtime": "0.8.18",
68
+ "@uniweb/schemas": "0.2.3"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
72
- "react": "^18.0.0 || ^19.0.0",
73
- "react-dom": "^18.0.0 || ^19.0.0",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0",
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "0.7.12"
77
+ "@uniweb/core": "0.7.13"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -6,8 +6,9 @@
6
6
  //
7
7
  // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
8
  // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
- // `{ entry: <uuid>, path_segment: <slug> }` inside a branch whose
10
- // `path_segment` is the collection name (folder.js `defaultEntries`). The
9
+ // `{ entry: { model, entity: <uuid> }, path_segment: <slug> }` inside a branch
10
+ // (its `$children`) whose `path_segment` is the collection name (folder.js
11
+ // `defaultContents`). The
11
12
  // folder is the authoritative organization on a read (the record document's
12
13
  // own `$id` envelope is not guaranteed to be echoed back), with the record
13
14
  // document's `$id` (`<collection>/<slug>`) used as a fallback when present.
@@ -108,24 +109,28 @@ function briefHasContentBody(declaration) {
108
109
  return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
109
110
  }
110
111
 
111
- // Build `uuid → { collection, slug }` from the folder document's ref leaves. A
112
- // leaf sits in a branch whose `path_segment` is the collection; the leaf's
113
- // `path_segment` is the slug and its `entry` is the record uuid. Nested branches
112
+ // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
113
+ // folder is a self-nesting tree under `contents`, nesting via `$children` (the
114
+ // site-content invariant folder.js). A leaf sits in a branch whose
115
+ // `path_segment` is the collection; the leaf's `path_segment` is the slug and its
116
+ // `entry` is the entity_ref open form `{ model, entity: <uuid> }`. Nested branches
114
117
  // are walked; the collection is the NEAREST enclosing branch segment (correct for
115
118
  // the default one-branch-per-collection org; a deeply nested virtual org may
116
119
  // differ — see the module header).
117
120
  function indexFolder(folderDoc) {
118
121
  const byUuid = new Map()
119
- const walk = (entries, collection) => {
120
- for (const node of entries || []) {
122
+ const walk = (nodes, collection) => {
123
+ for (const node of nodes || []) {
121
124
  if (node?.kind === 'branch') {
122
- walk(node.entries, node.path_segment ?? collection)
125
+ walk(node.$children, node.path_segment ?? collection)
123
126
  } else if (node?.kind === 'ref' && node.entry) {
124
- byUuid.set(node.entry, { collection, slug: node.path_segment })
127
+ // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
128
+ const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
129
+ if (uuid) byUuid.set(uuid, { collection, slug: node.path_segment })
125
130
  }
126
131
  }
127
132
  }
128
- walk(folderDoc?.entries, null)
133
+ walk(folderDoc?.contents, null)
129
134
  return byUuid
130
135
  }
131
136
 
@@ -245,7 +250,7 @@ export function declarationsToCollectionsYml({ document, siteRoot }) {
245
250
  * Project a pulled folder + its record entities to `collections/**` files.
246
251
  *
247
252
  * @param {object} params
248
- * @param {object} params.folderDoc - the `@uniweb/folder` document `{ $uuid?, entries }`
253
+ * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
249
254
  * @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $model, <brief> }`
250
255
  * @param {string} params.siteRoot
251
256
  * @param {object} params.opts
@@ -99,6 +99,7 @@ const INFO_TO_SITE_YML = {
99
99
  search: 'search',
100
100
  paths: 'paths',
101
101
  data: 'data',
102
+ template: 'template',
102
103
  }
103
104
 
104
105
  /**
@@ -390,12 +391,16 @@ function pruneOrphanPageDirs(pagesDir, keepDirs, report) {
390
391
  // wipe it).
391
392
  function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
392
393
  writePagesTree(pages, pagesDir, sourceLocale, report, ctx)
393
- if (prune) prunePagesTree(pages, pagesDir, report)
394
+ if (prune) prunePagesTree(pages, pagesDir, sourceLocale, report)
394
395
  }
395
396
 
396
397
  // The directory name for a page record (slug, or `[param]/` for a dynamic page).
397
- function pageDirName(record) {
398
- return record.is_dynamic ? `[${record.param_name || record.slug}]` : record.slug
398
+ // `slug` is a localized `{lang: value}` map on the wire (a page route stays
399
+ // localized); the directory uses the canonical SOURCE-locale slug. `param_name`
400
+ // is already a plain string.
401
+ function pageDirName(record, sourceLocale) {
402
+ const slug = unwrapLocalized(record.slug, sourceLocale)
403
+ return record.is_dynamic ? `[${record.param_name || slug}]` : slug
399
404
  }
400
405
 
401
406
  // Pass 1 — write + relocate every page dir, its page.yml/folder.yml, and its
@@ -404,7 +409,8 @@ function pageDirName(record) {
404
409
  // producer's slugPath, normalizeRouteForPath strips any leading slash on both).
405
410
  function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix = '') {
406
411
  for (const record of pages || []) {
407
- const pageDir = join(pagesDir, pageDirName(record))
412
+ const slug = unwrapLocalized(record.slug, sourceLocale) // localized {lang:value} → canonical
413
+ const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
408
414
  // Relocate the whole page dir if this uuid moved to a new slug, then record it.
409
415
  placeByUuid(ctx, record.$uuid, pageDir)
410
416
 
@@ -417,7 +423,7 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
417
423
  if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw))
418
424
  else ctx.collector?.add(record.keywords)
419
425
 
420
- const route = routePrefix ? `${routePrefix}/${record.slug}` : record.slug
426
+ const route = routePrefix ? `${routePrefix}/${slug}` : slug
421
427
  const pageContext = { route, id: record.stable_id }
422
428
 
423
429
  let sectionsArray = []
@@ -456,16 +462,16 @@ function collectSectionFileBases(pageSections) {
456
462
 
457
463
  // Pass 2 — prune orphan section files (per page dir) and orphan page dirs (per
458
464
  // level), AFTER every relocation in pass 1. Guarded against wiping an empty level.
459
- function prunePagesTree(pages, pagesDir, report) {
465
+ function prunePagesTree(pages, pagesDir, sourceLocale, report) {
460
466
  const incomingDirs = new Set()
461
467
  for (const record of pages || []) {
462
- incomingDirs.add(pageDirName(record))
463
- const pageDir = join(pagesDir, pageDirName(record))
468
+ incomingDirs.add(pageDirName(record, sourceLocale))
469
+ const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
464
470
  if (record.mode === 'page') {
465
471
  const keep = collectSectionFileBases(record.page_sections)
466
472
  if (keep.size > 0) pruneOrphanSectionFiles(pageDir, keep, report)
467
473
  }
468
- prunePagesTree(record.$children || [], pageDir, report)
474
+ prunePagesTree(record.$children || [], pageDir, sourceLocale, report)
469
475
  }
470
476
  if (incomingDirs.size > 0) pruneOrphanPageDirs(pagesDir, incomingDirs, report)
471
477
  }
package/src/uwx/site.js CHANGED
@@ -49,6 +49,7 @@ import { normalizeHideIn } from '../site/nav-visibility.js'
49
49
  import { emitEntitySyncPackage } from './entity-document.js'
50
50
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
51
51
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
52
+ import { unwrapLocalized } from './backfill.js'
52
53
  import { loadFreeformTranslation } from '../i18n/freeform.js'
53
54
  import { upsertYamlScalar } from './yaml-upsert.js'
54
55
  import { resolveCollectionsConfig } from './collections-config.js'
@@ -99,7 +100,10 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
99
100
  }
100
101
  const visitPages = async (pgs, routePrefix) => {
101
102
  for (const p of pgs || []) {
102
- const route = routePrefix ? `${routePrefix}/${p.slug}` : p.slug
103
+ // `slug` is a localized `{lang:value}` map; the free-form route is the
104
+ // canonical (source-locale) path, so unwrap before building it.
105
+ const slug = unwrapLocalized(p.slug, sourceLocale)
106
+ const route = routePrefix ? `${routePrefix}/${slug}` : slug
103
107
  const page = { route, id: p.stable_id }
104
108
  if (Array.isArray(p.page_sections)) await visitSections(p.page_sections, page)
105
109
  if (Array.isArray(p.$children)) await visitPages(p.$children, route)
@@ -535,7 +539,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
535
539
  const translations = targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales) : null
536
540
 
537
541
  const info = {}
538
- info.name = localizeScalar(siteYml.name, sourceLocale, translations)
542
+ // `name` is an identity LABEL (the author's own handle for the site, like a
543
+ // filename) — single-language by nature, and it must ALWAYS render: under locale
544
+ // projection a localized field with no entry for the requested locale is dropped,
545
+ // so a `{en:…}`-only name would vanish. It therefore ships as a plain string, never
546
+ // a localized `{lang:value}` map. Genuine content fields (`description` below, page
547
+ // title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
548
+ info.name = siteYml.name
539
549
  setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
540
550
  if (themeYml && Object.keys(themeYml).length > 0) info.theme = themeYml
541
551
  setIf(info, 'languages', siteYml.languages)
@@ -554,6 +564,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
554
564
  setIf(info, 'search', siteYml.search)
555
565
  setIf(info, 'paths', siteYml.paths)
556
566
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
567
+ // `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
568
+ // backend applies a clonability designation to this site-content entity (it is
569
+ // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
570
+ setIf(info, 'template', siteYml.template)
557
571
 
558
572
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
559
573
  const pagesPath = siteYml.paths?.pages
@@ -13,6 +13,7 @@ import { join, resolve } from 'node:path'
13
13
  import { buildSchema } from './schema.js'
14
14
  import { generateEntryPoint, shouldRegenerateForFile } from './generate-entry.js'
15
15
  import { processAllPreviews } from './images.js'
16
+ import { generateFoundationVars } from './theme/index.js'
16
17
 
17
18
  /**
18
19
  * Build schema.json with preview image references
@@ -300,6 +301,45 @@ async function buildSSRBundle(outDir) {
300
301
  }
301
302
  }
302
303
 
304
+ /**
305
+ * Append the foundation's theme-variable DEFAULTS as a `:root{}` baseline to the
306
+ * built CSS (`assets/style.css`), so a runtime-loaded foundation carries its own
307
+ * var defaults wherever it loads (registry ref / URL — where the site build can't
308
+ * read them). Context-aware vars (color/gradient) are excluded: those are applied
309
+ * per light/dark context by the site theme, not as a flat default. Idempotent
310
+ * (marker-guarded), and a no-op when the foundation declares no vars or ships no
311
+ * stylesheet to carry them.
312
+ */
313
+ async function emitFoundationVarsCss(outDir, schema) {
314
+ const rawVars = schema?._self?.vars
315
+ if (!rawVars || Object.keys(rawVars).length === 0) return
316
+
317
+ const CONTEXT_AWARE = new Set(['color', 'gradient'])
318
+ const flatVars = Object.fromEntries(
319
+ Object.entries(rawVars).filter(
320
+ ([, cfg]) => !(cfg && typeof cfg === 'object' && CONTEXT_AWARE.has(cfg.type))
321
+ )
322
+ )
323
+
324
+ const rootCss = generateFoundationVars(flatVars)
325
+ if (!rootCss) return
326
+
327
+ const cssPath = join(outDir, 'assets', 'style.css')
328
+ if (!existsSync(cssPath)) {
329
+ console.warn(
330
+ `Foundation declares ${Object.keys(flatVars).length} theme var(s) but has no assets/style.css to carry their defaults — skipped.`
331
+ )
332
+ return
333
+ }
334
+
335
+ const marker = '/* uniweb:foundation-var-defaults */'
336
+ const existing = await readFile(cssPath, 'utf-8')
337
+ if (existing.includes(marker)) return
338
+
339
+ await writeFile(cssPath, `${existing.trimEnd()}\n\n${marker}\n${rootCss}\n`, 'utf-8')
340
+ console.log(`Emitted ${Object.keys(flatVars).length} foundation theme-var default(s) to assets/style.css`)
341
+ }
342
+
303
343
  /**
304
344
  * Vite plugin for foundation builds
305
345
  */
@@ -359,6 +399,18 @@ export function foundationBuildPlugin(options = {}) {
359
399
 
360
400
  console.log(`Generated meta/schema.json with ${Object.keys(schema).length - 1} components`)
361
401
 
402
+ // Emit the foundation's theme-variable DEFAULTS as a :root{} baseline into
403
+ // the delivered CSS. A runtime-loaded foundation (registry ref / URL) is
404
+ // loaded with only its dist/ — the site build can't read these defaults
405
+ // (they live in schema._self.vars, which the site never sees), so without
406
+ // this the foundation renders with its theme vars undefined (e.g. collapsed
407
+ // section spacing where components use py-[var(--section-padding-y)]).
408
+ // Shipping the defaults in the foundation's own CSS makes it self-sufficient
409
+ // in every load mode; a site's theme.yml overrides still win (the site theme
410
+ // loads after the foundation CSS). Bundled sites already get these via the
411
+ // site build's theme.css — this is harmless redundancy there.
412
+ await emitFoundationVarsCss(outDir, schema)
413
+
362
414
  // Emit runtime-pin.json so the edge isolate (under Strategy S) can
363
415
  // side-load the matching runtime/{ver}/ssr.js. Lands silently before
364
416
  // the dual-mode resolver ships; foundations published in the dual-mode