@uniweb/build 0.47.1 → 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.
@@ -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
@@ -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
- // Process section-level fetches (own fetch → parsedContent.data)
173
- await processDevSectionFetches(page.sections, fetchOptions)
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
- if (!shouldPrefetchInDev(sectionFetch)) continue
194
- const result = await executeFetch(sectionFetch, fetchOptions)
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 (!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
@@ -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,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. 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.
748
740
  if (config.sort) {
749
741
  items = applySort(items, config.sort)
750
742
  }
751
743
 
752
- // Apply limit
753
- if (config.limit > 0) {
754
- items = items.slice(0, config.limit)
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 && !parsed.url) {
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
- // wire `detail_url` → file-side `detailUrl` (the key the producer reads).
289
- if (d.detail_url !== undefined) decl.detailUrl = d.detail_url
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
@@ -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
- walkSections(doc?.layout_sections, 'layout')
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> }`. */