@uniweb/build 0.55.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/entity-pool.js +136 -42
- package/src/site/index.js +2 -2
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +28 -149
- package/src/uwx/backfill.js +31 -4
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +29 -17
- package/src/uwx/records-project.js +131 -78
- package/src/uwx/records.js +324 -203
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +34 -15
- package/src/uwx/site.js +80 -90
- package/src/uwx/sync-package.js +23 -15
- package/src/uwx/sync-store.js +351 -0
- package/src/validate-data.js +3 -1
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
* by name. Read-only — never writes. See deploy-config-writer.js for
|
|
6
6
|
* the write path.
|
|
7
7
|
*
|
|
8
|
-
* On-disk shape:
|
|
8
|
+
* On-disk shape (spec: kb/framework/reference/deploy-yml.md):
|
|
9
9
|
* default: production
|
|
10
10
|
* targets:
|
|
11
|
-
* production: { host, ...host-specific config }
|
|
11
|
+
* production: { host, backend?, ...host-specific config }
|
|
12
12
|
* preview: { host, ...host-specific config }
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* deploys:
|
|
14
|
+
* production: { at, host, url?, git?, servicesRequest? }
|
|
15
|
+
* saveDeploys: true
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ `deploys` was `lastDeploy` and `saveDeploys` was `autoSave: off|lastDeploy|full`
|
|
18
|
+
* until 2026-09-20. `full` was "reserved; behaves as lastDeploy" — so the tri-state
|
|
19
|
+
* was already a boolean in practice, and the rename says so. `autoSave` also
|
|
20
|
+
* SOUNDED like it covered the whole file, which it never did.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Nothing a backend minted lives here any more. Identity is `sync.json`'s.
|
|
16
23
|
*
|
|
17
24
|
* The CLI and the build pipeline both call resolveTarget() to turn a
|
|
18
25
|
* loaded document + a (possibly null) --target flag into a concrete
|
|
@@ -26,9 +33,9 @@ import { existsSync } from 'node:fs'
|
|
|
26
33
|
import { join } from 'node:path'
|
|
27
34
|
import yaml from 'js-yaml'
|
|
28
35
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
36
|
+
import { normalizeOrigin } from '../uwx/sync-store.js'
|
|
29
37
|
|
|
30
38
|
const DEFAULT_TARGET_NAME = 'production'
|
|
31
|
-
const VALID_AUTOSAVE = new Set(['off', 'lastDeploy', 'full'])
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* Load and validate deploy.yml from `siteDir`. Returns null when the
|
|
@@ -57,7 +64,7 @@ export async function loadDeployYml(siteDir) {
|
|
|
57
64
|
* targetName: string,
|
|
58
65
|
* host: string,
|
|
59
66
|
* config: object,
|
|
60
|
-
*
|
|
67
|
+
* saveDeploys: boolean,
|
|
61
68
|
* fromFile: boolean,
|
|
62
69
|
* }}
|
|
63
70
|
*/
|
|
@@ -73,7 +80,7 @@ export function resolveTarget(deployYml, requestedTarget) {
|
|
|
73
80
|
targetName: DEFAULT_TARGET_NAME,
|
|
74
81
|
host: 'uniweb',
|
|
75
82
|
config: {},
|
|
76
|
-
|
|
83
|
+
saveDeploys: true,
|
|
77
84
|
fromFile: false,
|
|
78
85
|
}
|
|
79
86
|
}
|
|
@@ -99,11 +106,73 @@ export function resolveTarget(deployYml, requestedTarget) {
|
|
|
99
106
|
targetName: name,
|
|
100
107
|
host,
|
|
101
108
|
config,
|
|
102
|
-
|
|
109
|
+
saveDeploys: deployYml.saveDeploys !== false,
|
|
103
110
|
fromFile: true,
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The target a publish to `origin` records its deploy under.
|
|
116
|
+
*
|
|
117
|
+
* ⭐ **The target follows the backend, never the reverse.** A publish goes to ONE backend,
|
|
118
|
+
* decided before this runs — `--backend`, or the backend the user is logged in to
|
|
119
|
+
* *[Diego, 2026-09-21: "publish should publish to the backend the user logged in to"]*.
|
|
120
|
+
* The default target is only a tie-break, winning when it names that backend too.
|
|
121
|
+
*
|
|
122
|
+
* 1. no deploy.yml → the scaffold: `production`, naming `origin`
|
|
123
|
+
* 2. `host: uniweb` targets naming `origin` → the default one if it is among them,
|
|
124
|
+
* else the first by name. A target with no `backend:` names `defaultBackend` —
|
|
125
|
+
* its documented meaning
|
|
126
|
+
* 3. none → a NEW target, named after the backend's host (`uniweb.app`,
|
|
127
|
+
* `localhost:8080`), `-2`, `-3`… if taken. The caller adds it when it persists.
|
|
128
|
+
*
|
|
129
|
+
* ⛔ Until 2026-09-21 publish always resolved the DEFAULT target, whatever backend it
|
|
130
|
+
* published to: a publish to a second backend recorded over the default target's deploy,
|
|
131
|
+
* and compared its service request against that target's fingerprint.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} deployYml - from loadDeployYml
|
|
134
|
+
* @param {string} origin - the backend being published to
|
|
135
|
+
* @param {{ defaultBackend?: string }} [opts]
|
|
136
|
+
* @returns {{ targetName: string, host: 'uniweb', config: object, saveDeploys: boolean,
|
|
137
|
+
* fromFile: boolean }} `fromFile: false` means the file has no such target yet
|
|
138
|
+
*/
|
|
139
|
+
export function resolvePublishTarget(deployYml, origin, { defaultBackend } = {}) {
|
|
140
|
+
const key = normalizeOrigin(origin)
|
|
141
|
+
if (!deployYml) {
|
|
142
|
+
return {
|
|
143
|
+
targetName: DEFAULT_TARGET_NAME,
|
|
144
|
+
host: 'uniweb',
|
|
145
|
+
config: { backend: key },
|
|
146
|
+
saveDeploys: true,
|
|
147
|
+
fromFile: false,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const saveDeploys = deployYml.saveDeploys !== false
|
|
151
|
+
const targets = deployYml.targets || {}
|
|
152
|
+
const naming = Object.keys(targets)
|
|
153
|
+
.filter((name) => {
|
|
154
|
+
const t = targets[name]
|
|
155
|
+
return t && t.host === 'uniweb' && normalizeOrigin(t.backend || defaultBackend) === key
|
|
156
|
+
})
|
|
157
|
+
.sort()
|
|
158
|
+
if (naming.length) {
|
|
159
|
+
const targetName = naming.includes(deployYml.default) ? deployYml.default : naming[0]
|
|
160
|
+
const { host, ...config } = targets[targetName]
|
|
161
|
+
return { targetName, host, config, saveDeploys, fromFile: true }
|
|
162
|
+
}
|
|
163
|
+
let base = 'uniweb'
|
|
164
|
+
try {
|
|
165
|
+
base = new URL(key).host || base
|
|
166
|
+
} catch {
|
|
167
|
+
/* keep the fallback */
|
|
168
|
+
}
|
|
169
|
+
let targetName = base
|
|
170
|
+
for (let i = 2; Object.prototype.hasOwnProperty.call(targets, targetName); i++) {
|
|
171
|
+
targetName = `${base}-${i}`
|
|
172
|
+
}
|
|
173
|
+
return { targetName, host: 'uniweb', config: { backend: key }, saveDeploys, fromFile: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
107
176
|
function validate(doc, path) {
|
|
108
177
|
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
109
178
|
throw new Error(`${path}: top level must be a map.`)
|
|
@@ -111,39 +180,19 @@ function validate(doc, path) {
|
|
|
111
180
|
if (doc.targets !== undefined && (typeof doc.targets !== 'object' || Array.isArray(doc.targets))) {
|
|
112
181
|
throw new Error(`${path}: \`targets\` must be a map.`)
|
|
113
182
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// both be coherent
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// *(Diego, 2026-08-12: multiple deploys are not expected in general, and are not
|
|
122
|
-
// allowed for Uniweb Cloud specifically — the only host that reads `$uuid` at
|
|
123
|
-
// all. Every other host is stateless from the CLI's side, so any number of those
|
|
124
|
-
// targets is fine.)*
|
|
125
|
-
if (doc.targets && typeof doc.targets === 'object' && !Array.isArray(doc.targets)) {
|
|
126
|
-
const uniwebTargets = Object.entries(doc.targets)
|
|
127
|
-
.filter(([, t]) => t && typeof t === 'object' && t.host === 'uniweb')
|
|
128
|
-
.map(([name]) => name)
|
|
129
|
-
if (uniwebTargets.length > 1) {
|
|
130
|
-
throw new Error(
|
|
131
|
-
`${path}: only one target may use \`host: uniweb\` — found ${uniwebTargets.length} (${uniwebTargets.join(', ')}). ` +
|
|
132
|
-
'A site has a single Uniweb identity (site.yml::$uuid), so a second Uniweb target cannot describe a different site. ' +
|
|
133
|
-
'Keep one, or point the others at a third-party host.'
|
|
134
|
-
)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (doc.autoSave !== undefined && !VALID_AUTOSAVE.has(doc.autoSave)) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`${path}: \`autoSave\` must be one of: ${[...VALID_AUTOSAVE].join(', ')}.`
|
|
140
|
-
)
|
|
183
|
+
// ⭐ SEVERAL `host: uniweb` TARGETS ARE LEGAL (2026-09-20). This rejected a second one
|
|
184
|
+
// because a site had a single identity, `site.yml::$uuid`, so two Uniweb targets
|
|
185
|
+
// could not both be coherent. Identity is keyed by backend origin in `sync.json`
|
|
186
|
+
// now: two targets naming DIFFERENT backends are two sites, and two naming the SAME
|
|
187
|
+
// backend are one site shipped twice. The reason is gone, so the rejection is too.
|
|
188
|
+
if (doc.saveDeploys !== undefined && typeof doc.saveDeploys !== 'boolean') {
|
|
189
|
+
throw new Error(`${path}: \`saveDeploys\` must be true or false.`)
|
|
141
190
|
}
|
|
142
191
|
if (doc.default !== undefined && typeof doc.default !== 'string') {
|
|
143
192
|
throw new Error(`${path}: \`default\` must be a string.`)
|
|
144
193
|
}
|
|
145
|
-
if (doc.
|
|
146
|
-
throw new Error(`${path}: \`
|
|
194
|
+
if (doc.deploys !== undefined && (typeof doc.deploys !== 'object' || Array.isArray(doc.deploys))) {
|
|
195
|
+
throw new Error(`${path}: \`deploys\` must be a map.`)
|
|
147
196
|
}
|
|
148
197
|
return doc
|
|
149
198
|
}
|
package/src/site/entity-pool.js
CHANGED
|
@@ -1,49 +1,130 @@
|
|
|
1
|
-
// A site's
|
|
1
|
+
// A site's RECORDS on disk — every file in `records/`, and the model each one has.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `records.
|
|
3
|
+
// ⭐ PLACING A FILE IN `records/` IS WHAT MAKES IT A RECORD (ruled 2026-09-21
|
|
4
|
+
// [Diego]). The directory is the file side of the site's records folder: the
|
|
5
|
+
// backend's folder holds references to entities, and putting a ref in it is what
|
|
6
|
+
// makes one a record — putting a file in this directory is the same act. Nothing
|
|
7
|
+
// else is needed, and nothing lists it. (`records.yml` only organizes records into
|
|
8
|
+
// sub-folders; see `records-config.js`.) A file whose name starts with `_` or `.`
|
|
9
|
+
// is not read, so it is not a record.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// ⛔ A RECORD IS NOT "PUBLISHED" BY BEING HERE. `push` sends the site's records to a
|
|
12
|
+
// backend, and they are served once the SITE is published — publishing the site
|
|
13
|
+
// publishes its folder and what is in it. Nothing about a single record decides that.
|
|
14
|
+
//
|
|
15
|
+
// ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. `collections/<name>/` used to
|
|
16
|
+
// mean three things at once — these files are entities, their schema is
|
|
17
|
+
// `@/<name>`, and they are grouped as `<name>` for placement. A schema folder here
|
|
18
|
+
// declares only the model; grouping is `records.yml`'s.
|
|
19
|
+
//
|
|
20
|
+
// ⇒ So nothing here reads a query or a folder. A site's records are a fact about
|
|
21
|
+
// the filesystem — plus `site.yml::paths.records`, which says where they live.
|
|
12
22
|
//
|
|
13
23
|
// ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
|
|
14
24
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
25
|
+
// records/person/ada.md → @/person (the foundation's own)
|
|
26
|
+
// records/std/person/ada.md → @std/person (the shared standard set)
|
|
27
|
+
// records/acme/project/x.md → @acme/project (an org's)
|
|
18
28
|
//
|
|
19
29
|
// matching `build/src/resolve-data-schema.js`, which is the only grammar there
|
|
20
30
|
// is: a bare directory name can mean `@/<name>` and nothing else.
|
|
21
31
|
//
|
|
22
|
-
// ⛔ BARE, NOT `
|
|
32
|
+
// ⛔ BARE, NOT `records/@std/`. Measured: `@` is a reserved indicator in YAML
|
|
23
33
|
// 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
|
|
24
34
|
// `bad indentation of a sequence entry`, which names neither the cause nor the
|
|
25
|
-
// fix. The `@` form would force quotes on
|
|
26
|
-
//
|
|
35
|
+
// fix. The `@` form would force quotes on every pattern in `records.yml` that
|
|
36
|
+
// names a scoped schema.
|
|
27
37
|
//
|
|
28
38
|
// ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
|
|
29
39
|
// total: it is the FILE's depth that decides, so one path answers the question
|
|
30
|
-
// with nothing to classify and no ambiguous case to resolve.
|
|
40
|
+
// with nothing to classify and no ambiguous case to resolve. Organization is a
|
|
41
|
+
// `folder:` in `records.yml`, never a directory.
|
|
31
42
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// `records.yml` replaces on-disk nesting outright, so the two lanes stop
|
|
37
|
-
// disagreeing rather than being taught to agree.
|
|
43
|
+
// ⛔ THE DIRECTORY WAS `entities/` UNTIL 2026-09-21, and `site.yml::paths.entities`
|
|
44
|
+
// the key that moved it. Both are refused by name rather than read (no alias —
|
|
45
|
+
// there is no population to carry): a leftover `entities/` beside `records/` would
|
|
46
|
+
// otherwise hold records nothing reads, silently.
|
|
38
47
|
|
|
39
48
|
import { readdir } from 'node:fs/promises'
|
|
40
|
-
import { existsSync } from 'node:fs'
|
|
41
|
-
import { join, extname, basename } from 'node:path'
|
|
49
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
50
|
+
import { join, extname, basename, resolve } from 'node:path'
|
|
51
|
+
import yaml from 'js-yaml'
|
|
52
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
53
|
+
|
|
54
|
+
/** Where a site's records live, relative to its root, unless `site.yml::paths.records` moves them. */
|
|
55
|
+
export const RECORDS_DIR = 'records'
|
|
56
|
+
|
|
57
|
+
/** The directory's name until 2026-09-21. Read only to refuse it. */
|
|
58
|
+
const RETIRED_DIR = 'entities'
|
|
59
|
+
|
|
60
|
+
function isDirectory(path) {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(path).isDirectory()
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
function readSitePaths(siteRoot) {
|
|
69
|
+
const file = join(siteRoot, 'site.yml')
|
|
70
|
+
if (!existsSync(file)) return {}
|
|
71
|
+
try {
|
|
72
|
+
const doc = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)
|
|
73
|
+
return doc && typeof doc.paths === 'object' && doc.paths !== null ? doc.paths : {}
|
|
74
|
+
} catch {
|
|
75
|
+
// An unreadable site.yml is reported by whoever reads the site; this only
|
|
76
|
+
// needs the one key, and the default is the right answer without it.
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
45
80
|
|
|
46
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Refuse the retired `entities/` directory when it is not the one the site reads.
|
|
83
|
+
*
|
|
84
|
+
* ⛔ A RENAMED DIRECTORY FAILS SILENTLY — its files are simply not read, and a site
|
|
85
|
+
* with no records looks exactly like a site whose records moved. So its presence
|
|
86
|
+
* stops the build, the push and every other reader, naming the new place.
|
|
87
|
+
*/
|
|
88
|
+
export function refuseRetiredRecordsDir(siteRoot, rel) {
|
|
89
|
+
const retired = resolve(siteRoot, RETIRED_DIR)
|
|
90
|
+
if (resolve(siteRoot, rel) === retired) return
|
|
91
|
+
if (!isDirectory(retired)) return
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[uniweb] ${RETIRED_DIR}/ is not read — a site's records live in \`${rel}/\` now. ` +
|
|
94
|
+
`Rename the directory (\`git mv ${RETIRED_DIR} ${rel}\`) and every file in it is a record, ` +
|
|
95
|
+
`as before; records.yml no longer lists them.`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where a site's records live — `site.yml::paths.records`, else `records/`.
|
|
101
|
+
*
|
|
102
|
+
* ⭐ THE ONE RESOLVER, and every lane asks it: the build, the push, the pull and
|
|
103
|
+
* the CLI. ⛔ Until 2026-09-21 only the build honoured the key — then called
|
|
104
|
+
* `paths.entities` — while push read the default, so a site that moved its records
|
|
105
|
+
* built fine and then failed to push, with an error naming a directory it did not
|
|
106
|
+
* use (measured).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} siteRoot
|
|
109
|
+
* @param {object} [paths] - `site.yml::paths`, when the caller has already read it;
|
|
110
|
+
* otherwise `site.yml` is read here.
|
|
111
|
+
* @returns {{ rel: string, abs: string }} `rel` as written (for messages), `abs`
|
|
112
|
+
* resolved against the site root — an absolute `paths.records` is honoured.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveRecordsDir(siteRoot, paths) {
|
|
115
|
+
const p = paths === undefined ? readSitePaths(siteRoot) : paths || {}
|
|
116
|
+
if (p.entities !== undefined) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[uniweb] site.yml: \`paths.entities\` is now \`paths.records\` — the directory a site's ` +
|
|
119
|
+
`records live in. Rename the key.`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
const rel = typeof p.records === 'string' && p.records.trim() ? p.records.trim().replace(/\/+$/, '') : RECORDS_DIR
|
|
123
|
+
refuseRetiredRecordsDir(siteRoot, rel)
|
|
124
|
+
return { rel, abs: resolve(siteRoot, rel) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Source extensions a record file may have. Mirrors the sync-lane reader. */
|
|
47
128
|
export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
|
|
48
129
|
|
|
49
130
|
const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
@@ -51,7 +132,7 @@ const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
|
|
|
51
132
|
/**
|
|
52
133
|
* The schema ref a pool path names.
|
|
53
134
|
*
|
|
54
|
-
* @param {string[]} dirs - the directory segments below `
|
|
135
|
+
* @param {string[]} dirs - the directory segments below `records/`
|
|
55
136
|
* @returns {string|null} the ref, or null when the depth names no schema
|
|
56
137
|
*/
|
|
57
138
|
export function schemaForPoolDirs(dirs) {
|
|
@@ -61,7 +142,7 @@ export function schemaForPoolDirs(dirs) {
|
|
|
61
142
|
}
|
|
62
143
|
|
|
63
144
|
/**
|
|
64
|
-
* Where a schema's
|
|
145
|
+
* Where a schema's records live — the inverse of `schemaForPoolDirs`.
|
|
65
146
|
*
|
|
66
147
|
* ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
|
|
67
148
|
* exists at all: the reader derives a model from a path and the pull side derives
|
|
@@ -70,7 +151,7 @@ export function schemaForPoolDirs(dirs) {
|
|
|
70
151
|
* `deferredFromSchema` — a deriver and its recognizer must not be two copies.
|
|
71
152
|
*
|
|
72
153
|
* @param {string} schema - a ref: `@/name` or `@org/name`
|
|
73
|
-
* @returns {string[]|null} the directory segments below `
|
|
154
|
+
* @returns {string[]|null} the directory segments below `records/`, or null for
|
|
74
155
|
* a ref this layout cannot express
|
|
75
156
|
*/
|
|
76
157
|
export function poolDirsForSchema(schema) {
|
|
@@ -85,7 +166,7 @@ export function poolDirsForSchema(schema) {
|
|
|
85
166
|
/**
|
|
86
167
|
* Both readings of a 2-segment pool path, for an error that has to name them.
|
|
87
168
|
*
|
|
88
|
-
* ⚠️ A reader who mistakes `
|
|
169
|
+
* ⚠️ A reader who mistakes `records/person/2024/ada.md` for "the `person`
|
|
89
170
|
* schema, organised by year" needs to be told what the build actually did with
|
|
90
171
|
* it, not merely that something did not resolve. The wrong reading is the
|
|
91
172
|
* plausible one, so the message carries both.
|
|
@@ -98,33 +179,46 @@ export function poolPathReadings(dirs) {
|
|
|
98
179
|
}
|
|
99
180
|
|
|
100
181
|
/**
|
|
101
|
-
* Read a site's
|
|
182
|
+
* Read a site's records.
|
|
102
183
|
*
|
|
103
|
-
* Returns
|
|
184
|
+
* Returns them in a stable, path-sorted order — the wire's package digest
|
|
104
185
|
* depends on it — each carrying the model its path declares.
|
|
105
186
|
*
|
|
106
187
|
* ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
|
|
107
188
|
* can actually see is a question for whoever holds the foundation's built schema
|
|
108
189
|
* map, and only that caller can raise the error §4 of the model asks for. This
|
|
109
|
-
* reports the
|
|
110
|
-
* deep — because those are answerable from the filesystem alone.
|
|
190
|
+
* reports the directory's SHAPE — a file with no schema above it, a file nested
|
|
191
|
+
* too deep — because those are answerable from the filesystem alone.
|
|
111
192
|
*
|
|
112
193
|
* @param {string} siteRoot
|
|
113
194
|
* @param {object} [opts]
|
|
114
|
-
* @param {string} [opts.dir] -
|
|
195
|
+
* @param {string} [opts.dir] - the records directory, site-root-relative or
|
|
196
|
+
* absolute, when the caller already resolved it; otherwise `resolveRecordsDir`
|
|
197
|
+
* reads `site.yml::paths.records`.
|
|
115
198
|
* @returns {Promise<{
|
|
116
199
|
* entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
|
|
117
200
|
* errors: string[],
|
|
118
201
|
* exists: boolean,
|
|
202
|
+
* dir: string,
|
|
119
203
|
* }>}
|
|
120
|
-
* `id` is the
|
|
204
|
+
* `id` is the record's path under `records/` without its extension — unique
|
|
121
205
|
* by construction, stable across pushes, and derivable identically on both
|
|
122
|
-
* sides without either lane holding the other's ids.
|
|
206
|
+
* sides without either lane holding the other's ids. `exists` is whether the
|
|
207
|
+
* directory is there at all, which is not the same as holding nothing: a push
|
|
208
|
+
* leaves the backend's folder alone for a site with no records directory.
|
|
123
209
|
*/
|
|
124
210
|
export async function readEntityPool(siteRoot, opts = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
let rel
|
|
212
|
+
if (opts.dir) {
|
|
213
|
+
rel = String(opts.dir).replace(/\/+$/, '')
|
|
214
|
+
refuseRetiredRecordsDir(siteRoot, rel)
|
|
215
|
+
} else {
|
|
216
|
+
rel = resolveRecordsDir(siteRoot).rel
|
|
217
|
+
}
|
|
218
|
+
// ⛔ `resolve`, not `join`: an absolute `paths.records` is supported, and
|
|
219
|
+
// `join(siteRoot, '/abs')` is a path under the site root that does not exist.
|
|
220
|
+
const base = resolve(siteRoot, rel)
|
|
221
|
+
if (!isDirectory(base)) return { entities: [], errors: [], exists: false, dir: rel }
|
|
128
222
|
|
|
129
223
|
const entities = []
|
|
130
224
|
const errors = []
|
|
@@ -188,7 +282,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
|
|
|
188
282
|
}
|
|
189
283
|
|
|
190
284
|
await walk(base, [])
|
|
191
|
-
return { entities, errors, exists: true }
|
|
285
|
+
return { entities, errors, exists: true, dir: rel }
|
|
192
286
|
}
|
|
193
287
|
|
|
194
288
|
/**
|
package/src/site/index.js
CHANGED
|
@@ -50,5 +50,5 @@ export {
|
|
|
50
50
|
applyPostProcessing,
|
|
51
51
|
mergeDataIntoContent
|
|
52
52
|
} from './data-fetcher.js'
|
|
53
|
-
export { loadDeployYml, resolveTarget } from './deploy-config.js'
|
|
54
|
-
export { recordLastDeploy, recordTarget } from './deploy-config-writer.js'
|
|
53
|
+
export { loadDeployYml, resolveTarget, resolvePublishTarget } from './deploy-config.js'
|
|
54
|
+
export { recordLastDeploy, recordTarget, forgetDeploys, forgetDeployYml } from './deploy-config-writer.js'
|
package/src/site/plugin.js
CHANGED
|
@@ -50,7 +50,7 @@ import { collectSiteContent, mountEntriesOf } from './content-collector.js'
|
|
|
50
50
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
51
51
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
52
52
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
53
|
-
import {
|
|
53
|
+
import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
|
|
54
54
|
import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
|
|
55
55
|
import { shouldSplitContent } from './split-content.js'
|
|
56
56
|
import { FONT_LINKS_MARKER } from './head-markers.js'
|
|
@@ -133,7 +133,7 @@ export function shouldPrefetchInDev(cfg) {
|
|
|
133
133
|
* query list just collected, reload. `queries.yml` declares queries, and so does
|
|
134
134
|
* `site.yml` (`queries:`), beside the rest of the site's configuration.
|
|
135
135
|
* - `'records'` — re-materialize the query files, reload: `records.yml` decides
|
|
136
|
-
* which
|
|
136
|
+
* which folder each record sits in, which a query's `scope:` reads.
|
|
137
137
|
* - `'content'` — re-collect the site, reload.
|
|
138
138
|
*
|
|
139
139
|
* ⛔ Until 2026-09-14 nothing watched `records.yml` or `queries.yml`, a `site.yml`
|
|
@@ -665,7 +665,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
665
665
|
let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
|
|
666
666
|
let resolvedMountPaths = [] // Absolute dirs mounted under pages/ via site.yml paths:
|
|
667
667
|
let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
|
|
668
|
-
let
|
|
668
|
+
let resolvedRecordsDir = null // site.yml `paths.records` as written, via `resolveRecordsDir`
|
|
669
669
|
let headHtml = '' // Contents of site/head.html for injection
|
|
670
670
|
let basePath = '/' // Vite's config.base, always has trailing slash
|
|
671
671
|
|
|
@@ -725,8 +725,11 @@ export function siteContentPlugin(options = {}) {
|
|
|
725
725
|
*/
|
|
726
726
|
async function materializeQueries(queries, config) {
|
|
727
727
|
if (!queries) return
|
|
728
|
-
const byQuery = await processQueries(resolvedSitePath, queries,
|
|
728
|
+
const byQuery = await processQueries(resolvedSitePath, queries, resolvedRecordsDir, basePath, {
|
|
729
729
|
locale: resolveDefaultLocale(config ?? {}) ?? null,
|
|
730
|
+
// ⭐ A draft is previewable here, as a hidden page is — `pnpm dev` is where the
|
|
731
|
+
// author looks at it. A production build leaves it out (`buildStart`).
|
|
732
|
+
includeDrafts: !isProduction,
|
|
730
733
|
})
|
|
731
734
|
await writeQueryFiles(resolvedSitePath, byQuery, queries)
|
|
732
735
|
}
|
|
@@ -911,7 +914,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
911
914
|
resolvedLayoutPath = paths.layout
|
|
912
915
|
? resolve(resolvedSitePath, paths.layout)
|
|
913
916
|
: resolve(resolvedSitePath, 'layout')
|
|
914
|
-
|
|
917
|
+
resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
|
|
915
918
|
resolvedMountPaths = mountEntriesOf(paths).map(([, rel]) => resolve(resolvedSitePath, rel))
|
|
916
919
|
|
|
917
920
|
if (queriesConfig) {
|
|
@@ -934,7 +937,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
934
937
|
resolvedLayoutPath = paths.layout
|
|
935
938
|
? resolve(resolvedSitePath, paths.layout)
|
|
936
939
|
: resolve(resolvedSitePath, 'layout')
|
|
937
|
-
|
|
940
|
+
resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
|
|
938
941
|
}
|
|
939
942
|
},
|
|
940
943
|
|
|
@@ -954,7 +957,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
954
957
|
// In production, do it here
|
|
955
958
|
if (isProduction && siteContent.config?.queries) {
|
|
956
959
|
console.log('[site-content] Materializing queries...')
|
|
957
|
-
const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries,
|
|
960
|
+
const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedRecordsDir, basePath)
|
|
958
961
|
await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
|
|
959
962
|
}
|
|
960
963
|
|
|
@@ -1113,11 +1116,11 @@ export function siteContentPlugin(options = {}) {
|
|
|
1113
1116
|
// ⭐ WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
|
|
1114
1117
|
// `config.path` for each declaration and watch each one, so a schema
|
|
1115
1118
|
// folder no query had mentioned yet — a new one, mid-session — was
|
|
1116
|
-
// watched by nothing and its records never rebuilt. `
|
|
1119
|
+
// watched by nothing and its records never rebuilt. `records/` is one
|
|
1117
1120
|
// recursive root and covers every schema, present and future.
|
|
1118
1121
|
{
|
|
1119
1122
|
const contentPaths = new Set([
|
|
1120
|
-
resolve(resolvedSitePath,
|
|
1123
|
+
resolve(resolvedSitePath, resolvedRecordsDir || RECORDS_DIR)
|
|
1121
1124
|
])
|
|
1122
1125
|
|
|
1123
1126
|
for (const contentPath of contentPaths) {
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
// rather keep one file. Precedence (per-query, per-key): queries.yml > site.yml.
|
|
17
17
|
//
|
|
18
18
|
// ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
|
|
19
|
-
// `
|
|
20
|
-
//
|
|
21
|
-
// LAST of those only.
|
|
19
|
+
// `records/{schema}/` holds the site's records (placing a file there is what makes
|
|
20
|
+
// it one), `records.yml` sorts them into folders, and a query asks for a set. This
|
|
21
|
+
// file resolves the LAST of those only.
|
|
22
22
|
//
|
|
23
23
|
// ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
|
|
24
24
|
// deprecation path — the model's §5 ruling, and there is nothing outside this
|
|
@@ -85,7 +85,7 @@ export function defaultSchema(name) {
|
|
|
85
85
|
// address nothing local can derive.
|
|
86
86
|
function normalizeQueryDecl(name, decl) {
|
|
87
87
|
// ⭐ THE STRING SHORTHAND NAMES THE SCHEMA, because that is what a file-based
|
|
88
|
-
// query actually needs — `
|
|
88
|
+
// query actually needs — `records/{schema}/` supplies the records, so there is
|
|
89
89
|
// no directory left to name. (It named a PATH while the pool was
|
|
90
90
|
// `collections/<name>/` and the same directory answered both questions.)
|
|
91
91
|
if (typeof decl === 'string') return { name, schema: decl }
|
|
@@ -165,7 +165,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
165
165
|
if (decl.path && decl.url === undefined) {
|
|
166
166
|
console.warn(
|
|
167
167
|
`[uniweb] query "${decl.name}": \`path: ${decl.path}\` is ignored. A query names a ` +
|
|
168
|
-
`\`schema:\` and \`
|
|
168
|
+
`\`schema:\` and \`records/{schema}/\` supplies its records — there is no directory ` +
|
|
169
169
|
`for it to name. Move the files under the schema folder instead.`
|
|
170
170
|
)
|
|
171
171
|
}
|
|
@@ -234,6 +234,16 @@ export function refuseQueryDeclaration(decl) {
|
|
|
234
234
|
}
|
|
235
235
|
refuseQueryRoute(decl, where)
|
|
236
236
|
refuseLimit(decl.limit, where)
|
|
237
|
+
// ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
|
|
238
|
+
// backend while a query owned its records; every record in `records/` is pushed
|
|
239
|
+
// now, whatever reads it, so an ignored `sync: false` would push exactly what the
|
|
240
|
+
// author meant to hold back. Refused, naming what does that now.
|
|
241
|
+
if (decl.sync !== undefined) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`[uniweb] ${where}: \`sync:\` is retired — a query does not own its records. Every file in ` +
|
|
244
|
+
`\`records/\` is a record and is pushed; a file whose name starts with \`_\` is not a record.`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
237
247
|
const external = decl.url !== undefined
|
|
238
248
|
if (!external) {
|
|
239
249
|
for (const key of ['method', 'body', 'transform', 'record']) {
|