uniweb 0.83.0 → 0.85.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 +5 -5
- package/partials/agents.md +99 -51
- package/src/backend/service-request.js +146 -173
- package/src/backend/site-sync.js +154 -47
- package/src/commands/doctor.js +32 -36
- package/src/commands/publish.js +25 -155
- package/src/commands/pull.js +13 -2
- package/src/commands/push.js +6 -1
- package/src/framework-index.json +7 -7
|
@@ -1,68 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The requests a push or publish carries — what the owner asks for, and whether it
|
|
3
|
+
* is new.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Two of them, kept by two mechanisms:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
|
|
8
|
+
* 2026-10-06]*. A push STATES them — the file's, and off for each held one it no
|
|
9
|
+
* longer lists — and the backend decides per service from the versions sent
|
|
10
|
+
* (`statedServices`, `@uniweb/build/uwx`; spec:
|
|
11
|
+
* kb/framework/reference/site-services-request.md). All this module does for them
|
|
12
|
+
* is say what the file asks that will not be sent (`announceServices`).
|
|
13
|
+
* - **The language selection** — `site.yml::publishLanguages` — told apart from the
|
|
14
|
+
* status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
* is
|
|
14
|
-
* `uuidless_records_anchor_by_natural_key_and_keep_the_stored_uuid`, written
|
|
15
|
-
* against this emitter's shape). So a re-send is not a harmless echo — it
|
|
16
|
-
* **overwrites the stored request**, including a decision the owner made in the
|
|
17
|
-
* app, which is where the consent workflow's publish happens.
|
|
16
|
+
* ⭐ The model both follow — *"the services in `site.yml` are a request, never a
|
|
17
|
+
* tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* so a stale re-send of the other names overwrote rows nothing read. That stopped
|
|
25
|
-
* being true the same day, and `api` was never inert — an `enabled: false` on it
|
|
26
|
-
* with a live plan schedules a paid service to end.
|
|
27
|
-
*
|
|
28
|
-
* ## What "changed" is measured against
|
|
29
|
-
*
|
|
30
|
-
* The last block we are known to have sent, recorded in **`deploy.yml`** — a
|
|
31
|
-
* COMMITTED project file that travels with a clone.
|
|
32
|
-
*
|
|
33
|
-
* ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
|
|
34
|
-
* wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
|
|
35
|
-
* has no base at all — and this workflow is multi-machine by construction (consent
|
|
36
|
-
* in a browser, publish from the app). A base that vanishes turns this gate into
|
|
37
|
-
* either the original defect or a silently dropped request.
|
|
38
|
-
*
|
|
39
|
-
* ## ⛔ A HASH, never the block
|
|
40
|
-
*
|
|
41
|
-
* `$secrets` names every secret the site has (values are the `#ref` marker, never
|
|
42
|
-
* secret material — the old wording here said otherwise), and `$services[].config` is opaque and
|
|
43
|
-
* per-service — anything may be in it. `deploy.yml` is committed, so recording
|
|
44
|
-
* either verbatim would write them into git. Equality is all this gate needs;
|
|
45
|
-
* *what* differs is a question for the backend's own copy of the request, not for
|
|
46
|
-
* a mirror of our own.
|
|
19
|
+
* ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
|
|
20
|
+
* nobody edits, so a CLI user could ask for nothing; this module then compared them by
|
|
21
|
+
* a fingerprint in `deploy.yml`, which could not tell who moved; and until 2026-10-07
|
|
22
|
+
* it read the site's services before every push and settled each against a record in
|
|
23
|
+
* `sync.json` (`settleServices`), because the backend replaced the list it was sent.*
|
|
47
24
|
*
|
|
48
25
|
* @module
|
|
49
26
|
*/
|
|
50
27
|
|
|
51
28
|
import { createHash } from 'node:crypto'
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { readServicesRequest } from '@uniweb/build/uwx'
|
|
52
32
|
|
|
53
33
|
/**
|
|
54
|
-
* A stable fingerprint of one declared
|
|
34
|
+
* A stable fingerprint of one declared value, or `null` when the key is absent.
|
|
55
35
|
*
|
|
56
|
-
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so:
|
|
57
|
-
*
|
|
58
|
-
* function only has to preserve that distinction — the destructive difference
|
|
59
|
-
* between them is the backend's.
|
|
36
|
+
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
|
|
37
|
+
* language selection, no key means "every declared language" and `[]` means none.
|
|
60
38
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
39
|
+
* List entries are ordered by their serialized form and object keys sorted, so
|
|
40
|
+
* reordering them in the file is not a change.
|
|
63
41
|
*
|
|
64
|
-
* @param {*} declared
|
|
65
|
-
* `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
|
|
42
|
+
* @param {*} declared
|
|
66
43
|
* @returns {string|null} 16 hex chars, or null when undeclared
|
|
67
44
|
*/
|
|
68
45
|
export function fingerprintDeclaration(declared) {
|
|
@@ -70,13 +47,10 @@ export function fingerprintDeclaration(declared) {
|
|
|
70
47
|
const canonical = Array.isArray(declared)
|
|
71
48
|
? declared.map(stableString).sort()
|
|
72
49
|
: [stableString(declared)]
|
|
73
|
-
return createHash('sha256')
|
|
74
|
-
.update(JSON.stringify(canonical))
|
|
75
|
-
.digest('hex')
|
|
76
|
-
.slice(0, 16)
|
|
50
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
|
|
77
51
|
}
|
|
78
52
|
|
|
79
|
-
/** Absent, or an empty list — a stored
|
|
53
|
+
/** Absent, or an empty list — a stored value that asks for nothing. */
|
|
80
54
|
function isNothing(value) {
|
|
81
55
|
return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
|
|
82
56
|
}
|
|
@@ -91,125 +65,22 @@ function stableString(value) {
|
|
|
91
65
|
}
|
|
92
66
|
|
|
93
67
|
/**
|
|
94
|
-
*
|
|
95
|
-
* them. Keys are omitted rather than set to null, so an undeclared block leaves no
|
|
96
|
-
* trace in the file.
|
|
68
|
+
* A two-way request field, reconciled against what the site has stored.
|
|
97
69
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*/
|
|
101
|
-
export function fingerprintRequest(siteYml, provisioned = {}) {
|
|
102
|
-
const out = {}
|
|
103
|
-
// ⭐ Two sources, on purpose. The PROVISIONED rows come from `sync.json` for the
|
|
104
|
-
// backend being published to (they were `site.yml::$services` / `$secrets` until
|
|
105
|
-
// 2026-09-20). The language selection below is still authored, so still site.yml.
|
|
106
|
-
const services = fingerprintDeclaration(provisioned?.services)
|
|
107
|
-
if (services) out.servicesRequest = services
|
|
108
|
-
const secrets = fingerprintDeclaration(provisioned?.secrets)
|
|
109
|
-
if (secrets) out.secretsRequest = secrets
|
|
110
|
-
// ⭐ The language selection is a request too, and it is the one that moves a
|
|
111
|
-
// PRICE — the line is billed on how many languages go out. Banking it is what
|
|
112
|
-
// lets a later edit read as "the owner asked for another language" rather than
|
|
113
|
-
// as a value that was always there.
|
|
114
|
-
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
115
|
-
if (langs) out.publishLanguagesRequest = langs
|
|
116
|
-
return out
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Should this push DECLARE the request blocks?
|
|
121
|
-
*
|
|
122
|
-
* ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
|
|
123
|
-
* that decides it:
|
|
124
|
-
*
|
|
125
|
-
* 1. It is the behaviour every CLI has had, so nothing regresses.
|
|
126
|
-
* 2. A project may legitimately have no `deploy.yml` (never published, or
|
|
127
|
-
* `autoSave: off`), and that is not evidence about the request.
|
|
128
|
-
* 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
|
|
129
|
-
* failure directions, sending an unchanged block writes back what is usually
|
|
130
|
-
* already there, while withholding a changed one leaves an owner's edit with
|
|
131
|
-
* no effect and nothing said. The loud failure is the better one.
|
|
132
|
-
*
|
|
133
|
-
* @param {object} siteYml - the parsed site.yml
|
|
134
|
-
* @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
|
|
135
|
-
* @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
|
|
136
|
-
*/
|
|
137
|
-
export function decideDeclaration(siteYml, lastDeploy, provisioned = {}) {
|
|
138
|
-
const now = fingerprintRequest(siteYml, provisioned)
|
|
139
|
-
if (!now.servicesRequest && !now.secretsRequest) {
|
|
140
|
-
// Nothing in the file to send. The gate is moot; say so rather than
|
|
141
|
-
// reporting "unchanged", which would imply a comparison happened.
|
|
142
|
-
return { declare: true, reason: 'undeclared' }
|
|
143
|
-
}
|
|
144
|
-
if (!lastDeploy || typeof lastDeploy !== 'object') {
|
|
145
|
-
return { declare: true, reason: 'no-record' }
|
|
146
|
-
}
|
|
147
|
-
const same =
|
|
148
|
-
now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
|
|
149
|
-
now.secretsRequest === (lastDeploy.secretsRequest || undefined)
|
|
150
|
-
return same
|
|
151
|
-
? { declare: false, reason: 'unchanged' }
|
|
152
|
-
: { declare: true, reason: 'changed' }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* The four-way reconcile, once the backend's own copy of the request is in hand.
|
|
157
|
-
*
|
|
158
|
-
* ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
|
|
159
|
-
* compares the file to our MEMORY of what we last sent, which leaves a window: a
|
|
160
|
-
* request changed in the app between a `uniweb pull` and the next publish reads as
|
|
161
|
-
* unchanged-from-nothing. Comparing to the backend's own rows closes it, because
|
|
162
|
-
* the base stops being something we have to remember correctly.
|
|
163
|
-
*
|
|
164
|
-
* ⛔ It still needs the banked fingerprint. Local and remote differing says the two
|
|
165
|
-
* disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
|
|
166
|
-
* the difference between "the app decided, adopt it" and "you edited, send it".
|
|
167
|
-
*
|
|
168
|
-
* ## The four outcomes
|
|
169
|
-
*
|
|
170
|
-
* | local | remote | |
|
|
171
|
-
* |---|---|---|
|
|
172
|
-
* | unchanged | unchanged | `none` — nobody asked anything |
|
|
173
|
-
* | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
|
|
174
|
-
* | **edited** | unchanged | `send` — a real request |
|
|
175
|
-
* | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
|
|
70
|
+
* `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
|
|
71
|
+
* and stored on the other side where something else may move it.
|
|
176
72
|
*
|
|
177
|
-
* ⛔
|
|
178
|
-
*
|
|
179
|
-
* owner
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* agree, `none`. That withholds rather than sends, which is safe HERE — unlike
|
|
184
|
-
* `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
|
|
185
|
-
*
|
|
186
|
-
* @param {object} siteYml
|
|
187
|
-
* @param {*} remoteServices - the status read's `services` rows, or undefined
|
|
188
|
-
* @param {object|null} lastDeploy - the banked base
|
|
189
|
-
* @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
|
|
190
|
-
*/
|
|
191
|
-
export function reconcileRequest(siteYml, remoteServices, lastDeploy, provisioned = {}) {
|
|
192
|
-
return reconcile(provisioned?.services, remoteServices, lastDeploy?.servicesRequest)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The same reconcile over any two-way request field.
|
|
197
|
-
*
|
|
198
|
-
* ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
|
|
199
|
-
* the same shape — the owner's ASK, pushed up, projected back on pull, and stored
|
|
200
|
-
* on the other side where something else may move it.
|
|
201
|
-
*
|
|
202
|
-
* ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
|
|
203
|
-
* reasoning that nearly left languages out: "nobody overwrites it, so there is no
|
|
204
|
-
* hazard." Overwriting is not the only thing a base is for — without one, a value
|
|
205
|
-
* that has always been in the file is indistinguishable from one the owner just
|
|
206
|
-
* typed, so the CLI cannot tell an intentional change from the status quo. For
|
|
207
|
-
* languages that difference is money: the count is priced, so a new language is a
|
|
208
|
-
* charge, and saying so before sending requires knowing it is new.
|
|
73
|
+
* ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
|
|
74
|
+
* only thing a base is for — without one, a value that has always been in the file
|
|
75
|
+
* is indistinguishable from one the owner just typed, so the CLI cannot tell an
|
|
76
|
+
* intentional change from the status quo. For languages that difference is money:
|
|
77
|
+
* the count is priced, so a new language is a charge, and saying so before sending
|
|
78
|
+
* requires knowing it is new.
|
|
209
79
|
*
|
|
210
80
|
* @param {*} localValue - the file's declaration
|
|
211
81
|
* @param {*} remoteValue - what the site has stored
|
|
212
82
|
* @param {string|null} baseFingerprint - what we last agreed on
|
|
83
|
+
* @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
|
|
213
84
|
*/
|
|
214
85
|
export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
215
86
|
const local = fingerprintDeclaration(localValue)
|
|
@@ -217,11 +88,9 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
|
217
88
|
const base = baseFingerprint || null
|
|
218
89
|
|
|
219
90
|
if (local === remote) return { action: 'none', local, remote }
|
|
220
|
-
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
|
|
221
|
-
// nothing stored either
|
|
222
|
-
//
|
|
223
|
-
// first publish of a site whose file is silent warned that its services "were changed
|
|
224
|
-
// elsewhere, and site.yml changed too", listing both as nothing.
|
|
91
|
+
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
|
|
92
|
+
// With nothing stored either there is nothing to do; with something stored, the file
|
|
93
|
+
// is merely behind.
|
|
225
94
|
if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
|
|
226
95
|
if (!base) return { action: 'conflict', local, remote }
|
|
227
96
|
|
|
@@ -231,3 +100,107 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
|
231
100
|
if (localMoved && !remoteMoved) return { action: 'send', local, remote }
|
|
232
101
|
return { action: 'conflict', local, remote }
|
|
233
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a publish records in `deploy.yml` about the language selection: its
|
|
106
|
+
* fingerprint, on EVERY publish — it rides the content push, so what went live is
|
|
107
|
+
* what was agreed.
|
|
108
|
+
*
|
|
109
|
+
* ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
|
|
110
|
+
* selection itself is in `site.yml`.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} siteYml
|
|
113
|
+
* @returns {{publishLanguagesRequest?: string}}
|
|
114
|
+
*/
|
|
115
|
+
export function bankLanguages(siteYml) {
|
|
116
|
+
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
117
|
+
return langs ? { publishLanguagesRequest: langs } : {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The services the site's foundation says it renders — the build's `_self.supports`, else
|
|
122
|
+
* `package.json::uniweb.supports` — or null when that is unknown: no local foundation, or
|
|
123
|
+
* one that declares nothing. ⛔ Absent is UNKNOWN, never "none".
|
|
124
|
+
*
|
|
125
|
+
* @param {string} siteDir
|
|
126
|
+
* @param {object} siteYml - parsed
|
|
127
|
+
* @returns {Promise<string[]|null>}
|
|
128
|
+
*/
|
|
129
|
+
export async function foundationSupports(siteDir, siteYml) {
|
|
130
|
+
try {
|
|
131
|
+
if (!siteYml?.foundation) return null
|
|
132
|
+
const { detectFoundationType } = await import('@uniweb/build')
|
|
133
|
+
const found = detectFoundationType(siteYml.foundation, siteDir)
|
|
134
|
+
if (found?.type !== 'local' || !found.path) return null
|
|
135
|
+
const built = join(found.path, 'dist', 'meta', 'schema.json')
|
|
136
|
+
if (existsSync(built)) {
|
|
137
|
+
const derived = JSON.parse(readFileSync(built, 'utf8'))?._self?.supports
|
|
138
|
+
if (Array.isArray(derived)) return derived
|
|
139
|
+
}
|
|
140
|
+
const declared = JSON.parse(readFileSync(join(found.path, 'package.json'), 'utf8'))?.uniweb?.supports
|
|
141
|
+
return Array.isArray(declared) ? declared : null
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Say what `site.yml::services` asks that will not be sent as written — a credential,
|
|
151
|
+
* an entry that is not one, an `api` address — and where it and the foundation disagree,
|
|
152
|
+
* before a push or publish sends the rest. What a push sends is the producer's
|
|
153
|
+
* (`statedServices`), from the same file.
|
|
154
|
+
*
|
|
155
|
+
* ⭐ The foundation INFORMS and never decides [Diego, 2026-10-07]: a service the file turns
|
|
156
|
+
* on that the foundation does not say it renders is said; so is one it renders that the
|
|
157
|
+
* file does not mention, since every service is off unless the site asks for it. An
|
|
158
|
+
* explicit `false` is a decision, and quiets the second. `tracking` is in neither — a
|
|
159
|
+
* foundation may not claim it, since it renders nothing — and `records` is left to
|
|
160
|
+
* publish, which knows whether the pages show any (`recordsNotAsked`).
|
|
161
|
+
*
|
|
162
|
+
* @param {object} p
|
|
163
|
+
* @param {object} p.siteYml - parsed
|
|
164
|
+
* @param {{ warn: Function }} p.say
|
|
165
|
+
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
166
|
+
*/
|
|
167
|
+
export function announceServices({ siteYml, say, supports = null }) {
|
|
168
|
+
const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
|
|
169
|
+
if (!Array.isArray(supports)) return
|
|
170
|
+
const ignored = new Set(['tracking', 'records'])
|
|
171
|
+
const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
|
|
172
|
+
const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
|
|
173
|
+
if (unrendered.length) {
|
|
174
|
+
say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
|
|
175
|
+
}
|
|
176
|
+
const mentioned = new Set(asks.map((a) => a.name))
|
|
177
|
+
const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
|
|
178
|
+
if (unasked.length) {
|
|
179
|
+
say.warn(
|
|
180
|
+
`Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
|
|
181
|
+
`unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
|
|
182
|
+
`${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The publish warning for `records`: the site's pages show records live, and `site.yml`
|
|
189
|
+
* does not ask for the service that delivers them on a published site [Diego, 2026-10-07:
|
|
190
|
+
* "When records is off and the site is published with us, there is no meant to be a fall
|
|
191
|
+
* back at all"]. Syncing records does not depend on it, so push and pull say nothing.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} p
|
|
194
|
+
* @param {object} p.siteYml - parsed
|
|
195
|
+
* @param {string[]} p.shown - from the package (`recordsShown`)
|
|
196
|
+
* @returns {string|null} the warning, or null
|
|
197
|
+
*/
|
|
198
|
+
export function recordsNotAsked({ siteYml, shown }) {
|
|
199
|
+
if (!Array.isArray(shown) || !shown.length) return null
|
|
200
|
+
const records = (readServicesRequest(siteYml?.services) || []).find((a) => a.name === 'records')
|
|
201
|
+
if (records && records.enabled !== false) return null
|
|
202
|
+
return (
|
|
203
|
+
`Your pages show records from ${names(shown)}, but site.yml does not ask for \`records\` — ` +
|
|
204
|
+
'a published site delivers them only with it on. Add `records: true` under `services:`.'
|
|
205
|
+
)
|
|
206
|
+
}
|