@uniweb/build 0.47.2 → 0.49.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/prerender.js +128 -49
- package/src/site/build-site-data.js +2 -1
- package/src/site/content-collector.js +121 -190
- package/src/site/data-fetcher.js +306 -193
- package/src/site/fetch-shapes.js +9 -8
- package/src/site/layout-folder.js +221 -0
- package/src/site/plugin.js +29 -21
- package/src/site/queries-config.js +80 -5
- package/src/site/query-processor.js +32 -29
- package/src/utils/content-files.js +38 -0
- package/src/uwx/records-project.js +7 -2
- package/src/uwx/site-diff.js +7 -2
- package/src/uwx/site-project.js +73 -41
- package/src/uwx/site.js +54 -40
- package/src/validate-data.js +7 -4
package/src/site/fetch-shapes.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// What shape a `fetch:` declaration is, and what a PROJECTION may write back.
|
|
2
2
|
//
|
|
3
|
-
// ⛔ WHY THIS EXISTS. A `fetch:` declaration
|
|
4
|
-
// accepts
|
|
3
|
+
// ⛔ WHY THIS EXISTS. A `fetch:` declaration is a BINDING — it names a query — and
|
|
4
|
+
// accepts `query · as · where · sort · limit · current · …`, NOT `path`/`url`
|
|
5
|
+
// (`data-fetcher.js` RECOGNIZED_FETCH_KEYS). A string is a query name.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// (The source shape — `{ path }`, `{ url }` — and `refine: true` were retired on
|
|
8
|
+
// 2026-09-13; the build refuses both. `source` below classifies what a store may
|
|
9
|
+
// still hold from before, so a pull passes it through rather than guessing.)
|
|
9
10
|
|
|
10
11
|
//
|
|
11
12
|
// The build RESOLVES a `query:` shorthand into a concrete location, so the
|
|
@@ -36,10 +37,9 @@
|
|
|
36
37
|
// a `query` are recoverable from the query itself; anything else survives and the
|
|
37
38
|
// validator's warning stays the honest signal.
|
|
38
39
|
|
|
39
|
-
/** Which
|
|
40
|
+
/** Which shape a declaration is — the same order `data-fetcher` uses. */
|
|
40
41
|
export function fetchShapeOf(fetch) {
|
|
41
42
|
if (!fetch || typeof fetch !== 'object') return null
|
|
42
|
-
if (fetch.refine === true) return 'refine'
|
|
43
43
|
if (fetch.query) return 'query'
|
|
44
44
|
return 'source'
|
|
45
45
|
}
|
|
@@ -47,7 +47,6 @@ export function fetchShapeOf(fetch) {
|
|
|
47
47
|
/** Keys a shape derives rather than the author writing them. */
|
|
48
48
|
const DERIVED_BY_SHAPE = {
|
|
49
49
|
query: ['path', 'url'],
|
|
50
|
-
refine: [],
|
|
51
50
|
source: []
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -95,8 +94,10 @@ export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
|
|
|
95
94
|
*/
|
|
96
95
|
export function queryNamesOf(fetch) {
|
|
97
96
|
const nameOf = (one) => {
|
|
97
|
+
if (typeof one === 'string' && one !== '') return one
|
|
98
98
|
if (!one || typeof one !== 'object' || typeof one.query !== 'string' || one.query === '') return null
|
|
99
99
|
for (const [key, value] of Object.entries(one)) {
|
|
100
|
+
if (value === undefined) continue
|
|
100
101
|
if (key === 'query' || key === 'path' || key === 'url') continue
|
|
101
102
|
if (key === 'as' && value === one.query) continue
|
|
102
103
|
if (key === 'prerender' && value === true) continue
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A site's `layout/` folder, read from the site alone.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ DEPTH DECIDES WHAT A FOLDER IS — never the foundation.
|
|
5
|
+
*
|
|
6
|
+
* layout/<area>.md a one-section area of the default layout
|
|
7
|
+
* layout/<name>/ a named layout, always
|
|
8
|
+
* layout/<name>/<area>.md a one-section area of that layout
|
|
9
|
+
* layout/<name>/<area>/ an area with several sections, in filename order
|
|
10
|
+
* layout/default/ the default layout written as a folder — how it
|
|
11
|
+
* gets an area with several sections
|
|
12
|
+
*
|
|
13
|
+
* ⛔ Why not the foundation. The build used to call a folder a named layout only
|
|
14
|
+
* when the foundation declared a layout by that name, and an area of the default
|
|
15
|
+
* layout otherwise — while the sync producer called every folder a named layout.
|
|
16
|
+
* So `layout/header/1-topbar.md` was the default layout's header on a static build
|
|
17
|
+
* and a layout named `header` once synced. And a site's meaning cannot depend
|
|
18
|
+
* on a foundation the build may not have: a site can link a remote foundation that
|
|
19
|
+
* is nowhere on disk. The runtime, which always has the foundation, is where a
|
|
20
|
+
* layout name is matched to one.
|
|
21
|
+
*
|
|
22
|
+
* ⭐ ONE READER FOR EVERY PRODUCER. The build (`collectLayouts`) and the sync
|
|
23
|
+
* producer (`collectLayoutNested`) both read the folder through this module, so they
|
|
24
|
+
* cannot disagree about what it holds again.
|
|
25
|
+
*
|
|
26
|
+
* An area is a CONTAINER OF SECTIONS, not a page: it has no `page.yml`, its sections
|
|
27
|
+
* are ordered by filename prefix and bind their own data, and none of them nests
|
|
28
|
+
* another (nesting is configured in a `page.yml`, which an area does not have).
|
|
29
|
+
*
|
|
30
|
+
* @module @uniweb/build/site/layout-folder
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readdir } from 'node:fs/promises'
|
|
34
|
+
import { existsSync } from 'node:fs'
|
|
35
|
+
import { dirname, join, parse, relative } from 'node:path'
|
|
36
|
+
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
37
|
+
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
38
|
+
import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
39
|
+
|
|
40
|
+
/** The key the default layout's areas are held under, on every lane. */
|
|
41
|
+
export const DEFAULT_LAYOUT = 'default'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Area names a layout folder is most likely to have been meant as. A folder under
|
|
45
|
+
* `layout/` named like one of them was, before this convention, an area of the
|
|
46
|
+
* default layout — and the public docs taught `layout/header/header.md` that way.
|
|
47
|
+
*/
|
|
48
|
+
const CONVENTIONAL_AREAS = new Set(['header', 'footer', 'left', 'right'])
|
|
49
|
+
|
|
50
|
+
/** Files that configure a page, which nothing in a layout folder is. */
|
|
51
|
+
const PAGE_CONFIG_FILES = new Set(['page.yml', 'page.yaml', 'folder.yml', 'folder.yaml'])
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Order section files the way a page orders its sections — the collector's one
|
|
55
|
+
* numeric-prefix rule (`1`, `1.5`, `2`, …), never a second copy of it.
|
|
56
|
+
*/
|
|
57
|
+
function compareSectionFiles(a, b) {
|
|
58
|
+
return compareByNumericPrefix(parse(a).name, parse(b).name)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const warned = new Set()
|
|
62
|
+
|
|
63
|
+
/** Test seam — reset the once-per-folder warning memo. */
|
|
64
|
+
export function _resetLayoutFolderWarnings() {
|
|
65
|
+
warned.clear()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every area a site's `layout/` folder declares.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} layoutDir - the site's layout directory (`layout/`, or `paths.layout`)
|
|
72
|
+
* @param {Object} [options]
|
|
73
|
+
* @param {string} [options.siteRoot] - for the paths named in messages
|
|
74
|
+
* @param {(message: string) => void} [options.onWarning]
|
|
75
|
+
* @returns {Promise<Array<{ layout: string, area: string, form: 'file'|'folder', dir: string, files: string[], source: string }>>}
|
|
76
|
+
* one entry per area, in a stable order: the default layout's areas first, then
|
|
77
|
+
* each named layout's, by folder name. `files` are the area's section files, in
|
|
78
|
+
* section order; `source` is the area's path, relative, for messages.
|
|
79
|
+
* @throws when the folder declares something no lane can read the same way
|
|
80
|
+
*/
|
|
81
|
+
export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning = (m) => console.warn(m) } = {}) {
|
|
82
|
+
if (!layoutDir || !existsSync(layoutDir)) return []
|
|
83
|
+
|
|
84
|
+
const base = siteRoot || dirname(layoutDir)
|
|
85
|
+
const shown = (path) => relative(base, path) || '.'
|
|
86
|
+
const fail = (path, message) => {
|
|
87
|
+
throw new Error(`[uniweb] ${shown(path)}: ${message}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const areas = []
|
|
91
|
+
const byKey = new Map() // `<layout name key>/<area>` → source
|
|
92
|
+
|
|
93
|
+
const add = (entry) => {
|
|
94
|
+
const key = `${layoutNameKey(entry.layout)}/${entry.area}`
|
|
95
|
+
const other = byKey.get(key)
|
|
96
|
+
if (other) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`[uniweb] ${other} and ${entry.source} both declare the \`${entry.area}\` area of the ` +
|
|
99
|
+
`${entry.layout === DEFAULT_LAYOUT ? 'default layout' : `\`${entry.layout}\` layout`}. ` +
|
|
100
|
+
'An area has one source — keep one of them.'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
byKey.set(key, entry.source)
|
|
104
|
+
areas.push(entry)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const refusePageConfig = (dir, name) => {
|
|
108
|
+
if (PAGE_CONFIG_FILES.has(name.toLowerCase())) {
|
|
109
|
+
fail(
|
|
110
|
+
join(dir, name),
|
|
111
|
+
'a layout folder holds no page.yml or folder.yml. A layout area is a container of sections: ' +
|
|
112
|
+
'its sections are ordered by filename prefix (1-topbar.md, 2-navbar.md) and each binds its own data.'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const refuseChild = (dir, name) => {
|
|
118
|
+
if (name.startsWith('@')) {
|
|
119
|
+
fail(
|
|
120
|
+
join(dir, name),
|
|
121
|
+
'a layout area has no child sections. Nesting is configured in a page.yml, which an area does not ' +
|
|
122
|
+
'have — give the section its own file.'
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The areas inside one layout's folder (the root folder is the default layout's). */
|
|
128
|
+
const readAreas = async (layoutName, dir, { isRoot }) => {
|
|
129
|
+
const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
|
130
|
+
|
|
131
|
+
const files = []
|
|
132
|
+
const folders = []
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (entry.isFile()) {
|
|
135
|
+
refusePageConfig(dir, entry.name)
|
|
136
|
+
if (!isMarkdownFile(entry.name)) continue
|
|
137
|
+
refuseChild(dir, entry.name)
|
|
138
|
+
files.push(entry.name)
|
|
139
|
+
} else if (entry.isDirectory() && !isIgnoredFolder(entry.name)) {
|
|
140
|
+
folders.push(entry.name)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const file of files.sort(compareSectionFiles)) {
|
|
145
|
+
const area = parseNumericPrefix(parse(file).name).name
|
|
146
|
+
add({ layout: layoutName, area, form: 'file', dir, files: [file], source: shown(join(dir, file)) })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// In the root folder, a folder is a named layout — handled by the caller.
|
|
150
|
+
if (isRoot) return folders
|
|
151
|
+
|
|
152
|
+
for (const folder of folders) {
|
|
153
|
+
const areaDir = join(dir, folder)
|
|
154
|
+
const area = parseNumericPrefix(folder).name
|
|
155
|
+
const sectionFiles = []
|
|
156
|
+
for (const entry of await readdir(areaDir, { withFileTypes: true })) {
|
|
157
|
+
if (entry.isFile()) {
|
|
158
|
+
refusePageConfig(areaDir, entry.name)
|
|
159
|
+
if (!isMarkdownFile(entry.name)) continue
|
|
160
|
+
refuseChild(areaDir, entry.name)
|
|
161
|
+
sectionFiles.push(entry.name)
|
|
162
|
+
} else if (entry.isDirectory() && !isIgnoredFolder(entry.name)) {
|
|
163
|
+
const nested = (await readdir(join(areaDir, entry.name))).filter(isMarkdownFile)
|
|
164
|
+
if (nested.length > 0) {
|
|
165
|
+
fail(
|
|
166
|
+
join(areaDir, entry.name),
|
|
167
|
+
'nothing nests inside a layout area. An area is `layout/<layout>/<area>/` holding its section files.'
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (sectionFiles.length === 0) continue
|
|
173
|
+
add({
|
|
174
|
+
layout: layoutName,
|
|
175
|
+
area,
|
|
176
|
+
form: 'folder',
|
|
177
|
+
dir: areaDir,
|
|
178
|
+
files: sectionFiles.sort(compareSectionFiles),
|
|
179
|
+
source: shown(areaDir),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return []
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const layoutFolders = await readAreas(DEFAULT_LAYOUT, layoutDir, { isRoot: true })
|
|
186
|
+
|
|
187
|
+
// Each folder directly under layout/ is a named layout; `default` (in any case, with
|
|
188
|
+
// or without a trailing `Layout`) is the default layout written as a folder.
|
|
189
|
+
// ⭐ Names compare as the runtime compares them (`@uniweb/core/layout-name`): `docs`
|
|
190
|
+
// is the foundation's `DocsLayout`, so the folder need not repeat the suffix.
|
|
191
|
+
const seenNames = new Map() // layout name key → as written
|
|
192
|
+
for (const folder of layoutFolders) {
|
|
193
|
+
const lower = folder.toLowerCase()
|
|
194
|
+
const key = layoutNameKey(folder)
|
|
195
|
+
const earlier = seenNames.get(key)
|
|
196
|
+
if (earlier) {
|
|
197
|
+
fail(
|
|
198
|
+
join(layoutDir, folder),
|
|
199
|
+
`\`${earlier}\` and \`${folder}\` name one layout — layout names match regardless of case and of a ` +
|
|
200
|
+
'trailing `Layout`. Keep one folder.'
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
seenNames.set(key, folder)
|
|
204
|
+
|
|
205
|
+
const layoutName = key === DEFAULT_LAYOUT ? DEFAULT_LAYOUT : folder
|
|
206
|
+
if (layoutName !== DEFAULT_LAYOUT && CONVENTIONAL_AREAS.has(lower)) {
|
|
207
|
+
const memo = `${layoutDir}/${folder}`
|
|
208
|
+
if (!warned.has(memo)) {
|
|
209
|
+
warned.add(memo)
|
|
210
|
+
onWarning(
|
|
211
|
+
`[uniweb] ${shown(join(layoutDir, folder))}/ is the layout named \`${folder}\`, not the default layout's ` +
|
|
212
|
+
`\`${lower}\` area. For that area write \`${shown(join(layoutDir, `${lower}.md`))}\` — or ` +
|
|
213
|
+
`\`${shown(join(layoutDir, DEFAULT_LAYOUT, lower))}/\` for an area with several sections.`
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await readAreas(layoutName, join(layoutDir, folder), { isRoot: false })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return areas
|
|
221
|
+
}
|
package/src/site/plugin.js
CHANGED
|
@@ -99,25 +99,27 @@ function resolveFaviconHref(configFavicon, publicDir, basePath) {
|
|
|
99
99
|
* collection regenerates `/data/*.json` but the runtime keeps reading the stale
|
|
100
100
|
* embed until the dev server is restarted.
|
|
101
101
|
*
|
|
102
|
-
* So in dev we embed only what the browser genuinely cannot fetch itself
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* -
|
|
109
|
-
*
|
|
110
|
-
*
|
|
102
|
+
* So in dev we embed only what the browser genuinely cannot fetch itself. Asked of
|
|
103
|
+
* a RESOLVED config (`resolveFetchConfigs`), since only the query says where its
|
|
104
|
+
* records live:
|
|
105
|
+
* - a query over the site's records (`/data/<query>.json`) is served by Vite and
|
|
106
|
+
* always browser-reachable, so we never embed it — the runtime fetches it live
|
|
107
|
+
* and picks up edits on reload;
|
|
108
|
+
* - `prerender: false` opts a source out of build-time fetching by definition —
|
|
109
|
+
* and it is an external query's default;
|
|
110
|
+
* - an external query whose binding says `prerender: true` may target an
|
|
111
|
+
* endpoint only the build can reach (no CORS), so we keep pre-fetching and
|
|
112
|
+
* embedding it, mirroring what prod prerender does.
|
|
111
113
|
*
|
|
112
114
|
* Prod is unaffected: prerender still embeds fetched data for flash-free SSG
|
|
113
115
|
* hydration (see build/src/prerender.js executeAllFetches / injectBuildData).
|
|
114
116
|
*
|
|
115
|
-
* @param {Object|null} cfg - A
|
|
117
|
+
* @param {Object|null} cfg - A resolved fetch config
|
|
116
118
|
* @returns {boolean}
|
|
117
119
|
*/
|
|
118
120
|
export function shouldPrefetchInDev(cfg) {
|
|
119
121
|
if (!cfg) return false
|
|
120
|
-
if (!cfg.path && !cfg.url) return false //
|
|
122
|
+
if (!cfg.path && !cfg.url) return false // nothing to fetch
|
|
121
123
|
if (cfg.prerender === false) return false // author opted into runtime fetch
|
|
122
124
|
if (cfg.path && !cfg.url) return false // local file — runtime fetches it live
|
|
123
125
|
return true // remote build-time fetch — keep embedding in dev
|
|
@@ -134,7 +136,9 @@ export function shouldPrefetchInDev(cfg) {
|
|
|
134
136
|
* @param {string} siteDir - Path to site directory
|
|
135
137
|
*/
|
|
136
138
|
async function executeDevFetches(siteContent, siteDir) {
|
|
137
|
-
|
|
139
|
+
// The page's locale, which a `sort` collates texts in.
|
|
140
|
+
const locale = siteContent.config?.activeLocale ?? resolveDefaultLocale(siteContent.config) ?? null
|
|
141
|
+
const fetchOptions = { siteRoot: siteDir, publicDir: 'public', locale }
|
|
138
142
|
const fetchedData = []
|
|
139
143
|
// Resolved the way the runtime resolves it (see prerender.js::executeAllFetches
|
|
140
144
|
// for why): the SPA hydrates by the cache key of ITS resolved config.
|
|
@@ -149,8 +153,8 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
149
153
|
|
|
150
154
|
// Site-level fetch — every declaration.
|
|
151
155
|
for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
|
|
152
|
-
if (!shouldPrefetchInDev(siteFetch)) continue
|
|
153
156
|
const cfg = resolveForDev(siteFetch)
|
|
157
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
154
158
|
const result = await executeFetch(cfg, fetchOptions)
|
|
155
159
|
if (result.data && !result.error) {
|
|
156
160
|
fetchedData.push(entry(cfg, result.data))
|
|
@@ -161,16 +165,17 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
161
165
|
for (const page of siteContent.pages || []) {
|
|
162
166
|
// Page-level fetch — every declaration.
|
|
163
167
|
for (const pageFetch of toFetchList(page.fetch)) {
|
|
164
|
-
if (!shouldPrefetchInDev(pageFetch)) continue
|
|
165
168
|
const cfg = resolveForDev(pageFetch)
|
|
169
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
166
170
|
const result = await executeFetch(cfg, fetchOptions)
|
|
167
171
|
if (result.data && !result.error) {
|
|
168
172
|
fetchedData.push(entry(cfg, result.data))
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
//
|
|
173
|
-
|
|
176
|
+
// Section-level fetches (own fetch → parsedContent.data) — ⛔ never on a
|
|
177
|
+
// parametric page, whose sections the runtime fills per URL (as prerender does).
|
|
178
|
+
if (!page.isDynamic) await processDevSectionFetches(page.sections, fetchOptions, resolveForDev)
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
// Store on siteContent for runtime DataStore pre-population
|
|
@@ -184,14 +189,15 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
184
189
|
* @param {Array} sections - Sections to process
|
|
185
190
|
* @param {Object} fetchOptions - Options for executeFetch
|
|
186
191
|
*/
|
|
187
|
-
async function processDevSectionFetches(sections, fetchOptions) {
|
|
192
|
+
async function processDevSectionFetches(sections, fetchOptions, resolve) {
|
|
188
193
|
if (!sections || !Array.isArray(sections)) return
|
|
189
194
|
|
|
190
195
|
for (const section of sections) {
|
|
191
196
|
// Execute every section-level fetch, so dev shows what prerender will.
|
|
192
197
|
for (const sectionFetch of toFetchList(section.fetch)) {
|
|
193
|
-
|
|
194
|
-
|
|
198
|
+
const cfg = resolve(sectionFetch)
|
|
199
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
200
|
+
const result = await executeFetch(cfg, fetchOptions)
|
|
195
201
|
if (result.data && !result.error) {
|
|
196
202
|
// A section's own fetch goes to content.data, matching prerender.
|
|
197
203
|
section.parsedContent = mergeDataIntoContent(
|
|
@@ -205,7 +211,7 @@ async function processDevSectionFetches(sections, fetchOptions) {
|
|
|
205
211
|
|
|
206
212
|
// Process subsections recursively
|
|
207
213
|
if (section.subsections && section.subsections.length > 0) {
|
|
208
|
-
await processDevSectionFetches(section.subsections, fetchOptions)
|
|
214
|
+
await processDevSectionFetches(section.subsections, fetchOptions, resolve)
|
|
209
215
|
}
|
|
210
216
|
}
|
|
211
217
|
}
|
|
@@ -821,7 +827,9 @@ export function siteContentPlugin(options = {}) {
|
|
|
821
827
|
|
|
822
828
|
if (queriesConfig) {
|
|
823
829
|
console.log('[site-content] Materializing queries...')
|
|
824
|
-
const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath
|
|
830
|
+
const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath, {
|
|
831
|
+
locale: resolveDefaultLocale(earlyContent.config) ?? null,
|
|
832
|
+
})
|
|
825
833
|
await writeQueryFiles(resolvedSitePath, byQuery, queriesConfig)
|
|
826
834
|
}
|
|
827
835
|
} catch (err) {
|
|
@@ -137,6 +137,8 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
for (const decl of Object.values(declarations)) refuseQueryDeclaration(decl)
|
|
141
|
+
|
|
140
142
|
// Schema default (query-name convention) + `model:`→`schema:` synonym.
|
|
141
143
|
// `schemaExplicit` records whether the author asked for this schema: an explicit
|
|
142
144
|
// schema that fails to resolve is a hard error; a convention-defaulted one that
|
|
@@ -147,8 +149,8 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
147
149
|
} else if (decl.model) {
|
|
148
150
|
decl.schema = decl.model // migration synonym
|
|
149
151
|
decl.schemaExplicit = true
|
|
150
|
-
} else if (
|
|
151
|
-
decl.schema = defaultSchema(decl.name) // query-name convention
|
|
152
|
+
} else if (decl.url === undefined) {
|
|
153
|
+
decl.schema = defaultSchema(decl.name) // query-name convention; an external query has none
|
|
152
154
|
decl.schemaExplicit = false
|
|
153
155
|
}
|
|
154
156
|
}
|
|
@@ -156,10 +158,9 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
156
158
|
// ⛔ A `path:` ON A FILE-BASED QUERY DOES NOTHING, so say so. It resolved the
|
|
157
159
|
// pool while the pool was `collections/<name>/`; now `schema:` does, and a key
|
|
158
160
|
// that is quietly inert is how an author spends an afternoon on a query that
|
|
159
|
-
// was reading a different set of files all along.
|
|
160
|
-
// REMOTE source, whose address nothing local can derive.
|
|
161
|
+
// was reading a different set of files all along. (Beside `url:` it is refused.)
|
|
161
162
|
for (const decl of Object.values(declarations)) {
|
|
162
|
-
if (decl.path &&
|
|
163
|
+
if (decl.path && decl.url === undefined) {
|
|
163
164
|
console.warn(
|
|
164
165
|
`[uniweb] query "${decl.name}": \`path: ${decl.path}\` is ignored. A query names a ` +
|
|
165
166
|
`\`schema:\` and \`entities/{schema}/\` supplies its records — there is no directory ` +
|
|
@@ -187,6 +188,80 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/** What a query over the site's records declares, which an external query cannot. */
|
|
192
|
+
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', 'route', 'path']
|
|
193
|
+
/** The keys of an external query's `record:` request. */
|
|
194
|
+
const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ⛔ WHAT A QUERY DECLARATION MAY NOT SAY — refused on the build and the sync push
|
|
198
|
+
* alike, since both resolve queries here.
|
|
199
|
+
*
|
|
200
|
+
* ⭐ AN EXTERNAL QUERY is a query with `url:` (ruled 2026-09-13 [Diego]: *"Setting
|
|
201
|
+
* `url` would classify it as external"*): an address, `method` and `body` for a POST,
|
|
202
|
+
* `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
|
|
203
|
+
* them, and `record:` — `{ url, method, body, transform }` — for one record on a
|
|
204
|
+
* parametric page. What describes the site's records — `schema`, `scope`,
|
|
205
|
+
* `deferred`, `excerpt`, `route` — is refused beside `url:`. ⛔ `detailUrl:` is
|
|
206
|
+
* retired everywhere: its one real case is `record.url`.
|
|
207
|
+
*
|
|
208
|
+
* @param {Object} decl - one normalized declaration (`{ name, … }`)
|
|
209
|
+
*/
|
|
210
|
+
export function refuseQueryDeclaration(decl) {
|
|
211
|
+
const where = `query "${decl.name}"`
|
|
212
|
+
if (decl.detailUrl !== undefined) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`[uniweb] ${where}: \`detailUrl:\` is retired. An API's single-record address is \`record: { url: … }\` ` +
|
|
215
|
+
`on an external query — one with \`url:\`.`
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
const external = decl.url !== undefined
|
|
219
|
+
if (!external) {
|
|
220
|
+
for (const key of ['method', 'body', 'transform', 'record']) {
|
|
221
|
+
if (decl[key] === undefined) continue
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[uniweb] ${where}: \`${key}:\` belongs on an external query — one with \`url:\`. A query over the site's ` +
|
|
224
|
+
`records names a \`schema:\` instead.`
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (typeof decl.url !== 'string' || decl.url.trim() === '') {
|
|
230
|
+
throw new Error(`[uniweb] ${where}: \`url:\` is the external source's address — a non-empty string.`)
|
|
231
|
+
}
|
|
232
|
+
const clash = SITE_RECORDS_ONLY.filter((key) => decl[key] !== undefined)
|
|
233
|
+
if (clash.length > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`[uniweb] ${where}: ${clash.map((k) => `\`${k}:\``).join(', ')} ${clash.length === 1 ? 'describes' : 'describe'} the ` +
|
|
236
|
+
`site's records, and this query has \`url:\` — an external source. Declare a separate query for the site's records.`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
refuseMethod(decl.method, where)
|
|
240
|
+
if (decl.transform !== undefined && typeof decl.transform !== 'string') {
|
|
241
|
+
throw new Error(`[uniweb] ${where}: \`transform:\` is a dot-path to the records in the response, e.g. \`data.items\`.`)
|
|
242
|
+
}
|
|
243
|
+
if (decl.record === undefined) return
|
|
244
|
+
if (!decl.record || typeof decl.record !== 'object' || Array.isArray(decl.record)) {
|
|
245
|
+
throw new Error(`[uniweb] ${where}: \`record:\` is the request for one record — \`{ url, method, body, transform }\`.`)
|
|
246
|
+
}
|
|
247
|
+
const unknown = Object.keys(decl.record).filter((key) => !RECORD_KEYS.includes(key))
|
|
248
|
+
if (unknown.length > 0) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`[uniweb] ${where}: \`record:\` takes ${RECORD_KEYS.map((k) => `\`${k}\``).join(', ')} — not ${unknown.map((k) => `\`${k}\``).join(', ')}.`
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
refuseMethod(decl.record.method, `${where} record`)
|
|
254
|
+
if (decl.record.transform !== undefined && typeof decl.record.transform !== 'string') {
|
|
255
|
+
throw new Error(`[uniweb] ${where} record: \`transform:\` is a dot-path to the record in the response.`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function refuseMethod(method, where) {
|
|
260
|
+
if (method === undefined) return
|
|
261
|
+
if (typeof method === 'string' && ['GET', 'POST'].includes(method.toUpperCase())) return
|
|
262
|
+
throw new Error(`[uniweb] ${where}: \`method: ${JSON.stringify(method)}\` — an external query is read with \`GET\` or \`POST\`.`)
|
|
263
|
+
}
|
|
264
|
+
|
|
190
265
|
/**
|
|
191
266
|
* The declarations as `config.queries` should carry them.
|
|
192
267
|
*
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* array, with the cite key as slug). Multiple files in the same folder
|
|
34
34
|
* merge — the loader flattens one level after collecting them.
|
|
35
35
|
* - Converts markdown body to ProseMirror JSON
|
|
36
|
-
* -
|
|
36
|
+
* - Applies the query's fixed `where` and orders by its `sort`; its `limit` is
|
|
37
|
+
* left to the runtime, so every record the query selects compiles
|
|
37
38
|
* - Auto-generates excerpts and extracts first images (markdown items only)
|
|
38
39
|
*
|
|
39
40
|
* @module @uniweb/build/site/collection-processor
|
|
@@ -55,7 +56,7 @@ import { existsSync } from 'node:fs'
|
|
|
55
56
|
import yaml from 'js-yaml'
|
|
56
57
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
57
58
|
import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
|
|
58
|
-
import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
|
|
59
|
+
import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage } from './data-fetcher.js'
|
|
59
60
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
60
61
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
61
62
|
import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
|
|
@@ -124,6 +125,7 @@ function parseQueryConfig(name, config) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
refuseUnder(config.where, `queries.${name}`)
|
|
128
|
+
refuseOutsideLanguage(config.where, `queries.${name}`)
|
|
127
129
|
return {
|
|
128
130
|
name,
|
|
129
131
|
// The query's schema selects its records from the pool — `entities/{schema}/`
|
|
@@ -153,15 +155,6 @@ function parseQueryConfig(name, config) {
|
|
|
153
155
|
// automatically on dynamic-route pages (entity-store routes the
|
|
154
156
|
// singular detail there) or via the kit's useEntityDetail hook.
|
|
155
157
|
deferred: Array.isArray(config.deferred) ? config.deferred.slice() : null,
|
|
156
|
-
// `detailUrl:` names the per-record endpoint pattern for API-backed
|
|
157
|
-
// remote sources (where the build emits no per-record files because
|
|
158
|
-
// there are no on-disk source files to materialize). Used by the
|
|
159
|
-
// runtime's auto-detail injection and the useEntityDetail kit hook.
|
|
160
|
-
// Pattern uses {slug} as the placeholder; substitution at runtime
|
|
161
|
-
// pulls from the dynamic-route param (entity-store) or the record's
|
|
162
|
-
// slug field (useEntityDetail). File-backed queries leave
|
|
163
|
-
// this null and get the static-file default /data/<name>/<slug>.json.
|
|
164
|
-
detailUrl: typeof config.detailUrl === 'string' ? config.detailUrl : null,
|
|
165
158
|
// `queryable:` declares the queryable surface — which fields a
|
|
166
159
|
// foundation can offer for filtering UI, with their type and
|
|
167
160
|
// type-specific metadata (enum options, range bounds). Foundations
|
|
@@ -624,7 +617,7 @@ function warnDuplicateSlugs(items, queryName) {
|
|
|
624
617
|
* @param {Object} config - Parsed collection config
|
|
625
618
|
* @returns {Promise<Array>} Array of processed items
|
|
626
619
|
*/
|
|
627
|
-
async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
620
|
+
async function collectItems(siteDir, config, entitiesDir, basePath, locale = null) {
|
|
628
621
|
// ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS — the same resolution the
|
|
629
622
|
// sync lane makes, from the same reader, so the two lanes cannot disagree
|
|
630
623
|
// about which files are a query's records. They used to: this one recursed
|
|
@@ -719,10 +712,10 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
719
712
|
})
|
|
720
713
|
}
|
|
721
714
|
|
|
722
|
-
// ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where,
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
715
|
+
// ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
|
|
716
|
+
// lanes evaluate the same declaration (this one materializes a query to
|
|
717
|
+
// `/data/<name>.json`; that one runs a page-level `fetch:`), so a difference in
|
|
718
|
+
// order is a difference in RESULT.
|
|
726
719
|
//
|
|
727
720
|
// ⚠️ `where` was missing here entirely until 2026-08-29: `parseQueryConfig`
|
|
728
721
|
// read `filter` and never `where`, so the CANONICAL predicate was parsed, put on
|
|
@@ -735,24 +728,26 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
735
728
|
// `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
|
|
736
729
|
// `':dir'`, and the query compiled to no records (measured).
|
|
737
730
|
//
|
|
738
|
-
// ⛔ `scope` is NEVER baked, fixed or routed
|
|
739
|
-
//
|
|
740
|
-
// service does. Baked here, a page's `scope:` could only narrow inside the
|
|
741
|
-
// query's branch on a static site and would replace it on a hosted one.
|
|
731
|
+
// ⛔ `scope` is NEVER baked, fixed or routed — the runtime applies it, as the
|
|
732
|
+
// records service does, so a routed `scope: :dir` binds per page.
|
|
742
733
|
const fixed = withoutRouteVariables({ where: config.where })
|
|
743
734
|
if (fixed.where) {
|
|
744
735
|
items = applyWhere(items, fixed.where)
|
|
745
736
|
}
|
|
746
737
|
|
|
747
|
-
//
|
|
738
|
+
// The query's `sort` orders the file; the runtime applies whichever sort wins —
|
|
739
|
+
// a binding's own, else this one — so the order here only spares it the work.
|
|
740
|
+
// Texts collate in the site's default language; each page re-sorts in its own.
|
|
748
741
|
if (config.sort) {
|
|
749
|
-
items = applySort(items, config.sort)
|
|
742
|
+
items = applySort(items, config.sort, { locale })
|
|
750
743
|
}
|
|
751
744
|
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
745
|
+
// ⛔ `limit` IS NEVER BAKED (ruled 2026-09-13 [Diego]): it is how many a list
|
|
746
|
+
// shows, and a binding may pick its own — more than the query's included — which
|
|
747
|
+
// a file cut here could not give it. Every record the query selects compiles, and
|
|
748
|
+
// gets its detail page; the runtime cuts each list (`@uniweb/core/fetch-config`,
|
|
749
|
+
// `narrowQuery`). Until then this sliced the file, so a query's `limit: 2` made the
|
|
750
|
+
// static build emit two detail pages while the records service resolved all five.
|
|
756
751
|
|
|
757
752
|
return items
|
|
758
753
|
}
|
|
@@ -763,6 +758,10 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
763
758
|
* @param {string} siteDir - Site root directory
|
|
764
759
|
* @param {Object} queriesConfig - the resolved QUERY declarations
|
|
765
760
|
* @param {string} [entitiesDir] - pool directory override (`site.yml::paths.entities`)
|
|
761
|
+
* @param {string} [basePath='/']
|
|
762
|
+
* @param {Object} [options]
|
|
763
|
+
* @param {string|null} [options.locale] - the site's default language, which a query's `sort`
|
|
764
|
+
* collates texts in when it orders the compiled file
|
|
766
765
|
* @returns {Promise<Object>} Map of collection name to items array
|
|
767
766
|
*
|
|
768
767
|
* @example
|
|
@@ -772,7 +771,7 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
772
771
|
* })
|
|
773
772
|
* // { articles: [...], products: [...] }
|
|
774
773
|
*/
|
|
775
|
-
export async function processQueries(siteDir, queriesConfig, entitiesDir, basePath = '/') {
|
|
774
|
+
export async function processQueries(siteDir, queriesConfig, entitiesDir, basePath = '/', { locale = null } = {}) {
|
|
776
775
|
if (!queriesConfig || typeof queriesConfig !== 'object') {
|
|
777
776
|
return {}
|
|
778
777
|
}
|
|
@@ -810,17 +809,21 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
|
|
|
810
809
|
const results = {}
|
|
811
810
|
|
|
812
811
|
for (const [name, config] of Object.entries(queriesConfig)) {
|
|
812
|
+
// ⭐ AN EXTERNAL QUERY COMPILES NOTHING — its records are its address's, fetched
|
|
813
|
+
// where the page renders (`@uniweb/core/fetch-config`). A file written here would
|
|
814
|
+
// be an empty `/data/<name>.json` standing in for a live source.
|
|
815
|
+
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
813
816
|
const parsed = parseQueryConfig(name, config)
|
|
814
817
|
parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
|
|
815
818
|
parsed.placements = folder?.placements ?? null
|
|
816
|
-
if (parsed.poolEntities.length === 0
|
|
819
|
+
if (parsed.poolEntities.length === 0) {
|
|
817
820
|
console.warn(
|
|
818
821
|
`[query-processor] Query "${name}" matches no records — nothing ` +
|
|
819
822
|
`published declares ${parsed.schema || '(no schema)'}. ` +
|
|
820
823
|
(managed ? 'Check records.yml lists them.' : 'Check entities/.')
|
|
821
824
|
)
|
|
822
825
|
}
|
|
823
|
-
const items = await collectItems(siteDir, parsed, entitiesDir, basePath)
|
|
826
|
+
const items = await collectItems(siteDir, parsed, entitiesDir, basePath, locale)
|
|
824
827
|
results[name] = items
|
|
825
828
|
console.log(`[query-processor] Processed ${name}: ${items.length} items`)
|
|
826
829
|
}
|