uniweb 0.59.2 → 0.61.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,347 +1,137 @@
1
1
  /**
2
- * The project's SYNC SCOPE — reading and asserting `site.yml::$backend`.
2
+ * The project's backend-minted identity — read from `sync.json`, never from `site.yml`.
3
3
  *
4
- * A project that has synced holds backend-minted identity on **four** surfaces:
4
+ * ⭐ **`site.yml` holds nothing a backend minted, as of 2026-09-20.** It carried
5
+ * `$uuid`, `$org` and `$backend`; all three moved to
6
+ * `sync.json::backends.<origin>.site`, keyed by the backend that minted them.
7
+ * Spec: `kb/framework/reference/sync-json.md`. Why:
8
+ * `kb/framework/plans/backend-scoped-project-state.md`.
5
9
  *
6
- * · `site.yml::$uuid` the site-content entity
7
- * · every collection record `$uuid` in its own source file (the pull-side match key)
8
- * · `assets.json` local asset path → content-addressed id
9
- * · `.uniweb/sync-cache.json` item uuids, hashes, base versions
10
+ * ## ⛔ What is GONE, and why nothing replaces it
10
11
  *
11
- * All four are meaningless against a different backend, and none of them says which
12
- * backend it came from. `$backend` is the one fact that scopes all of them — which is
13
- * why a mismatch is a **stop**, not a fallback: it does not mean "we guessed the wrong
14
- * default", it means the entire stored surface is foreign.
12
+ * `assertSiteBackendScope` refused a command whose resolved origin disagreed with
13
+ * the project's recorded `$backend`. It existed because ONE `$uuid` sat in
14
+ * `site.yml` with no way to say which backend it was for, so it could be read
15
+ * against the wrong one and sent there.
15
16
  *
16
- * ⛔ **`$backend` is absent for the default backend, deliberately.** The 98% case keeps a
17
- * clean `site.yml`, and an absent value reads as the default — correct both for a project
18
- * written before this key existed and for one synced against the default. That inference
19
- * is monotone: it is never worse than the pre-`$backend` behaviour, which recorded nothing
20
- * at all.
17
+ * ⭐ **Keyed by origin that is unrepresentable.** A command for backend B reads B's
18
+ * section and finds B's ids or nothing at all. There is no configuration in which
19
+ * A's identity reaches B, so there is no mismatch to detect, no stop to print, and
20
+ * no accepted false positive to live with. The guard is not replaced; the shape it
21
+ * guarded against stopped existing.
21
22
  *
22
- * ⚠️ **Reads `site.yml` only, not the legacy `site.yaml`.** That matches every existing
23
- * identity reader AND all three writers (`writeSiteEntityUuid`, `writeSiteOrg`,
24
- * `clone.js`'s `seedYamlUuid`), so this module is consistent with what is on disk. It is
25
- * also a KNOWN GAP, not an oversight: five other readers do accept `site.yaml`, and
26
- * `upsertYamlScalar` creates a file when missing — so on a `site.yaml` project the writers
27
- * produce a phantom `site.yml` holding nothing but identity. Closing that is its own change,
28
- * and it has to move the readers and the writers together or it makes the split worse.
23
+ * `recordSiteBackend` and `$backend` went with it: the store's KEY is the scope.
24
+ *
25
+ * ## ⛔ NO STATIC `@uniweb/build` IMPORT
26
+ *
27
+ * This module is reachable from the CLI's startup graph, and `@uniweb/build` is an
28
+ * OPTIONAL PEER — a static import makes `uniweb --version` die with
29
+ * ERR_MODULE_NOT_FOUND on a global install (`test/smoke-startup.test.js` catches it).
30
+ *
31
+ * ⭐ **`sync.json` is plain JSON, so reading it needs no parser and no dependency.**
32
+ * That is the whole reason the format is JSON rather than YAML: `site.yml`'s
33
+ * equivalent needed a hand-rolled dependency-free fallback WRITER to survive the
34
+ * same constraint. Writes go through `@uniweb/build/uwx`'s store, which is the one
35
+ * implementation; this file only reads.
29
36
  */
30
37
 
31
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
32
- import { join, dirname } from 'node:path'
33
- import yaml from 'js-yaml'
38
+ import { existsSync, readFileSync } from 'node:fs'
39
+ import { join } from 'node:path'
34
40
  import { DEFAULT_BACKEND_ORIGIN } from './config.js'
35
41
 
36
- // ⛔ NO STATIC `@uniweb/build` IMPORT IN THIS FILE — it is an OPTIONAL PEER, and this
37
- // module is reachable from the CLI's startup graph (`index.js` imports `clone` eagerly,
38
- // and `clone` imports this). A static import here makes `uniweb --version` die with
39
- // ERR_MODULE_NOT_FOUND on a global install that has no build package:
40
- //
41
- // $ npm i -g uniweb && uniweb --version
42
- // Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@uniweb/build'
43
- //
44
- // `test/smoke-startup.test.js` catches this structurally — and did, on the commit that
45
- // introduced this file. The writer below therefore imports lazily, which also keeps the
46
- // cost off every startup that never writes. Command modules may import build statically;
47
- // anything a `utils/` leaf pulls in cannot.
48
-
49
42
  /** A bare origin with no trailing slash, or null when unparseable. */
50
43
  export function normalizeOrigin(value) {
51
44
  if (typeof value !== 'string' || !value.trim()) return null
52
45
  try {
53
- return new URL(value).origin
46
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null".
47
+ const u = new URL(value)
48
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
54
49
  } catch {
55
50
  return null
56
51
  }
57
52
  }
58
53
 
59
54
  /**
60
- * The project's identity trio, read from `site.yml`. Every field is independently
61
- * optional — a project may be unsynced (no `uuid`), on the default backend (no
62
- * `backend`), or personally owned (no `org`).
55
+ * `sync.json`'s backends map, or `{}`. Read-only, dependency-free — see the header.
63
56
  *
64
- * ⚠️ This is NOT yet the single accessor for `$uuid`. Nine other places still read it
65
- * directly; they are correct as they stand and were deliberately left alone when the
66
- * scope check was centralized here (a per-read accessor was solving a coupling problem
67
- * that one guard solves better). Consolidating them is separable cleanup.
68
- *
69
- * @param {string} siteDir
70
- * @returns {{ uuid: string|null, backend: string|null, org: string|null }}
57
+ * ⚠️ A second READER of a format whose writer lives in `@uniweb/build`. Deliberate,
58
+ * and bounded: it parses one object and normalizes its keys. The shape it assumes is
59
+ * exactly what `sync-store.js` writes, and `test/two-backends.test.js` drives both.
71
60
  */
72
- export function readSiteIdentity(siteDir) {
73
- const path = join(siteDir, 'site.yml')
74
- if (!existsSync(path)) return { uuid: null, backend: null, org: null }
75
- let y
61
+ function readBackends(siteDir) {
62
+ const p = join(siteDir, 'sync.json')
63
+ if (!existsSync(p)) return {}
76
64
  try {
77
- y = yaml.load(readFileSync(path, 'utf8'))
65
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
66
+ const backends = parsed?.backends
67
+ if (!backends || typeof backends !== 'object' || Array.isArray(backends)) return {}
68
+ const out = {}
69
+ for (const [origin, state] of Object.entries(backends)) {
70
+ const key = normalizeOrigin(origin)
71
+ if (key && state && typeof state === 'object') out[key] = state
72
+ }
73
+ return out
78
74
  } catch {
79
- // Unreadable or malformed — report "nothing recorded" rather than throwing. Every
80
- // caller here is a guard or a default; none of them should be the reason a command
81
- // dies, and a malformed site.yml has its own, better error elsewhere.
82
- return { uuid: null, backend: null, org: null }
83
- }
84
- if (!y || typeof y !== 'object') return { uuid: null, backend: null, org: null }
85
- const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
86
- return {
87
- uuid: str(y.$uuid),
88
- backend: normalizeOrigin(y.$backend),
89
- org: str(y.$org)
75
+ return {}
90
76
  }
91
77
  }
92
78
 
93
79
  /**
94
- * The backend this project is bound to, with the default filled in.
95
- *
96
- * Use this rather than `readSiteIdentity().backend` wherever an absent value means the
97
- * default — which is everywhere except a "was it recorded?" question.
98
- *
99
- * ⛔ **NOT for the origin ladder.** `resolveBackendOrigin`'s `siteScope` tier must receive
100
- * the RAW `readSiteIdentity().backend`, which is null when nothing was recorded. Handing
101
- * it this defaulted value would make the tier fire for EVERY project, and since it sits
102
- * above the session it would shadow `uniweb login --backend <local>` on any project
103
- * without `$backend` — i.e. break local development for the 98% that never record one.
104
- * "Absent means the default" is the right rule for a comparison and the wrong one for a
105
- * precedence chain, where absent has to mean "defer to the next tier".
80
+ * The identity this project holds for ONE backend.
106
81
  *
107
82
  * @param {string} siteDir
108
- * @returns {string} a bare origin
83
+ * @param {string} origin
84
+ * @returns {{ uuid: string|null, org: string|null }}
109
85
  */
110
- export function resolveSiteScope(siteDir) {
111
- return readSiteIdentity(siteDir).backend || DEFAULT_BACKEND_ORIGIN
86
+ export function readSiteIdentity(siteDir, origin) {
87
+ const key = normalizeOrigin(origin)
88
+ const site = key ? readBackends(siteDir)[key]?.site : null
89
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
90
+ return { uuid: str(site?.uuid), org: str(site?.org) }
112
91
  }
113
92
 
114
- /**
115
- * Record the sync scope on a site that has just been created or seeded.
116
- *
117
- * A no-op for the default backend (see the ⛔ above) and a no-op when the value is
118
- * already what we would write, so a re-push never dirties `git status`.
119
- *
120
- * @param {string} siteDir
121
- * @param {string} origin - the backend the site was just created on
122
- * @param {object} [deps]
123
- * @param {() => Promise<object>} [deps.loadUwx] - injected module loader. Exists so the
124
- * two failure branches below can be TESTED: both are about which `@uniweb/build`
125
- * happens to be on disk, which a test cannot otherwise vary — and the too-old
126
- * branch is precisely the one that used to fail silently.
127
- * @returns {Promise<string|null>} the origin recorded, or null when nothing was written
128
- */
129
- export async function recordSiteBackend(siteDir, origin, deps = {}) {
130
- const norm = normalizeOrigin(origin)
131
- if (!norm || norm === DEFAULT_BACKEND_ORIGIN) return null
132
- if (readSiteIdentity(siteDir).backend === norm) return null
133
- const loadUwx = deps.loadUwx || (() => import('@uniweb/build/uwx'))
134
- let mod
135
- try {
136
- // Lazy by necessity, not by taste — see the header.
137
- mod = await loadUwx()
138
- } catch {
139
- // ⛔ NO `@uniweb/build` — and this is the COMMON case on the path that needs it
140
- // most, not the rare one this branch used to assume.
141
- //
142
- // `clone` calls us straight after scaffolding and BEFORE `pnpm install`, so the
143
- // site has no `node_modules` yet; under `npx uniweb@latest` the CLI's own tree
144
- // may not carry build either (it is an optional peer). The import therefore
145
- // fails on a FRESH CLONE essentially always, and returning null wrote nothing.
146
- //
147
- // The cost is not cosmetic: the clone then holds a `$uuid` minted by the
148
- // `--backend` origin it was given, with nothing recording that origin, so the
149
- // scope guard infers the DEFAULT backend and stops the very next command with
150
- // "this project's stored identity belongs to https://uniweb.app". The origin was
151
- // on the command line the whole time.
152
- // Measured by the backend lane 2026-09-18: `0.48.5` recorded `$backend`,
153
- // `0.57.0` did not, and their clone was unusable until they added the line by hand.
154
- //
155
- // ⇒ Fall back to a dependency-free write. `js-yaml` is the CLI's own direct
156
- // dependency, so this path needs nothing installed in the project.
157
- return writeSiteBackendFallback(siteDir, norm)
158
- }
159
-
160
- // ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
161
- // above. `@uniweb/build` gained `writeSiteBackend` in 0.25.3; against an older copy the
162
- // import SUCCEEDS and the export is `undefined`, so calling it throws a TypeError that
163
- // a blanket catch would swallow — leaving the scope silently unrecorded on a project
164
- // that will later be stopped by the guard and told to add `$backend` by hand. Since the
165
- // CLI declares build as a peer at a caret range, a lockfile pinned to an older patch
166
- // reaches exactly this state on a CLI-only upgrade. Say so once.
167
- if (typeof mod.writeSiteBackend !== 'function') {
168
- console.error(
169
- `\x1b[33m⚠\x1b[0m This project's @uniweb/build is too old to record which backend it syncs with — upgrade it (\`npx uniweb@latest update\`), or add \`$backend: ${norm}\` to site.yml.`
170
- )
171
- return null
172
- }
173
-
174
- try {
175
- mod.writeSiteBackend(siteDir, norm)
176
- return norm
177
- } catch {
178
- // Same rule as `recordSiteOrg`: the uuid is the load-bearing write. Losing the scope
179
- // note must never fail a create that already succeeded on the backend — the guard
180
- // degrades to the pre-`$backend` behaviour, which is what everyone had until now.
181
- return null
182
- }
93
+ /** Every backend this project has synced with, sorted. */
94
+ export function syncedBackends(siteDir) {
95
+ return Object.keys(readBackends(siteDir)).sort()
183
96
  }
184
97
 
185
98
  /**
186
- * Write `$backend` into `site.yml` without `@uniweb/build` — the fallback for a
187
- * project whose dependencies are not installed yet (see `recordSiteBackend`).
99
+ * The backends this project HAS synced with, when `origin` is not one of them — or null.
188
100
  *
189
- * ⭐ Verifies the RESULT rather than trusting the edit: the file must still parse and
190
- * must read back the origin we meant to write. A write that corrupts `site.yml` would
191
- * be far worse than an unrecorded scope — an unparseable `site.yml` is the failure this
192
- * same channel spent an evening on.
101
+ * Every backend verb goes to the backend the user is logged in to *[Diego, 2026-09-21]*,
102
+ * so it can land where this project has no site while it has one elsewhere. A push
103
+ * there CREATES a second site rather than updating the one the project knows, and the
104
+ * owner question it asks does not say why — this is what lets the verb say it first.
193
105
  *
194
- * @returns {string|null} the recorded origin, or null when nothing was written
106
+ * @param {string} siteDir
107
+ * @param {string} origin - where the verb resolved to
108
+ * @returns {string[]|null}
195
109
  */
196
- function writeSiteBackendFallback(siteDir, norm) {
197
- const file = join(siteDir, 'site.yml')
198
- let text
199
- try {
200
- text = readFileSync(file, 'utf8')
201
- } catch {
202
- return null // no site.yml — nothing to annotate, and we do not create one
203
- }
204
- try {
205
- // Quote through js-yaml rather than by hand: an origin is plain today, but the
206
- // value is not ours to assume. Replace an existing key, else prepend.
207
- const scalar = yaml.dump(norm, { lineWidth: -1 }).trim()
208
- const next = /^\$backend:/m.test(text)
209
- ? text.replace(/^\$backend:.*$/m, `$backend: ${scalar}`)
210
- : `$backend: ${scalar}\n` + text
211
- const parsed = yaml.load(next)
212
- if (!parsed || typeof parsed !== 'object' || parsed.$backend !== norm) return null
213
- writeFileSync(file, next)
214
- return norm
215
- } catch {
216
- return null
217
- }
110
+ export function syncedElsewhere(siteDir, origin) {
111
+ const key = normalizeOrigin(origin)
112
+ const known = syncedBackends(siteDir)
113
+ if (!key || !known.length || known.includes(key)) return null
114
+ return known
218
115
  }
219
116
 
220
117
  /**
221
- * Refuse to act on a site whose stored identity belongs to a different backend.
118
+ * The heads-up for `syncedElsewhere`, as lines — each verb prints them with its own
119
+ * reporter. Only worth saying when the verb was not TOLD where to go: a `--backend` the
120
+ * user typed is already a decision.
222
121
  *
223
- * ⭐ **A stop, not a warning.** `BackendClient.token()` already carries an advisory
224
- * origin-mismatch guard for the SESSION (wrong bearer → a rejected request, recoverable).
225
- * This is the third leg and it is categorically worse: the stored surface is foreign, so
226
- * proceeding sends one backend's identity to another. The observed outcomes are a 404
227
- * whose stock advice destroys the binding, and — on the record lane — a hard refusal
228
- * (*"item uuids are globally unique; cross-entity move is not supported"*).
122
+ * ⚖️ Worded for where the verb GOES, not for why: it is the logged-in backend, or — logged
123
+ * in nowhere — the default one, where the login it is about to ask for will be.
229
124
  *
230
- * Silent for an unsynced project (nothing stored yet ⇒ nothing to be foreign) and for a
231
- * project on the default backend with no explicit override.
232
- *
233
- * @param {string} siteDir
234
- * @param {string} origin - the backend this command resolved to
235
- * @returns {{ ok: true } | { ok: false, message: string, hint: string[] }}
125
+ * @param {string[]} known
126
+ * @param {string} origin
127
+ * @param {'push'|'publish'} verb
236
128
  */
237
- export function assertSiteBackendScope(siteDir, origin) {
238
- const { uuid, backend } = readSiteIdentity(siteDir)
239
- // Nothing minted here yet — a create is exactly what is supposed to happen next, and
240
- // it will record the scope itself. Checking a project with no stored identity would
241
- // reject the first push of every new site.
242
- if (!uuid) return { ok: true }
243
-
244
- const target = normalizeOrigin(origin)
245
- const bound = backend || DEFAULT_BACKEND_ORIGIN
246
- if (!target || target === bound) return { ok: true }
247
-
248
- const hint = [
249
- `site.yml::$uuid (${uuid}) was minted by ${bound}, and the record uuids, assets.json`,
250
- 'and .uniweb/sync-cache.json are scoped to it too. Sending them elsewhere is refused.',
251
- '',
252
- `To work with ${bound}: uniweb login --backend ${bound} (or pass --backend ${bound})`,
253
- `To move this project to ${target}, it becomes a NEW site there — clear $uuid, $org and`,
254
- '$backend from site.yml and delete .uniweb/ and assets.json first.'
129
+ export function describeSyncedElsewhere(known, origin, verb) {
130
+ const where = known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`
131
+ return [
132
+ `This project's ${where} — not on ${origin}, where this ${verb} goes.`,
133
+ `It creates a new site there. To ${verb} to ${known.length === 1 ? 'that one' : 'one of those'} instead: uniweb login --backend <url>`
255
134
  ]
256
-
257
- // ⚠️ THE ONE FALSE POSITIVE, and it prints its own fix.
258
- //
259
- // `$backend` is omitted for the default backend, so an ABSENT value is ambiguous: it
260
- // means "the default" for anything written under this scheme, and "unknown" for a
261
- // project that synced to a non-default backend BEFORE the key existed. This branch
262
- // reads absent as the default, so that second project is stopped and told it belongs
263
- // to a backend it never used.
264
- //
265
- // Accepted deliberately rather than softened to a warning, because the alternative is
266
- // worse in the case that matters: warning-on-absent would leave every DEFAULT-backend
267
- // project — the 98% — unprotected forever, since those never record the key. Stopping
268
- // on a guess is only tolerable when the guess prints the one-line correction, so it
269
- // does. (The population at risk is also near-empty by construction: pre-`$backend`
270
- // projects on a non-default backend, at a moment when no such backend is running.)
271
- if (!backend) {
272
- hint.push(
273
- '',
274
- `If this project actually syncs with ${target}, nothing recorded that — say so once:`,
275
- ` add $backend: ${target} to site.yml, and this stops asking.`
276
- )
277
- }
278
-
279
- return {
280
- ok: false,
281
- message: `This project's stored identity belongs to ${bound}, but this command targets ${target}.`,
282
- hint
283
- }
284
135
  }
285
136
 
286
- /**
287
- * The `$backend` of the site project `startDir` sits in — for verbs that are NOT site
288
- * verbs and so never resolve a site directory of their own.
289
- *
290
- * ⛔ **This does NOT feed the origin ladder, deliberately.** `login` writes a
291
- * MACHINE-WIDE session (`~/.uniweb/registry-auth.json`), not a per-project one, so
292
- * letting whichever directory you happen to stand in decide which backend you
293
- * authenticate against would be a silent surprise — the same class of surprise
294
- * `$backend` exists to remove. What this enables is a **notice**: the project says
295
- * where it belongs, so we say so before authenticating somewhere else.
296
- *
297
- * ⭐ **Conservative by construction — it answers only when there is exactly ONE
298
- * candidate.** A workspace of several sites has no single answer, and a confident
299
- * *"did you mean localhost?"* aimed at the wrong one of three sites is worse than
300
- * saying nothing. Ambiguity returns null and the caller stays quiet.
301
- *
302
- * ⚠️ Build-free, like everything else in this file — `login` is a STANDALONE command
303
- * that must work outside a project, where `@uniweb/build` is not installed. That is why
304
- * this cannot reuse `resolveSiteDir` (`commands/deploy.js`), which pulls build in.
305
- *
306
- * @param {string} startDir
307
- * @returns {{ siteDir: string, backend: string }|null}
308
- */
309
- export function findNearbySiteBackend(startDir) {
310
- // 1. Walk UP for the site we are standing in or under. Bounded: a `site.yml` more
311
- // than a few levels above is not "the project you are in", it is a coincidence,
312
- // and at the filesystem root it would be someone else's entirely.
313
- let dir = startDir
314
- for (let i = 0; i < 4; i++) {
315
- if (existsSync(join(dir, 'site.yml'))) {
316
- const { backend } = readSiteIdentity(dir)
317
- return backend ? { siteDir: dir, backend } : null
318
- }
319
- const up = dirname(dir)
320
- if (up === dir) break
321
- dir = up
322
- }
323
-
324
- // 2. Standing AT a project root, the site is one level down — `site/` in the default
325
- // layout, or a lone entry under `sites/`. Two or more candidates is a workspace,
326
- // which is exactly the ambiguity above.
327
- const candidates = []
328
- if (existsSync(join(startDir, 'site', 'site.yml')))
329
- candidates.push(join(startDir, 'site'))
330
- const sitesDir = join(startDir, 'sites')
331
- if (existsSync(sitesDir)) {
332
- let entries = []
333
- try {
334
- entries = readdirSync(sitesDir, { withFileTypes: true })
335
- } catch {
336
- entries = []
337
- }
338
- for (const e of entries) {
339
- if (!e.isDirectory()) continue
340
- const d = join(sitesDir, e.name)
341
- if (existsSync(join(d, 'site.yml'))) candidates.push(d)
342
- }
343
- }
344
- if (candidates.length !== 1) return null
345
- const { backend } = readSiteIdentity(candidates[0])
346
- return backend ? { siteDir: candidates[0], backend } : null
347
- }
137
+ export { DEFAULT_BACKEND_ORIGIN }
@@ -25,7 +25,7 @@ index: home
25
25
  # pages/docs: ../../../docs # Mount docs repo at /docs route
26
26
  # pages/blog: ../../blog-content # Mount blog content at /blog route
27
27
  # layout: ./custom-layout # Custom layout directory
28
- # entities: ./data # Where this site's entities live
28
+ # records: ./data # Where this site's records live
29
29
  #
30
30
  # To give a mounted route a layout or a title, add a local folder for it
31
31
  # (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
@@ -41,14 +41,16 @@ index: home
41
41
  # ─── Structured Content ───────────────────────────────────────────────────────
42
42
  # Three things, and they are separate on purpose:
43
43
  #
44
- # entities/{schema}/ your stored records. The folder names their data schema:
45
- # entities/article/ → @/article (your foundation's)
46
- # entities/std/person/ → @std/person
47
- # records.yml WHAT IS PUBLISHED. Listing an entity here is what makes it
48
- # a record; anything you leave out is a draft. Usually three
49
- # lines:
50
- # - article/*.md
51
- # - person/*.md
44
+ # records/{schema}/ your site's records — every file here is one. The folder
45
+ # names their data schema:
46
+ # records/article/ → @/article (your foundation's)
47
+ # records/std/person/ → @std/person
48
+ # A file whose name starts with `_` is not a record.
49
+ # records.yml OPTIONAL — sorts records into folders a query can slice
50
+ # with `scope:`. Most sites have none:
51
+ # - folder: archive
52
+ # records:
53
+ # - article/2025-*.md
52
54
  # queries.yml HOW CONTENT IS REACHED — named queries over those records:
53
55
  # recent:
54
56
  # schema: '@/article'