@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 +8 -8
- package/src/uwx/collections-project.js +16 -11
- package/src/uwx/site-project.js +15 -9
- package/src/uwx/site.js +16 -2
- package/src/vite-foundation-plugin.js +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.14.
|
|
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/
|
|
63
|
-
"@uniweb/
|
|
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/
|
|
68
|
-
"@uniweb/
|
|
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": "^
|
|
73
|
-
"react-dom": "^
|
|
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.
|
|
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
|
|
10
|
-
// `path_segment` is the collection name (folder.js
|
|
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.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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 = (
|
|
120
|
-
for (const node of
|
|
122
|
+
const walk = (nodes, collection) => {
|
|
123
|
+
for (const node of nodes || []) {
|
|
121
124
|
if (node?.kind === 'branch') {
|
|
122
|
-
walk(node
|
|
125
|
+
walk(node.$children, node.path_segment ?? collection)
|
|
123
126
|
} else if (node?.kind === 'ref' && node.entry) {
|
|
124
|
-
|
|
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?.
|
|
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 `{
|
|
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
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
|
|
398
|
-
|
|
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
|
|
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}/${
|
|
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
|
-
|
|
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
|
-
|
|
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
|