@uniweb/build 0.47.2 → 0.48.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 +124 -47
- package/src/site/content-collector.js +121 -190
- package/src/site/data-fetcher.js +294 -188
- package/src/site/fetch-shapes.js +9 -8
- package/src/site/layout-folder.js +221 -0
- package/src/site/plugin.js +23 -19
- package/src/site/queries-config.js +80 -5
- package/src/site/query-processor.js +23 -25
- 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
|
@@ -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
|
|
@@ -149,8 +151,8 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
149
151
|
|
|
150
152
|
// Site-level fetch — every declaration.
|
|
151
153
|
for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
|
|
152
|
-
if (!shouldPrefetchInDev(siteFetch)) continue
|
|
153
154
|
const cfg = resolveForDev(siteFetch)
|
|
155
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
154
156
|
const result = await executeFetch(cfg, fetchOptions)
|
|
155
157
|
if (result.data && !result.error) {
|
|
156
158
|
fetchedData.push(entry(cfg, result.data))
|
|
@@ -161,16 +163,17 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
161
163
|
for (const page of siteContent.pages || []) {
|
|
162
164
|
// Page-level fetch — every declaration.
|
|
163
165
|
for (const pageFetch of toFetchList(page.fetch)) {
|
|
164
|
-
if (!shouldPrefetchInDev(pageFetch)) continue
|
|
165
166
|
const cfg = resolveForDev(pageFetch)
|
|
167
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
166
168
|
const result = await executeFetch(cfg, fetchOptions)
|
|
167
169
|
if (result.data && !result.error) {
|
|
168
170
|
fetchedData.push(entry(cfg, result.data))
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
+
// Section-level fetches (own fetch → parsedContent.data) — ⛔ never on a
|
|
175
|
+
// parametric page, whose sections the runtime fills per URL (as prerender does).
|
|
176
|
+
if (!page.isDynamic) await processDevSectionFetches(page.sections, fetchOptions, resolveForDev)
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
// Store on siteContent for runtime DataStore pre-population
|
|
@@ -184,14 +187,15 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
184
187
|
* @param {Array} sections - Sections to process
|
|
185
188
|
* @param {Object} fetchOptions - Options for executeFetch
|
|
186
189
|
*/
|
|
187
|
-
async function processDevSectionFetches(sections, fetchOptions) {
|
|
190
|
+
async function processDevSectionFetches(sections, fetchOptions, resolve) {
|
|
188
191
|
if (!sections || !Array.isArray(sections)) return
|
|
189
192
|
|
|
190
193
|
for (const section of sections) {
|
|
191
194
|
// Execute every section-level fetch, so dev shows what prerender will.
|
|
192
195
|
for (const sectionFetch of toFetchList(section.fetch)) {
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
const cfg = resolve(sectionFetch)
|
|
197
|
+
if (!shouldPrefetchInDev(cfg)) continue
|
|
198
|
+
const result = await executeFetch(cfg, fetchOptions)
|
|
195
199
|
if (result.data && !result.error) {
|
|
196
200
|
// A section's own fetch goes to content.data, matching prerender.
|
|
197
201
|
section.parsedContent = mergeDataIntoContent(
|
|
@@ -205,7 +209,7 @@ async function processDevSectionFetches(sections, fetchOptions) {
|
|
|
205
209
|
|
|
206
210
|
// Process subsections recursively
|
|
207
211
|
if (section.subsections && section.subsections.length > 0) {
|
|
208
|
-
await processDevSectionFetches(section.subsections, fetchOptions)
|
|
212
|
+
await processDevSectionFetches(section.subsections, fetchOptions, resolve)
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
}
|
|
@@ -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
|
|
@@ -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,25 @@ 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.
|
|
748
740
|
if (config.sort) {
|
|
749
741
|
items = applySort(items, config.sort)
|
|
750
742
|
}
|
|
751
743
|
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
744
|
+
// ⛔ `limit` IS NEVER BAKED (ruled 2026-09-13 [Diego]): it is how many a list
|
|
745
|
+
// shows, and a binding may pick its own — more than the query's included — which
|
|
746
|
+
// a file cut here could not give it. Every record the query selects compiles, and
|
|
747
|
+
// gets its detail page; the runtime cuts each list (`@uniweb/core/fetch-config`,
|
|
748
|
+
// `narrowQuery`). Until then this sliced the file, so a query's `limit: 2` made the
|
|
749
|
+
// static build emit two detail pages while the records service resolved all five.
|
|
756
750
|
|
|
757
751
|
return items
|
|
758
752
|
}
|
|
@@ -810,10 +804,14 @@ export async function processQueries(siteDir, queriesConfig, entitiesDir, basePa
|
|
|
810
804
|
const results = {}
|
|
811
805
|
|
|
812
806
|
for (const [name, config] of Object.entries(queriesConfig)) {
|
|
807
|
+
// ⭐ AN EXTERNAL QUERY COMPILES NOTHING — its records are its address's, fetched
|
|
808
|
+
// where the page renders (`@uniweb/core/fetch-config`). A file written here would
|
|
809
|
+
// be an empty `/data/<name>.json` standing in for a live source.
|
|
810
|
+
if (config && typeof config === 'object' && config.url !== undefined) continue
|
|
813
811
|
const parsed = parseQueryConfig(name, config)
|
|
814
812
|
parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
|
|
815
813
|
parsed.placements = folder?.placements ?? null
|
|
816
|
-
if (parsed.poolEntities.length === 0
|
|
814
|
+
if (parsed.poolEntities.length === 0) {
|
|
817
815
|
console.warn(
|
|
818
816
|
`[query-processor] Query "${name}" matches no records — nothing ` +
|
|
819
817
|
`published declares ${parsed.schema || '(no schema)'}. ` +
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files and folders in a site's content tree are content.
|
|
3
|
+
*
|
|
4
|
+
* One home for the two rules, so the page collector and the layout folder reader
|
|
5
|
+
* (`site/layout-folder.js`) cannot disagree about them.
|
|
6
|
+
*
|
|
7
|
+
* @module @uniweb/build/utils/content-files
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a file is a markdown file that should be processed.
|
|
12
|
+
* Excludes:
|
|
13
|
+
* - Files not ending in .md
|
|
14
|
+
* - Files starting with _ (drafts/private)
|
|
15
|
+
* - README.md (repo documentation, not site content)
|
|
16
|
+
*/
|
|
17
|
+
export function isMarkdownFile(filename) {
|
|
18
|
+
if (!filename.endsWith('.md')) return false
|
|
19
|
+
if (filename.startsWith('_')) return false
|
|
20
|
+
if (filename.toLowerCase() === 'readme.md') return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a folder should be ignored.
|
|
26
|
+
*
|
|
27
|
+
* Excludes folders starting with `_` (drafts/private) and with `.` (hidden).
|
|
28
|
+
*
|
|
29
|
+
* Hidden folders matter more than they look. A site's own `pages/` never holds
|
|
30
|
+
* one, but a mount target routinely does: point `paths:` at a directory that is
|
|
31
|
+
* also a git working tree — a sibling clone, which the docs suggest — and `.git`
|
|
32
|
+
* is a directory sitting right next to the content. Walked as content it
|
|
33
|
+
* contributed hundreds of routes. (A submodule hides this: there, `.git` is a
|
|
34
|
+
* file, so only the plain-clone case ever showed it.)
|
|
35
|
+
*/
|
|
36
|
+
export function isIgnoredFolder(name) {
|
|
37
|
+
return name.startsWith('_') || name.startsWith('.')
|
|
38
|
+
}
|
|
@@ -249,7 +249,12 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
|
249
249
|
|
|
250
250
|
const source = d.source || {}
|
|
251
251
|
if (typeof source.url === 'string') {
|
|
252
|
+
// ⭐ An external query's source, whole — the inverse of `site.js::externalSource`.
|
|
252
253
|
decl.url = source.url
|
|
254
|
+
setIf(decl, 'method', source.method)
|
|
255
|
+
setIf(decl, 'body', source.body)
|
|
256
|
+
setIf(decl, 'transform', source.transform)
|
|
257
|
+
setIf(decl, 'record', source.record)
|
|
253
258
|
} else if (typeof source.path === 'string') {
|
|
254
259
|
// ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `entities/{schema}/` is the
|
|
255
260
|
// pool and `schema:` addresses it, so a `path` arriving on the wire is either
|
|
@@ -285,8 +290,8 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
|
285
290
|
if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
|
|
286
291
|
decl.deferred = d.deferred
|
|
287
292
|
}
|
|
288
|
-
//
|
|
289
|
-
|
|
293
|
+
// ⛔ A stored `detail_url` is not written back: `detailUrl:` is retired (2026-09-13)
|
|
294
|
+
// and the build refuses it — its case is `record.url` on an external query.
|
|
290
295
|
setIf(decl, 'queryable', d.queryable)
|
|
291
296
|
|
|
292
297
|
// ⛔ PRESERVE WHAT WE DO NOT MODEL — the pull half of the same rule the emitter
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// reported as unattributed rather than guessed at.
|
|
48
48
|
|
|
49
49
|
import { entityContentHash } from './records.js'
|
|
50
|
-
import { recordStableId, safeStableIdFilename, pageDirName } from './site-project.js'
|
|
50
|
+
import { recordStableId, safeStableIdFilename, pageDirName, layoutSectionPaths } from './site-project.js'
|
|
51
51
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
52
52
|
|
|
53
53
|
// A unit's own content, with the nested collections that are their own units
|
|
@@ -119,7 +119,12 @@ export function walkSiteUnits(doc, cb, sourceLocale = LOCALIZED_FIELD_ASSUMPTION
|
|
|
119
119
|
// is the label, since that is where its identity-bearing fields live.
|
|
120
120
|
if (doc?.info && typeof doc.info === 'object') cb('site.yml', doc.info, 'info')
|
|
121
121
|
walkPages(doc?.pages, 'pages')
|
|
122
|
-
|
|
122
|
+
// ⭐ A layout section is keyed by the path the pull writes it to (`layoutSectionPaths`),
|
|
123
|
+
// which names its layout and area. ⛔ It was `layout/<stable id>.md` until 2026-09-13,
|
|
124
|
+
// so a named layout's `footer` and the default layout's `footer` shared one key.
|
|
125
|
+
for (const { record, relPath } of layoutSectionPaths(doc?.layout_sections)) {
|
|
126
|
+
cb(`layout/${relPath}`, record, 'section')
|
|
127
|
+
}
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/** Per-unit content hashes: `{ <path>: <sha256> }`. */
|