@uniweb/build 0.26.0 → 0.27.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.
- package/package.json +6 -6
- package/src/site/build-site-data.js +10 -3
- package/src/site/collection-processor.js +89 -6
- package/src/site/collections-config.js +260 -0
- package/src/site/config.js +6 -174
- package/src/site/content-collector.js +12 -0
- package/src/site/data-fetcher.js +47 -0
- package/src/site/foundation-ref.js +207 -0
- package/src/site/schemaless-data.js +2 -4
- package/src/uwx/collection-source.js +57 -1
- package/src/uwx/collections-config.js +9 -125
- package/src/uwx/collections.js +69 -20
- package/src/uwx/site.js +18 -3
- package/src/uwx/sync-package.js +2 -2
- package/src/vite-foundation-plugin.js +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
+
"@uniweb/schemas": "^0.2.11",
|
|
62
63
|
"@uniweb/theming": "^0.1.15",
|
|
63
|
-
"@uniweb/schemas": "^0.2.10",
|
|
64
64
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
65
|
-
"@uniweb/projections": "^0.3.
|
|
65
|
+
"@uniweb/projections": "^0.3.7",
|
|
66
66
|
"@uniweb/content-writer": "^0.3.4"
|
|
67
67
|
},
|
|
68
68
|
"optionalDependencies": {
|
|
69
|
-
"@uniweb/runtime": "^0.
|
|
70
|
-
"@uniweb/schemas": "^0.2.
|
|
69
|
+
"@uniweb/runtime": "^0.13.0",
|
|
70
|
+
"@uniweb/schemas": "^0.2.11",
|
|
71
71
|
"@uniweb/semantic-parser": "^1.3.1",
|
|
72
72
|
"@uniweb/content-reader": "^1.2.4"
|
|
73
73
|
},
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@tailwindcss/vite": "^4.0.0",
|
|
79
79
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
80
80
|
"vite-plugin-svgr": "^4.0.0",
|
|
81
|
-
"@uniweb/core": "^0.
|
|
81
|
+
"@uniweb/core": "^0.13.0"
|
|
82
82
|
},
|
|
83
83
|
"peerDependenciesMeta": {
|
|
84
84
|
"vite": {
|
|
@@ -72,9 +72,16 @@ import {
|
|
|
72
72
|
* - `sitemap.xml`, `robots.txt` — static-host bundle territory. Note these
|
|
73
73
|
* are simply absent from Uniweb-hosted sites today: nothing in platform
|
|
74
74
|
* generates them, at publish time or at request time.
|
|
75
|
-
* -
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* - ANY search index — neither the single-file `search-index.json` nor the
|
|
76
|
+
* split `_search/{locale}/*.json`. This lane emitted both until
|
|
77
|
+
* 2026-08-01 and emits neither now; see step 5 for why. A host that
|
|
78
|
+
* stores the content derives search from it.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ This entry claimed the opposite until 2026-08-26 — that link mode
|
|
81
|
+
* emitted the split files "instead", and named a consumer for them. It
|
|
82
|
+
* described an emission its own step 5 had already removed, in the
|
|
83
|
+
* paragraph a reader consults BEFORE the code, and it was read as
|
|
84
|
+
* evidence by a downstream consumer.
|
|
78
85
|
*
|
|
79
86
|
* @param {Object} params
|
|
80
87
|
* @param {string} params.siteRoot - Absolute path to the site directory.
|
|
@@ -4,10 +4,28 @@
|
|
|
4
4
|
* Processes content collections from markdown and YAML files into JSON data.
|
|
5
5
|
* Collections are defined in site.yml and processed at build time.
|
|
6
6
|
*
|
|
7
|
+
* ⛔ A COLLECTION `.md` IS NOT A PAGE-SECTION `.md`. Same extension, unrelated
|
|
8
|
+
* meanings, and the sync-side reader states this too
|
|
9
|
+
* (`build/src/uwx/collection-source.js`) because getting it backwards produces
|
|
10
|
+
* confident nonsense:
|
|
11
|
+
*
|
|
12
|
+
* - collection record — frontmatter is structured DATA whose shape is the
|
|
13
|
+
* collection's data schema; the body is the value of ONE declared field
|
|
14
|
+
* (the Model's content body field). Not "metadata".
|
|
15
|
+
* - page section — frontmatter is foundation/runtime CONFIG (`type:`, params,
|
|
16
|
+
* `theme:`); the body is authored content with no schema behind it.
|
|
17
|
+
*
|
|
18
|
+
* ⭐ And `.md` is the HYBRID case, not the general one. It exists for records
|
|
19
|
+
* that are part data and part prose — a blog article. YAML and JSON records are
|
|
20
|
+
* data only, have no body, and express nesting and arrays natively; they are the
|
|
21
|
+
* plain case rather than the exception. Reasoning about collections from the
|
|
22
|
+
* markdown shape alone imports a body and a content field that most records
|
|
23
|
+
* do not have.
|
|
24
|
+
*
|
|
7
25
|
* Features:
|
|
8
26
|
* - Discovers markdown (.md), data (.yml/.yaml), JSON (.json), and BibTeX (.bib)
|
|
9
27
|
* files in collection folders
|
|
10
|
-
* - Parses frontmatter for
|
|
28
|
+
* - Parses frontmatter for record data (markdown), full YAML or JSON (data items),
|
|
11
29
|
* or BibTeX → CSL-JSON (bibliography items)
|
|
12
30
|
* - Pure-data formats (YAML, JSON, BibTeX) accept either one record per file
|
|
13
31
|
* (mapping at the top, slug from filename) or many records per file (array
|
|
@@ -574,6 +592,63 @@ async function processContentItem(dir, filename, config, siteRoot, basePath) {
|
|
|
574
592
|
}
|
|
575
593
|
}
|
|
576
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Every source file in a collection, as paths relative to the collection root —
|
|
597
|
+
* `hello.md`, `2024/spring.md`, `2024/q1/notes.yml`.
|
|
598
|
+
*
|
|
599
|
+
* Nesting is how an author gives a collection an internal structure, and it is
|
|
600
|
+
* what the `path` field and the `under` predicate address. Before this walk the
|
|
601
|
+
* scan was a flat `readdir`, so a record in a subdirectory was not ignored with
|
|
602
|
+
* a warning — it was invisible, and the site simply rendered without it.
|
|
603
|
+
*
|
|
604
|
+
* `_`-prefixed and dot-prefixed names are skipped at every level, files and
|
|
605
|
+
* directories alike: `_drafts/` stays out of the build the same way `_draft.md`
|
|
606
|
+
* always has. That is also the escape hatch for a subdirectory that holds
|
|
607
|
+
* something other than records.
|
|
608
|
+
*/
|
|
609
|
+
async function collectSourceFiles(dir, rel = '') {
|
|
610
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
611
|
+
const out = []
|
|
612
|
+
for (const entry of entries) {
|
|
613
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|
614
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name
|
|
615
|
+
if (entry.isDirectory()) {
|
|
616
|
+
out.push(...(await collectSourceFiles(join(dir, entry.name), relPath)))
|
|
617
|
+
} else if (/\.(md|ya?ml|json|bib)$/i.test(entry.name)) {
|
|
618
|
+
out.push(relPath)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return out
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* A slug identifies one record within its collection — it is what a `[slug]`
|
|
626
|
+
* route matches and what a per-record file is named. Two files in different
|
|
627
|
+
* branches can now share a stem (`2024/notes.md`, `2025/notes.md`), which makes
|
|
628
|
+
* a previously theoretical collision reachable in ordinary authoring.
|
|
629
|
+
*
|
|
630
|
+
* The build does not rename or drop either record: the cascade keeps both, and
|
|
631
|
+
* whichever sorts last wins the route and the per-record file. That is a real
|
|
632
|
+
* ambiguity only the author can resolve, so it is reported rather than repaired.
|
|
633
|
+
*/
|
|
634
|
+
function warnDuplicateSlugs(items, collectionName) {
|
|
635
|
+
const seen = new Map()
|
|
636
|
+
for (const item of items) {
|
|
637
|
+
if (!item || item.slug === undefined) continue
|
|
638
|
+
const slug = String(item.slug)
|
|
639
|
+
const where = item.path ? `${item.path}/` : ''
|
|
640
|
+
if (seen.has(slug)) {
|
|
641
|
+
console.warn(
|
|
642
|
+
`[collection-processor] Collection "${collectionName}" has more than one record with ` +
|
|
643
|
+
`slug "${slug}" (${seen.get(slug)}${slug}, ${where}${slug}). Its detail route and ` +
|
|
644
|
+
`per-record file resolve to only one of them — give them distinct slugs.`
|
|
645
|
+
)
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
seen.set(slug, where)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
577
652
|
/**
|
|
578
653
|
* Collect and process all items in a collection folder
|
|
579
654
|
*
|
|
@@ -591,11 +666,7 @@ async function collectItems(siteDir, config, collectionsBase, basePath) {
|
|
|
591
666
|
return []
|
|
592
667
|
}
|
|
593
668
|
|
|
594
|
-
const
|
|
595
|
-
const itemFiles = files.filter(f =>
|
|
596
|
-
!f.startsWith('_') &&
|
|
597
|
-
(f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json') || f.endsWith('.bib'))
|
|
598
|
-
)
|
|
669
|
+
const itemFiles = await collectSourceFiles(collectionDir)
|
|
599
670
|
|
|
600
671
|
// Process all collection files (markdown → content items, YAML/JSON → data
|
|
601
672
|
// items, BibTeX → CSL-JSON bibliography items).
|
|
@@ -614,6 +685,16 @@ async function collectItems(siteDir, config, collectionsBase, basePath) {
|
|
|
614
685
|
})
|
|
615
686
|
)
|
|
616
687
|
|
|
688
|
+
// Stamp each record's position inside the collection BEFORE flattening, while
|
|
689
|
+
// a result is still aligned with the file it came from. A file's own array
|
|
690
|
+
// entries (array-form YAML/JSON, every .bib entry) all share its directory.
|
|
691
|
+
items = items.map((result, i) => {
|
|
692
|
+
const dir = dirname(itemFiles[i])
|
|
693
|
+
const path = dir === '.' ? '' : dir
|
|
694
|
+
if (Array.isArray(result)) return result.map((item) => item && { ...item, path })
|
|
695
|
+
return result && { ...result, path }
|
|
696
|
+
})
|
|
697
|
+
|
|
617
698
|
// Flatten one level: array-form YAML/JSON files and every .bib file
|
|
618
699
|
// contribute their entries individually.
|
|
619
700
|
items = items.flat()
|
|
@@ -621,6 +702,8 @@ async function collectItems(siteDir, config, collectionsBase, basePath) {
|
|
|
621
702
|
// Filter out nulls (unpublished items)
|
|
622
703
|
items = items.filter(Boolean)
|
|
623
704
|
|
|
705
|
+
warnDuplicateSlugs(items, config.name)
|
|
706
|
+
|
|
624
707
|
// Add routes to items if collection has a route configured
|
|
625
708
|
if (config.route) {
|
|
626
709
|
const baseRoute = config.route.replace(/\/$/, '') // Remove trailing slash
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// A site's collection declarations — the ONE resolution, for every lane.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THIS LIVED IN `uwx/` AND THE SITE BUILD COULD NOT SEE IT, so the site build
|
|
4
|
+
// used `site.yml::collections` directly and the two disagreed. Measured before the
|
|
5
|
+
// move: a collection declared only in `collections/collections.yml` resolved here
|
|
6
|
+
// and was INVISIBLE to the build — no `dist/data/<name>.json`, so `data: <name>`
|
|
7
|
+
// delivered nothing while sync pushed it fine. Declared in both files, the build
|
|
8
|
+
// took `site.yml`'s values and sync took `collections.yml`'s, so an author writing
|
|
9
|
+
// `sort: date desc` here got `date asc` baked into the static file.
|
|
10
|
+
//
|
|
11
|
+
// The broken case was the one the public docs recommend. See
|
|
12
|
+
// `kb/framework/plans/one-collections-config.md`.
|
|
13
|
+
//
|
|
14
|
+
// Resolve a site's collection configuration from the (optional, local-first)
|
|
15
|
+
// `collections/collections.yml`, layered over the legacy `site.yml::collections`
|
|
16
|
+
// and the zero-config subfolder-name convention.
|
|
17
|
+
//
|
|
18
|
+
// `collections.yml` is the co-located home for FILE-BASED collection declarations
|
|
19
|
+
// (it sits with the data it describes). It is useful with NO backend at all — it
|
|
20
|
+
// maps each subfolder to a data schema, declares query/display config, and can lay
|
|
21
|
+
// out a VIRTUAL folder organization decoupled from the on-disk layout. (Sync holds no
|
|
22
|
+
// folder uuid here — the backend owns the site's `@uniweb/folder`, keyed by the
|
|
23
|
+
// site-content uuid.)
|
|
24
|
+
//
|
|
25
|
+
// Precedence (per-collection, per-key): collections.yml > site.yml::collections.
|
|
26
|
+
// `site.yml::collections` stays valid for remote `url:` sources and back-compat.
|
|
27
|
+
// When neither declares a schema, the subfolder-name convention fills it
|
|
28
|
+
// (`articles` → `@/article`). Absent the file entirely, behavior is unchanged.
|
|
29
|
+
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
|
|
33
|
+
import { detectFoundationType } from './foundation-ref.js'
|
|
34
|
+
import { readFile } from 'node:fs/promises'
|
|
35
|
+
import yaml from 'js-yaml'
|
|
36
|
+
|
|
37
|
+
// Read its own YAML rather than importing the site build's helper. That import
|
|
38
|
+
// pointed the wrong way — a config resolver reaching into the collector that
|
|
39
|
+
// consumes it — and became a cycle the moment the site build started calling
|
|
40
|
+
// this. Four lines beats a dependency between two modules that should not know
|
|
41
|
+
// about each other.
|
|
42
|
+
async function readYamlFile(filePath) {
|
|
43
|
+
if (!existsSync(filePath)) return {}
|
|
44
|
+
try {
|
|
45
|
+
return yaml.load(await readFile(filePath, 'utf8')) || {}
|
|
46
|
+
} catch {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const COLLECTIONS_YML_RELPATH = 'collections/collections.yml'
|
|
52
|
+
|
|
53
|
+
// The default data-schema ref for a collection that declares none: the collection's
|
|
54
|
+
// OWN NAME, unchanged. `@/` is the self scope — the local foundation's `schemas/` —
|
|
55
|
+
// so this stays backend-independent.
|
|
56
|
+
//
|
|
57
|
+
// ⛔ IT USED TO APPLY AN ENGLISH SINGULAR RULE to every language. Measured:
|
|
58
|
+
// `news` → `@/new`, `series` → `@/sery`, `analyses` → `@/analys`. Right for regular
|
|
59
|
+
// English plurals, right-by-accident elsewhere (`noticias` → `noticia`), and inert
|
|
60
|
+
// for languages with no plural marker.
|
|
61
|
+
//
|
|
62
|
+
// ⚠️ The failure was SILENT, which is why the rule was removed rather than improved.
|
|
63
|
+
// A default that does not resolve is not an error — the collection soft-skips to
|
|
64
|
+
// delivery-only — so a `news` collection simply never synced as entities, and
|
|
65
|
+
// nothing said the cause was a guess about English morphology.
|
|
66
|
+
//
|
|
67
|
+
// A more complete rule would move that boundary, not remove it: every irregular
|
|
68
|
+
// list belongs to one language, and a site names its collections in its own.
|
|
69
|
+
// Identity has no boundary to get wrong, and an author wanting a different schema
|
|
70
|
+
// name writes `schema:`, which is one line and says what it means.
|
|
71
|
+
//
|
|
72
|
+
// Exported so the inverse (projection) can drop a `schema:` that merely restates
|
|
73
|
+
// this default, keeping a projected collections.yml as terse as the author left it.
|
|
74
|
+
export function defaultSchema(name) {
|
|
75
|
+
return `@/${name}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Normalize one site.yml::collections entry (string shorthand or object) to the
|
|
79
|
+
// internal decl shape. Paths here are already site-root-relative (legacy contract).
|
|
80
|
+
function normalizeSiteDecl(name, decl) {
|
|
81
|
+
if (typeof decl === 'string') return { name, path: decl }
|
|
82
|
+
const d = decl && typeof decl === 'object' ? decl : {}
|
|
83
|
+
return { name, ...d }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Normalize one collections.yml::collections entry. Its `path:` is relative to the
|
|
87
|
+
// collections/ directory (default = the collection name); we lift it to a
|
|
88
|
+
// site-root-relative path so downstream readers resolve it uniformly.
|
|
89
|
+
function normalizeYmlDecl(name, decl) {
|
|
90
|
+
const d = decl && typeof decl === 'object' ? decl : {}
|
|
91
|
+
const rel = typeof d.path === 'string' ? d.path : name
|
|
92
|
+
return { name, ...d, path: `collections/${rel}` }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the merged collection configuration for a site.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} siteRoot - directory containing site.yml + collections/
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {object} [opts.siteYml] - an already-read site.yml (avoids a re-read)
|
|
101
|
+
* @returns {Promise<{
|
|
102
|
+
* folderSync: boolean, // collections.yml `sync` (whole-folder opt-out)
|
|
103
|
+
* hasCollectionsYml: boolean,
|
|
104
|
+
* declarations: object, // { name: decl } — merged, schema-defaulted
|
|
105
|
+
* folders: Array|null, // collections.yml `folders` (virtual org) or null
|
|
106
|
+
* }>}
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveCollectionsConfig(siteRoot, opts = {}) {
|
|
109
|
+
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
110
|
+
const ymlPath = join(siteRoot, COLLECTIONS_YML_RELPATH)
|
|
111
|
+
const hasCollectionsYml = existsSync(ymlPath)
|
|
112
|
+
const colYml = hasCollectionsYml ? await readYamlFile(ymlPath) : {}
|
|
113
|
+
|
|
114
|
+
const declarations = {}
|
|
115
|
+
|
|
116
|
+
// Legacy site.yml::collections first (lower precedence).
|
|
117
|
+
const siteCols = siteYml?.collections
|
|
118
|
+
if (siteCols && typeof siteCols === 'object' && !Array.isArray(siteCols)) {
|
|
119
|
+
for (const [name, decl] of Object.entries(siteCols)) {
|
|
120
|
+
declarations[name] = normalizeSiteDecl(name, decl)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// collections.yml::collections overlay (higher precedence; per-key merge).
|
|
125
|
+
const ymlCols = colYml?.collections
|
|
126
|
+
if (ymlCols && typeof ymlCols === 'object' && !Array.isArray(ymlCols)) {
|
|
127
|
+
for (const [name, decl] of Object.entries(ymlCols)) {
|
|
128
|
+
const incoming = normalizeYmlDecl(name, decl)
|
|
129
|
+
declarations[name] = { ...(declarations[name] || {}), ...incoming, name }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Schema default (subfolder-name convention) + `model:`→`schema:` synonym.
|
|
134
|
+
// `schemaExplicit` records whether the author asked for this schema: an explicit
|
|
135
|
+
// schema that fails to resolve is a hard error; a convention-defaulted one that
|
|
136
|
+
// fails to resolve soft-skips (so a delivery-only collection never breaks sync).
|
|
137
|
+
for (const decl of Object.values(declarations)) {
|
|
138
|
+
if (decl.schema) {
|
|
139
|
+
decl.schemaExplicit = true
|
|
140
|
+
} else if (decl.model) {
|
|
141
|
+
decl.schema = decl.model // migration synonym
|
|
142
|
+
decl.schemaExplicit = true
|
|
143
|
+
} else if (decl.path || !decl.url) {
|
|
144
|
+
decl.schema = defaultSchema(decl.name) // subfolder-name convention
|
|
145
|
+
decl.schemaExplicit = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
|
|
150
|
+
|
|
151
|
+
const folderSync = colYml?.sync !== false
|
|
152
|
+
return {
|
|
153
|
+
folderSync,
|
|
154
|
+
hasCollectionsYml,
|
|
155
|
+
declarations,
|
|
156
|
+
folders: Array.isArray(colYml?.folders) ? colYml.folders : null,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The declarations as `config.collections` should carry them.
|
|
162
|
+
*
|
|
163
|
+
* `schemaExplicit` records whether the AUTHOR asked for a schema or the
|
|
164
|
+
* subfolder-name convention supplied one. That decides how a failed resolution
|
|
165
|
+
* behaves during sync — hard error vs soft skip — and is nobody's business
|
|
166
|
+
* downstream. It is stripped here rather than at each consumer, so the payload
|
|
167
|
+
* has one shape and no consumer has to know the field existed.
|
|
168
|
+
*
|
|
169
|
+
* Returns undefined for a site with no collections, so `config.collections`
|
|
170
|
+
* stays absent rather than becoming an empty object — an empty object reads as
|
|
171
|
+
* "declared, and empty" to anything checking for presence.
|
|
172
|
+
*/
|
|
173
|
+
export function toConfigCollections(declarations) {
|
|
174
|
+
const names = Object.keys(declarations || {})
|
|
175
|
+
if (names.length === 0) return undefined
|
|
176
|
+
const out = {}
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
const { schemaExplicit, ...rest } = declarations[name]
|
|
179
|
+
out[name] = rest
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fill in `deferred:` from each collection's own data schema.
|
|
187
|
+
*
|
|
188
|
+
* ⭐ A schema's **brief** section already states what a record's summary is — the
|
|
189
|
+
* card, the row, the thing a list shows. Everything else is wanted only when one
|
|
190
|
+
* record is the focus. That is exactly what `deferred:` says, so an author with a
|
|
191
|
+
* schema should not have to say it twice, in a second vocabulary, with nothing
|
|
192
|
+
* checking the two against each other.
|
|
193
|
+
*
|
|
194
|
+
* ⇒ `deferred` = the schema's flat-record fields MINUS its brief fields.
|
|
195
|
+
*
|
|
196
|
+
* Derived from the SCHEMA, never from a record. That is what keeps the
|
|
197
|
+
* build-derived keys safe without a reserved list: `slug`, `route`, `path`,
|
|
198
|
+
* `excerpt`, `image` and `lastModified` are not schema fields, so they are never
|
|
199
|
+
* in the difference and never stripped. `content` is not exempt — it is
|
|
200
|
+
* schema-governed, and usually the heavy field the split exists for.
|
|
201
|
+
*
|
|
202
|
+
* ⛔ Silent on every path that cannot answer, because none of them is an error:
|
|
203
|
+
*
|
|
204
|
+
* - an author-declared `deferred:` wins outright — this never overrides one;
|
|
205
|
+
* - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
|
|
206
|
+
* to read, and a site must still build;
|
|
207
|
+
* - the schema is not in the foundation's built map → the same soft-skip the
|
|
208
|
+
* sync lane already applies. `dist/meta/schema.json` carries the schemas
|
|
209
|
+
* COMPONENTS reference, so a collection whose schema no component binds is
|
|
210
|
+
* simply not there;
|
|
211
|
+
* - the schema states no brief (`briefFields` → null, e.g. a root list) → there
|
|
212
|
+
* is no lean shape to honour, so records stay whole.
|
|
213
|
+
*
|
|
214
|
+
* The last two are why this reads the built artifact rather than resolving
|
|
215
|
+
* schemas itself: it is the same input the sync lane uses, so both lanes agree
|
|
216
|
+
* about which schemas exist.
|
|
217
|
+
*/
|
|
218
|
+
async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
219
|
+
const pending = Object.values(declarations).filter(
|
|
220
|
+
(d) => d.schema && !Array.isArray(d.deferred)
|
|
221
|
+
)
|
|
222
|
+
if (pending.length === 0) return
|
|
223
|
+
|
|
224
|
+
const dataSchemas = loadFoundationDataSchemas(siteRoot, siteYml)
|
|
225
|
+
if (!dataSchemas) return
|
|
226
|
+
|
|
227
|
+
for (const decl of pending) {
|
|
228
|
+
const schema = dataSchemas[decl.schema]
|
|
229
|
+
if (!schema) continue
|
|
230
|
+
const brief = briefFields(schema)
|
|
231
|
+
if (!brief) continue
|
|
232
|
+
const all = Object.keys(flatRecordFields(schema) || {})
|
|
233
|
+
const heavy = all.filter((f) => !brief.has(f))
|
|
234
|
+
if (heavy.length) decl.deferred = heavy
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The foundation's built data-schema map, or null when there is nothing to read. */
|
|
239
|
+
function loadFoundationDataSchemas(siteRoot, siteYml) {
|
|
240
|
+
if (!siteYml?.foundation) return null
|
|
241
|
+
let info
|
|
242
|
+
try {
|
|
243
|
+
info = detectFoundationType(siteYml.foundation, siteRoot)
|
|
244
|
+
} catch {
|
|
245
|
+
return null // a declaration this resolver refuses is not this function's error
|
|
246
|
+
}
|
|
247
|
+
if (info?.type !== 'local' || !info.path) return null
|
|
248
|
+
const schemaPath = join(info.path, 'dist', 'meta', 'schema.json')
|
|
249
|
+
if (!existsSync(schemaPath)) return null
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(readFileSync(schemaPath, 'utf8'))?.dataSchemas || null
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Path to the collections.yml file (whether or not it exists yet). */
|
|
258
|
+
export function collectionsYmlPath(siteRoot) {
|
|
259
|
+
return join(siteRoot, COLLECTIONS_YML_RELPATH)
|
|
260
|
+
}
|
package/src/site/config.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { importMapPlugin } from '../import-map-plugin.js'
|
|
32
32
|
import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
|
|
33
33
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
34
|
+
import { detectFoundationType } from './foundation-ref.js'
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Normalize a base path for Vite compatibility
|
|
@@ -60,180 +61,11 @@ function normalizeBasePath(raw) {
|
|
|
60
61
|
return path
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* of two types:
|
|
69
|
-
*
|
|
70
|
-
* - `'local'` — workspace-local source (sibling directory, file: dep, or
|
|
71
|
-
* `../../foundations/<name>/`). The build inlines or runtime-links it
|
|
72
|
-
* depending on the operating mode.
|
|
73
|
-
* - `'url'` — loaded by URL at runtime. Two URL shapes:
|
|
74
|
-
* - `@org/name@ver` → catalog ref (resolves against the registry CDN)
|
|
75
|
-
* - `https://...` → arbitrary URL
|
|
76
|
-
*
|
|
77
|
-
* Versionless registry refs (`@org/name`) are rejected with a specific error —
|
|
78
|
-
* they were a silent fall-through before. Versionless names that don't match
|
|
79
|
-
* any local resolution path are also rejected, with guidance toward the right
|
|
80
|
-
* shape.
|
|
81
|
-
*
|
|
82
|
-
* @param {string|Object} foundation - Foundation config from site.yml
|
|
83
|
-
* @param {string} siteRoot - Path to site directory
|
|
84
|
-
* @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
|
|
85
|
-
* @throws {Error} when the declaration shape is invalid (versionless registry
|
|
86
|
-
* ref, unknown name with no local match, etc.)
|
|
87
|
-
*/
|
|
88
|
-
export function detectFoundationType(foundation, siteRoot) {
|
|
89
|
-
// Object form with explicit URL
|
|
90
|
-
if (foundation && typeof foundation === 'object') {
|
|
91
|
-
if (foundation.url) {
|
|
92
|
-
return {
|
|
93
|
-
type: 'url',
|
|
94
|
-
url: foundation.url,
|
|
95
|
-
cssUrl: foundation.css || foundation.cssUrl || null
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Object form with name
|
|
99
|
-
foundation = foundation.name || 'foundation'
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// String form
|
|
103
|
-
const name = foundation || 'foundation'
|
|
104
|
-
|
|
105
|
-
// Check if it's a URL
|
|
106
|
-
if (name.startsWith('http://') || name.startsWith('https://')) {
|
|
107
|
-
// Try to infer CSS URL from JS URL
|
|
108
|
-
const cssUrl = name.replace(/\.js$/, '.css').replace(/foundation\.js/, 'assets/style.css')
|
|
109
|
-
return {
|
|
110
|
-
type: 'url',
|
|
111
|
-
url: name,
|
|
112
|
-
cssUrl
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Catalog registry ref: `@org/name@version`.
|
|
117
|
-
//
|
|
118
|
-
// A build does NOT turn this into a URL. Where a foundation is served is the
|
|
119
|
-
// host's to say — a serve location is READ (from backend discovery, or from an
|
|
120
|
-
// upload plan's `serve_base`), never reconstructed. `@uniweb/cli`'s
|
|
121
|
-
// DISCOVERY_DEFAULTS carries no serve-root default for exactly this reason.
|
|
122
|
-
// A build is offline and backend-optional by design, so it has nothing to ask.
|
|
123
|
-
//
|
|
124
|
-
// A ref names a foundation in the Uniweb platform's catalog, and `uniweb
|
|
125
|
-
// publish` — the verb reserved for that target — has the platform resolve it,
|
|
126
|
-
// running no vite build. That is one hosting target among many. The others
|
|
127
|
-
// reach a host through `uniweb deploy --host=<adapter>` or `uniweb export`,
|
|
128
|
-
// and both need a concrete URL, which the site declares: the runtime accepts
|
|
129
|
-
// any URL, from any host.
|
|
130
|
-
//
|
|
131
|
-
// Until 2026-08-04 this returned `{base}/foundations/{ns}/{name}/{ver}/foundation.js`
|
|
132
|
-
// against a hardcoded host, overridable only through an env var that did not
|
|
133
|
-
// match the documented backend selection — so `--backend`, `uniweb login
|
|
134
|
-
// --backend` and the documented env var all left it pinned — and the artifact
|
|
135
|
-
// names were the pre-`entry.js` ones the build stopped emitting.
|
|
136
|
-
const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
|
|
137
|
-
if (orgScopedMatch) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
[
|
|
140
|
-
`Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
|
|
141
|
-
`Where a foundation is served is the host's to declare, so the build does not guess it.`,
|
|
142
|
-
``,
|
|
143
|
-
` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
|
|
144
|
-
` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
|
|
145
|
-
` the runtime accepts any URL, from any host:`,
|
|
146
|
-
``,
|
|
147
|
-
` foundation: https://<host>/<path>/entry.js`,
|
|
148
|
-
``,
|
|
149
|
-
` or the object form when the stylesheet sits elsewhere:`,
|
|
150
|
-
``,
|
|
151
|
-
` foundation: { url: 'https://…/entry.js', cssUrl: 'https://…/assets/style.css' }`,
|
|
152
|
-
``,
|
|
153
|
-
` • Iterating locally?`,
|
|
154
|
-
` Reference the workspace foundation by package name.`,
|
|
155
|
-
``,
|
|
156
|
-
` • Targeting the Uniweb platform?`,
|
|
157
|
-
` \`uniweb publish\` has the platform resolve the ref — no build-time URL needed.`
|
|
158
|
-
].join('\n')
|
|
159
|
-
)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Versionless scoped names (`@org/name`) are valid as *handles* — they
|
|
163
|
-
// resolve through the local checks below (file: dep,
|
|
164
|
-
// workspace sibling) when the developer is iterating locally on a
|
|
165
|
-
// foundation that will eventually be published as `@org/name@ver`.
|
|
166
|
-
// Tianyu's uniweb.io site uses this shape:
|
|
167
|
-
// site.yml: foundation: '@uniweb/io'
|
|
168
|
-
// package.json: "@uniweb/io": "file:../../foundations/io"
|
|
169
|
-
// The file: dep check below picks it up. If no local resolution exists
|
|
170
|
-
// either, the function throws below with a "missing version" hint.
|
|
171
|
-
|
|
172
|
-
// Check if it's a local workspace sibling (directory name matches package name)
|
|
173
|
-
const localPath = resolve(siteRoot, '..', name)
|
|
174
|
-
if (existsSync(localPath)) {
|
|
175
|
-
return {
|
|
176
|
-
type: 'local',
|
|
177
|
-
name,
|
|
178
|
-
path: localPath
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Check if it's a file: dependency (co-located projects where dir name ≠ package name)
|
|
183
|
-
// e.g. "marketing-foundation": "file:../foundation" in marketing/site/package.json
|
|
184
|
-
try {
|
|
185
|
-
const pkg = JSON.parse(readFileSync(resolve(siteRoot, 'package.json'), 'utf8'))
|
|
186
|
-
const dep = pkg.dependencies?.[name]
|
|
187
|
-
if (dep && dep.startsWith('file:')) {
|
|
188
|
-
const filePath = resolve(siteRoot, dep.slice(5))
|
|
189
|
-
if (existsSync(filePath)) {
|
|
190
|
-
return {
|
|
191
|
-
type: 'local',
|
|
192
|
-
name,
|
|
193
|
-
path: filePath
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} catch {}
|
|
198
|
-
|
|
199
|
-
// Check in foundations/ directory (for multi-site projects)
|
|
200
|
-
const foundationsPath = resolve(siteRoot, '..', '..', 'foundations', name)
|
|
201
|
-
if (existsSync(foundationsPath)) {
|
|
202
|
-
return {
|
|
203
|
-
type: 'local',
|
|
204
|
-
name,
|
|
205
|
-
path: foundationsPath
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Versionless scoped name that didn't resolve locally — likely a typo
|
|
210
|
-
// or a missing file: dep. Give a specific hint distinguishing the two
|
|
211
|
-
// common causes (forgot @version vs. forgot to wire the file: dep).
|
|
212
|
-
if (/^@[a-z0-9_-]+\//.test(name)) {
|
|
213
|
-
throw new Error(
|
|
214
|
-
`site.yml foundation: '${name}' did not resolve to a local source and no version was specified.\n` +
|
|
215
|
-
`If this is a workspace-local foundation, add it to the site's package.json:\n` +
|
|
216
|
-
` "dependencies": { "${name}": "file:../path/to/foundation" }\n` +
|
|
217
|
-
`If this is a published catalog ref, include the version: '${name}@<version>' (e.g. '${name}@0.1.2').`
|
|
218
|
-
)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Foundations are not npm packages. If we get here, the declaration
|
|
222
|
-
// didn't match a workspace sibling, a `file:` dep, a foundations/ entry,
|
|
223
|
-
// a registry ref, or a URL — none of the supported shapes. Fail with
|
|
224
|
-
// guidance rather than fall through to a node_modules lookup that will
|
|
225
|
-
// produce a confusing error later in the build.
|
|
226
|
-
throw new Error(
|
|
227
|
-
`site.yml foundation: '${name}' did not resolve.\n` +
|
|
228
|
-
`Foundations must be one of:\n` +
|
|
229
|
-
` - a workspace-local sibling (a directory next to the site, named '${name}')\n` +
|
|
230
|
-
` - a 'file:' dep in the site's package.json\n` +
|
|
231
|
-
` - a directory in '../../foundations/${name}'\n` +
|
|
232
|
-
` - a versioned registry ref: '@org/${name}@<version>'\n` +
|
|
233
|
-
` - a full URL: 'https://...'\n` +
|
|
234
|
-
`Foundations are runtime federated modules, not npm packages — there is no fall-through to node_modules.`
|
|
235
|
-
)
|
|
236
|
-
}
|
|
64
|
+
// `detectFoundationType` moved to its own leaf so lanes that must not pull Vite
|
|
65
|
+
// can resolve a foundation declaration too — see that file's header. Imported as
|
|
66
|
+
// well as re-exported: this module calls it, and a bare re-export creates no local
|
|
67
|
+
// binding.
|
|
68
|
+
export { detectFoundationType }
|
|
237
69
|
|
|
238
70
|
/**
|
|
239
71
|
* Read and parse site.yml configuration
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
27
|
+
import { resolveCollectionsConfig, toConfigCollections } from './collections-config.js'
|
|
27
28
|
import { join, parse, resolve, sep } from 'node:path'
|
|
28
29
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
29
30
|
import yaml from 'js-yaml'
|
|
@@ -2172,6 +2173,17 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2172
2173
|
// Read site config and raw theme config
|
|
2173
2174
|
const siteConfig = await readYamlFile(join(sitePath, configFile))
|
|
2174
2175
|
|
|
2176
|
+
// Collections are declared in TWO files — `site.yml::collections` and
|
|
2177
|
+
// `collections/collections.yml`, the latter winning per key — and resolving
|
|
2178
|
+
// them is one question with one answer. This used to read `site.yml` alone
|
|
2179
|
+
// while the sync lane merged both, so a collection declared only in
|
|
2180
|
+
// `collections.yml` was invisible here: never compiled, `data: <name>`
|
|
2181
|
+
// delivering nothing, while sync pushed it fine.
|
|
2182
|
+
const collections = toConfigCollections(
|
|
2183
|
+
(await resolveCollectionsConfig(sitePath, { siteYml: siteConfig })).declarations
|
|
2184
|
+
)
|
|
2185
|
+
if (collections) siteConfig.collections = collections
|
|
2186
|
+
|
|
2175
2187
|
// Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
|
|
2176
2188
|
// config so every consumer reads one value. Prerender sets website.basePath
|
|
2177
2189
|
// from `config.base` alone — it has no access to Vite's BASE_URL — so a base
|