@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.
@@ -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 has three shapes, and the keys each one
4
- // accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
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
- // refine refine · detail · limit · sort · where · filter
7
- // query query · schema · … — and NOT `path`/`url`
8
- // source path · url · schema · …
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 of the three shapes a declaration is — the same order `data-fetcher` uses. */
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
+ }
@@ -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
- * - Local `path:` sources (file-based collections → `/data/*.json`) are served
104
- * by Vite and always browser-reachable, so we never embed them — the runtime
105
- * fetches them live and picks up edits on reload. This also gives local
106
- * collections true parity with a real backend fetcher.
107
- * - `prerender: false` opts a source out of build-time fetching by definition.
108
- * - Remote `url:` sources with `prerender !== false` may target a
109
- * build-time-only endpoint (server-side auth, no CORS), so we keep
110
- * pre-fetching and embedding them, mirroring what prod prerender does.
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 normalized fetch config (parseFetchConfig output)
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 // refinement / nothing to fetch
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
- const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
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
- // Process section-level fetches (own fetch → parsedContent.data)
173
- await processDevSectionFetches(page.sections, fetchOptions)
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
- if (!shouldPrefetchInDev(sectionFetch)) continue
194
- const result = await executeFetch(sectionFetch, fetchOptions)
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 (!decl.url) {
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. It stays meaningful for a
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 && !decl.url) {
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
- * - Supports filtering, sorting, and limiting
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, filter, sort,
723
- // limit. Two lanes evaluate the same declaration (this one materializes a query
724
- // to `/data/<name>.json`; that one runs a page-level `fetch:`), so a difference
725
- // in order is a difference in RESULT for any query that both narrows and limits.
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. The runtime applies the one that
739
- // wins — a page fetch's own, else this query's — which is what the records
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
- // Apply sort
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
- // Apply limit
753
- if (config.limit > 0) {
754
- items = items.slice(0, config.limit)
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 && !parsed.url) {
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
  }