@uniweb/build 0.76.1 → 0.77.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 +8 -8
- package/src/site/content-collector.js +44 -18
- package/src/uwx/emit-surface.json +2 -2
- package/src/uwx/index.js +9 -0
- package/src/uwx/project-writer.js +6 -2
- package/src/uwx/services-request.js +280 -0
- package/src/uwx/site-project.js +17 -22
- package/src/uwx/site.js +73 -65
- package/src/uwx/sync-package.js +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
+
"@uniweb/semantic-parser": "^1.5.1",
|
|
61
|
+
"@uniweb/theming": "^0.2.0",
|
|
62
|
+
"@uniweb/schemas": "^0.12.0",
|
|
60
63
|
"@uniweb/content-reader": "^1.2.8",
|
|
61
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
62
64
|
"@uniweb/projections": "^0.7.14",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/semantic-parser": "^1.5.1",
|
|
65
|
-
"@uniweb/theming": "^0.2.0"
|
|
65
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.29.
|
|
68
|
+
"@uniweb/runtime": "^0.29.2"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.37.2",
|
|
78
|
+
"@uniweb/runtime": "^0.29.2"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
|
@@ -2437,6 +2437,48 @@ async function collectLayouts(layoutDir, siteRoot) {
|
|
|
2437
2437
|
* @param {boolean} [options.strict=false] - Fail on content problems that would ship broken output rather than warning about them. A production build passes true; dev leaves it false, because a folder an author just created is empty for a moment and that is not an error.
|
|
2438
2438
|
* @returns {Promise<Object>} Site content object with assets manifest
|
|
2439
2439
|
*/
|
|
2440
|
+
/**
|
|
2441
|
+
* `site.yml` as a payload carries it — without what is authoring or publish intent,
|
|
2442
|
+
* and without the backend-scoped `$` keys. One rule for every path that ships a
|
|
2443
|
+
* site's config.
|
|
2444
|
+
*
|
|
2445
|
+
* `publishLanguages` is authoring/publish intent — it has no runtime consumer and
|
|
2446
|
+
* never ships in a payload (the visitor runtime is list-unaware; the sync lane reads
|
|
2447
|
+
* site.yml directly, not this output). The `query:` shorthand ships as
|
|
2448
|
+
* `config.fetch`, desugared by the caller; carried raw it would sit beside
|
|
2449
|
+
* `config.queries`, the declarations, and read as one.
|
|
2450
|
+
*
|
|
2451
|
+
* ⛔ `services` is the owner's REQUEST to their host (`uwx/services-request.js`), and
|
|
2452
|
+
* here it would land at `config.services` — the HOST tier, where a present block
|
|
2453
|
+
* declines every service it does not name. A request is never an answer, so it never
|
|
2454
|
+
* ships. *(Until 2026-10-06 it did, and doubled as an undocumented way to simulate a
|
|
2455
|
+
* host's offer locally.)*
|
|
2456
|
+
*
|
|
2457
|
+
* ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2458
|
+
* `$backend`, `$services`, `$secrets` — until they moved to `sync.json` (2026-09-20),
|
|
2459
|
+
* and a `site.yml` written before then can still carry them. This payload is a
|
|
2460
|
+
* PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in
|
|
2461
|
+
* core, runtime or kit reads a `config.$*` key), so this removes noise for four of
|
|
2462
|
+
* them and a real disclosure for the fifth: `$secrets` carries no values, but its
|
|
2463
|
+
* entries name every secret the site has, and an inventory of credential names is not
|
|
2464
|
+
* something an `export` should publish.
|
|
2465
|
+
*
|
|
2466
|
+
* @param {object} siteConfig
|
|
2467
|
+
* @returns {object}
|
|
2468
|
+
*/
|
|
2469
|
+
function payloadSiteConfig(siteConfig) {
|
|
2470
|
+
const {
|
|
2471
|
+
publishLanguages: _publishLanguages,
|
|
2472
|
+
query: _query,
|
|
2473
|
+
services: _services,
|
|
2474
|
+
...runtimeSiteConfig
|
|
2475
|
+
} = siteConfig
|
|
2476
|
+
for (const key of Object.keys(runtimeSiteConfig)) {
|
|
2477
|
+
if (key.startsWith('$')) delete runtimeSiteConfig[key]
|
|
2478
|
+
}
|
|
2479
|
+
return runtimeSiteConfig
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2440
2482
|
export async function collectSiteContent(sitePath, options = {}) {
|
|
2441
2483
|
const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/', strict = false } = options
|
|
2442
2484
|
|
|
@@ -2547,7 +2589,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2547
2589
|
// Check if pages directory exists
|
|
2548
2590
|
if (!existsSync(pagesPath)) {
|
|
2549
2591
|
return {
|
|
2550
|
-
config: siteConfig,
|
|
2592
|
+
config: payloadSiteConfig(siteConfig),
|
|
2551
2593
|
theme: {
|
|
2552
2594
|
...processedTheme,
|
|
2553
2595
|
css: themeCSS,
|
|
@@ -2725,23 +2767,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2725
2767
|
if (page.slug) delete page.slug
|
|
2726
2768
|
}
|
|
2727
2769
|
|
|
2728
|
-
|
|
2729
|
-
// consumer and never ships in a payload (the visitor runtime is
|
|
2730
|
-
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2731
|
-
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2732
|
-
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2733
|
-
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2734
|
-
// ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2735
|
-
// `$backend`, `$services`, `$secrets` — until they moved to `sync.json`
|
|
2736
|
-
// (2026-09-20), and a `site.yml` written before then can still carry them. This
|
|
2737
|
-
// payload is a PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
2738
|
-
// or kit reads a `config.$*` key), so this removes noise for four of them and a
|
|
2739
|
-
// real disclosure for the fifth: `$secrets` carries no values, but its entries
|
|
2740
|
-
// name every secret the site has, and an inventory of credential names is not
|
|
2741
|
-
// something an `export` should publish.
|
|
2742
|
-
for (const key of Object.keys(runtimeSiteConfig)) {
|
|
2743
|
-
if (key.startsWith('$')) delete runtimeSiteConfig[key]
|
|
2744
|
-
}
|
|
2770
|
+
const runtimeSiteConfig = payloadSiteConfig(siteConfig)
|
|
2745
2771
|
|
|
2746
2772
|
// ⛔ **A BUILD does not ship a site whose config failed to parse.**
|
|
2747
2773
|
// `strict` is already this codebase's word for it — `build-site-data.js` sets
|
|
@@ -106,11 +106,11 @@
|
|
|
106
106
|
},
|
|
107
107
|
"services": {
|
|
108
108
|
"kind": "passthrough",
|
|
109
|
-
"authoredAs": "site.yml
|
|
109
|
+
"authoredAs": "site.yml::services"
|
|
110
110
|
},
|
|
111
111
|
"secrets": {
|
|
112
112
|
"kind": "passthrough",
|
|
113
|
-
"authoredAs": "
|
|
113
|
+
"authoredAs": "sync.json::backends.<origin>.secrets"
|
|
114
114
|
},
|
|
115
115
|
"pages": {
|
|
116
116
|
"kind": "records"
|
package/src/uwx/index.js
CHANGED
|
@@ -113,6 +113,15 @@ export {
|
|
|
113
113
|
refForAssetId,
|
|
114
114
|
normalizeOrigin as normalizeBackendOrigin,
|
|
115
115
|
} from './sync-store.js'
|
|
116
|
+
// A site's services request — `site.yml::services`, applied over the site's own rows.
|
|
117
|
+
// Spec: kb/framework/reference/site-services-request.md
|
|
118
|
+
export {
|
|
119
|
+
readServicesRequest,
|
|
120
|
+
takeServices,
|
|
121
|
+
mergeServiceRows,
|
|
122
|
+
reconcileServices,
|
|
123
|
+
recordAfter,
|
|
124
|
+
} from './services-request.js'
|
|
116
125
|
export {
|
|
117
126
|
diffSiteUnits,
|
|
118
127
|
describeSiteDiff,
|
|
@@ -268,13 +268,17 @@ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArr
|
|
|
268
268
|
* ⛔ The declaration keys (`query` / `fetch` / `data`) are written WHOLE, never
|
|
269
269
|
* merged: a declaration is one value, and merging an incoming `fetch:` into the
|
|
270
270
|
* local one kept whatever key the remote no longer has — a stale `limit` survived
|
|
271
|
-
* every pull.
|
|
271
|
+
* every pull. So is `services`: it is the site's settled request, and a service
|
|
272
|
+
* merged in from the old map would be an ask nobody made.
|
|
272
273
|
* @returns {'updated'|'unchanged'}
|
|
273
274
|
*/
|
|
274
275
|
export function writeSiteConfig(siteRoot, config) {
|
|
275
|
-
return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace:
|
|
276
|
+
return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: SITE_CONFIG_WHOLE })
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/** `site.yml` keys a writer replaces whole rather than merging into. */
|
|
280
|
+
const SITE_CONFIG_WHOLE = Object.freeze([...DECLARATION_KEYS, 'services'])
|
|
281
|
+
|
|
278
282
|
/**
|
|
279
283
|
* Write a YAML object to a file (full dump, idempotent) — for machine-owned
|
|
280
284
|
* config the projector authors wholesale, e.g. a projected `page.yml`/`folder.yml`.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A site's services request — `site.yml::services`, a map from service name to
|
|
3
|
+
* what the owner asks their host for.
|
|
4
|
+
*
|
|
5
|
+
* ```yaml
|
|
6
|
+
* services:
|
|
7
|
+
* search: true # turn it on
|
|
8
|
+
* submit: false # turn it off
|
|
9
|
+
* api: # turn it on, with the service's own settings
|
|
10
|
+
* grade: pro
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* ⭐ ONE REQUEST, WHICHEVER BACKEND A COMMAND TALKS TO. The rows a backend holds
|
|
14
|
+
* for the site are kept apart from it, in that backend's entry in `sync.json` — the
|
|
15
|
+
* CLI's record of what it last agreed with that site, which nobody edits.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NOT THE TOP-LEVEL `search:` / `submit:` / `assistant:` / `tracking:` KEYS.
|
|
18
|
+
* Those say how the site uses a provider it brings itself; this asks the host. A
|
|
19
|
+
* site's own `submit: https://forms.example.com/…` read as a request would turn on
|
|
20
|
+
* the host's form handling, whose address then outranks the site's.
|
|
21
|
+
*
|
|
22
|
+
* Pure functions, shared by the producer (`site.js`), pull (`site-project.js`) and
|
|
23
|
+
* the CLI's push and publish.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Top-level `site.yml` keys that hold an address — named when one is misplaced here. */
|
|
29
|
+
const ADDRESS_KEYS = new Set(['search', 'submit', 'assistant', 'tracking', 'api'])
|
|
30
|
+
|
|
31
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
32
|
+
|
|
33
|
+
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
34
|
+
function stable(value) {
|
|
35
|
+
return JSON.stringify(value ?? null, (_k, v) =>
|
|
36
|
+
isMap(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A row as it is stored and compared: no payload-local `$id`, `enabled` only when
|
|
42
|
+
* it is `false` (absent means on), no empty `config`.
|
|
43
|
+
*/
|
|
44
|
+
function canonicalRow(row) {
|
|
45
|
+
const { $id: _id, enabled, ...rest } = row
|
|
46
|
+
const out = { ...rest }
|
|
47
|
+
if (enabled === false) out.enabled = false
|
|
48
|
+
if (isMap(out.config) && Object.keys(out.config).length === 0) delete out.config
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rowNamed = (rows, name) =>
|
|
53
|
+
Array.isArray(rows) ? rows.find((r) => isMap(r) && r.name === name) : undefined
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `site.yml::services` → the asks, as rows in the `services` Section's shape:
|
|
57
|
+
* `{ name, enabled?: false, config? }`.
|
|
58
|
+
*
|
|
59
|
+
* @param {*} declared - the raw `services:` value
|
|
60
|
+
* @param {{ warn?: (message: string) => void }} [opts] - told about every value it
|
|
61
|
+
* ignores, with the fix
|
|
62
|
+
* @returns {object[]|null} null when the file asks nothing — no key, or no usable entry
|
|
63
|
+
*/
|
|
64
|
+
export function readServicesRequest(declared, { warn = () => {} } = {}) {
|
|
65
|
+
if (declared === undefined || declared === null) return null
|
|
66
|
+
if (!isMap(declared)) {
|
|
67
|
+
warn('`services:` is a map of service names — `services: { search: true }`. Ignoring it.')
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const asks = []
|
|
71
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
72
|
+
if (value === true) asks.push({ name })
|
|
73
|
+
else if (value === false) asks.push({ name, enabled: false })
|
|
74
|
+
else if (isMap(value)) {
|
|
75
|
+
const { enabled, ...settings } = value
|
|
76
|
+
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
77
|
+
warn(`\`services.${name}.enabled\` is true or false. Ignoring \`${name}\`.`)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
asks.push({
|
|
81
|
+
name,
|
|
82
|
+
...(enabled === false ? { enabled: false } : {}),
|
|
83
|
+
...(Object.keys(settings).length ? { config: settings } : {})
|
|
84
|
+
})
|
|
85
|
+
} else {
|
|
86
|
+
const address =
|
|
87
|
+
typeof value === 'string' && ADDRESS_KEYS.has(name)
|
|
88
|
+
? ` An address for the site's own ${name} goes in the top-level \`${name}:\` key.`
|
|
89
|
+
: ''
|
|
90
|
+
warn(`\`services.${name}\` is true, false, or a map of its settings. Ignoring it.${address}`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return asks.length ? asks : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The site's stored rows → the `site.yml::services` map — what pull writes.
|
|
98
|
+
*
|
|
99
|
+
* A row becomes `true` or `false`, or a map of its settings with `enabled: false`
|
|
100
|
+
* when it is off.
|
|
101
|
+
*
|
|
102
|
+
* @param {object[]} rows
|
|
103
|
+
* @returns {object|null} null when there is nothing to write
|
|
104
|
+
*/
|
|
105
|
+
export function servicesRequestFromRows(rows) {
|
|
106
|
+
if (!Array.isArray(rows)) return null
|
|
107
|
+
const out = {}
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name) continue
|
|
110
|
+
out[row.name] = requestValue(row)
|
|
111
|
+
}
|
|
112
|
+
return Object.keys(out).length ? out : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One stored row → its value in the `site.yml::services` map. */
|
|
116
|
+
function requestValue(row) {
|
|
117
|
+
const off = row.enabled === false
|
|
118
|
+
const settings = isMap(row.config) && Object.keys(row.config).length ? row.config : null
|
|
119
|
+
if (!settings) return !off
|
|
120
|
+
return off ? { enabled: false, ...settings } : { ...settings }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `site.yml::services` map with these services taken from the site's rows — a
|
|
125
|
+
* service the site holds no row for leaves the map.
|
|
126
|
+
*
|
|
127
|
+
* @param {*} declared - the current `services:` value
|
|
128
|
+
* @param {object[]} stored - the site's rows now
|
|
129
|
+
* @param {string[]} names
|
|
130
|
+
* @returns {object|null} the new map, or null when nothing is left in it
|
|
131
|
+
*/
|
|
132
|
+
export function takeServices(declared, stored, names) {
|
|
133
|
+
const out = isMap(declared) ? { ...declared } : {}
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const row = rowNamed(stored, name)
|
|
136
|
+
if (row) out[name] = requestValue(row)
|
|
137
|
+
else delete out[name]
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(out).length ? out : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Does this stored row already give what the ask asks — the same switch, and every
|
|
144
|
+
* setting the ask names equal?
|
|
145
|
+
*
|
|
146
|
+
* @param {object|undefined} row
|
|
147
|
+
* @param {object} ask
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
export function satisfies(row, ask) {
|
|
151
|
+
if (!isMap(row)) return false
|
|
152
|
+
if ((row.enabled === false) !== (ask.enabled === false)) return false
|
|
153
|
+
if (!ask.config) return true
|
|
154
|
+
const stored = isMap(row.config) ? row.config : {}
|
|
155
|
+
return Object.entries(ask.config).every(([key, value]) => stable(stored[key]) === stable(value))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Two rows that are the same as stored — both absent included. */
|
|
159
|
+
function sameRow(a, b) {
|
|
160
|
+
return stable(isMap(a) ? canonicalRow(a) : null) === stable(isMap(b) ? canonicalRow(b) : null)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The full list to send: the stored rows, with these asks applied by name.
|
|
165
|
+
*
|
|
166
|
+
* ⭐ A FULL LIST, because the backend replaces the `services` Section with what it is
|
|
167
|
+
* sent: a row left out would lose its stored settings. A row no ask names is sent as
|
|
168
|
+
* stored. A row an ask names takes the ask's switch, and its settings over the
|
|
169
|
+
* stored ones, key by key. Every field is kept — a row is opaque past `name`,
|
|
170
|
+
* `enabled` and `config`.
|
|
171
|
+
*
|
|
172
|
+
* @param {object[]|null|undefined} base - the site's rows (or the record of them)
|
|
173
|
+
* @param {object[]} asks - from `readServicesRequest`
|
|
174
|
+
* @returns {object[]}
|
|
175
|
+
*/
|
|
176
|
+
export function mergeServiceRows(base, asks) {
|
|
177
|
+
const out = []
|
|
178
|
+
const at = new Map()
|
|
179
|
+
for (const row of Array.isArray(base) ? base : []) {
|
|
180
|
+
if (!isMap(row) || typeof row.name !== 'string' || !row.name || at.has(row.name)) continue
|
|
181
|
+
at.set(row.name, out.length)
|
|
182
|
+
out.push(canonicalRow(row))
|
|
183
|
+
}
|
|
184
|
+
for (const ask of asks || []) {
|
|
185
|
+
const index = at.get(ask.name)
|
|
186
|
+
const next = index === undefined ? { name: ask.name } : { ...out[index] }
|
|
187
|
+
if (ask.enabled === false) next.enabled = false
|
|
188
|
+
else delete next.enabled
|
|
189
|
+
if (ask.config) next.config = { ...(isMap(next.config) ? next.config : {}), ...ask.config }
|
|
190
|
+
if (index === undefined) {
|
|
191
|
+
at.set(ask.name, out.length)
|
|
192
|
+
out.push(next)
|
|
193
|
+
} else {
|
|
194
|
+
out[index] = next
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Per service the file names: who moved since the last agreement, and so what to do.
|
|
202
|
+
*
|
|
203
|
+
* Three states — the file's asks, the site's rows now (`stored`), and the record of
|
|
204
|
+
* the last agreement (`record`, from `sync.json`):
|
|
205
|
+
*
|
|
206
|
+
* | the file vs the record | the site vs the record | outcome |
|
|
207
|
+
* |---|---|---|
|
|
208
|
+
* | unchanged | unchanged | nothing |
|
|
209
|
+
* | changed | unchanged | `send` |
|
|
210
|
+
* | unchanged | changed | `adopt` — the site's is kept, and may be taken into the file |
|
|
211
|
+
* | changed | changed, differently | `conflict` — only the owner can rank the two |
|
|
212
|
+
*
|
|
213
|
+
* A service the site already gives as asked is nothing, whoever moved. With no record
|
|
214
|
+
* the two cannot be told apart: a service the site holds nothing for is sent, one it
|
|
215
|
+
* holds differently is a conflict. With the site unreadable, what changed against the
|
|
216
|
+
* record is sent over the record; with neither, nothing can be merged onto for a site
|
|
217
|
+
* that exists — `unreadable` — while a site not yet created has nothing stored.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} p
|
|
220
|
+
* @param {object[]} p.asks - from `readServicesRequest`
|
|
221
|
+
* @param {object[]} [p.record] - the rows last agreed, when the project has them
|
|
222
|
+
* @param {object[]} [p.stored] - the site's rows now, when they could be read
|
|
223
|
+
* @param {boolean} [p.siteKnown=true] - whether the site exists on this backend
|
|
224
|
+
* @returns {{ send: string[], adopt: string[], conflict: string[], unreadable: boolean }}
|
|
225
|
+
*/
|
|
226
|
+
export function reconcileServices({ asks, record, stored, siteKnown = true }) {
|
|
227
|
+
const send = []
|
|
228
|
+
const adopt = []
|
|
229
|
+
const conflict = []
|
|
230
|
+
const haveRecord = Array.isArray(record)
|
|
231
|
+
const haveStored = Array.isArray(stored)
|
|
232
|
+
if (!haveRecord && !haveStored) {
|
|
233
|
+
return siteKnown
|
|
234
|
+
? { send, adopt, conflict, unreadable: true }
|
|
235
|
+
: { send: asks.map((a) => a.name), adopt, conflict, unreadable: false }
|
|
236
|
+
}
|
|
237
|
+
for (const ask of asks) {
|
|
238
|
+
const now = rowNamed(stored, ask.name)
|
|
239
|
+
const then = rowNamed(record, ask.name)
|
|
240
|
+
if (!haveStored) {
|
|
241
|
+
if (!satisfies(then, ask)) send.push(ask.name)
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (satisfies(now, ask)) continue
|
|
245
|
+
if (!haveRecord) {
|
|
246
|
+
;(now === undefined ? send : conflict).push(ask.name)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const fileMoved = !satisfies(then, ask)
|
|
250
|
+
const siteMoved = !sameRow(now, then)
|
|
251
|
+
if (fileMoved && !siteMoved) send.push(ask.name)
|
|
252
|
+
else if (!fileMoved && siteMoved) adopt.push(ask.name)
|
|
253
|
+
else conflict.push(ask.name)
|
|
254
|
+
}
|
|
255
|
+
return { send, adopt, conflict, unreadable: false }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The record after a push: what this project now agrees with the site on.
|
|
260
|
+
*
|
|
261
|
+
* The rows sent, or — when nothing was — the site's rows now. ⛔ For a service left
|
|
262
|
+
* OPEN (an adopt not taken, a conflict not resolved) the earlier agreement stands,
|
|
263
|
+
* or the next run would read the site's change as the file's and send the stale ask.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} p
|
|
266
|
+
* @param {object[]} [p.record] - the rows last agreed
|
|
267
|
+
* @param {object[]} [p.agreed] - the rows sent, or the site's rows now
|
|
268
|
+
* @param {string[]} [p.open] - services whose decision is still open
|
|
269
|
+
* @returns {object[]|undefined} undefined when there is nothing to record
|
|
270
|
+
*/
|
|
271
|
+
export function recordAfter({ record, agreed, open = [] }) {
|
|
272
|
+
if (!Array.isArray(agreed)) return undefined
|
|
273
|
+
const keep = new Set(open)
|
|
274
|
+
const out = agreed.filter((r) => isMap(r) && !keep.has(r.name)).map(canonicalRow)
|
|
275
|
+
for (const name of open) {
|
|
276
|
+
const then = rowNamed(record, name)
|
|
277
|
+
if (then) out.push(canonicalRow(then))
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -36,6 +36,7 @@ import { foldOpenGraph } from './open-graph.js'
|
|
|
36
36
|
import { join, relative, extname, basename, dirname } from 'node:path'
|
|
37
37
|
import { restoreAssetRefs } from './asset-map.js'
|
|
38
38
|
import { readBackendState, updateBackendState } from './sync-store.js'
|
|
39
|
+
import { servicesRequestFromRows } from './services-request.js'
|
|
39
40
|
import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
|
|
40
41
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
41
42
|
import { createHash } from 'node:crypto'
|
|
@@ -309,30 +310,24 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
|
|
|
309
310
|
: []
|
|
310
311
|
if (extensions.length > 0) siteChanges.extensions = extensions
|
|
311
312
|
|
|
312
|
-
// services[]
|
|
313
|
+
// services[] → site.yml::services, and the same rows → sync.json as the record.
|
|
313
314
|
//
|
|
314
|
-
// ⭐
|
|
315
|
-
//
|
|
316
|
-
//
|
|
315
|
+
// ⭐ `site.yml` TAKES WHAT WAS DECIDED [Diego, 2026-10-06: "pull writes it"]. The
|
|
316
|
+
// stored list is the site's settled request, so the author's map becomes it, whole: a
|
|
317
|
+
// row is `true`, `false`, or its settings (`servicesRequestFromRows`). An empty list
|
|
318
|
+
// removes the key; a document with no `services` Section writes nothing.
|
|
317
319
|
//
|
|
318
|
-
// ⭐
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// state — "this site has no service rows" — and it is the state a `pull` must be
|
|
330
|
-
// able to deliver after the last one was removed. Skipping would leave a stale
|
|
331
|
-
// `$services` on disk that the next push would resurrect.
|
|
332
|
-
// ⭐ To `sync.json`, under the backend that provisioned them — not `site.yml`.
|
|
333
|
-
// A service is bought on one backend, so its rows describe that backend alone.
|
|
334
|
-
// ⛔ Needs a backend: with none there is nowhere coherent to file them, and
|
|
335
|
-
// writing them to a shared place is exactly the single-copy bug this removed.
|
|
320
|
+
// ⭐ The record goes to `sync.json`, under this backend: the rows as the site holds
|
|
321
|
+
// them — the last state both sides agreed on, which push and publish compare the file
|
|
322
|
+
// against (`reconcileServices`). `$id` is dropped, being derived; everything else rides
|
|
323
|
+
// VERBATIM, `config` included — it is opaque, per-service and will grow, and the next
|
|
324
|
+
// push sends the stored rows as its base. `secrets` likewise: an inventory of names
|
|
325
|
+
// whose values are set in the app. `[]` is written as `[]`: "the site holds no rows" is
|
|
326
|
+
// a state a pull must be able to deliver.
|
|
327
|
+
// ⛔ The record needs a backend: with none there is nowhere coherent to file it.
|
|
328
|
+
if (Array.isArray(document?.services)) {
|
|
329
|
+
siteChanges.services = servicesRequestFromRows(document.services)
|
|
330
|
+
}
|
|
336
331
|
if (backend) {
|
|
337
332
|
const provisioned = {}
|
|
338
333
|
for (const section of ['services', 'secrets']) {
|
package/src/uwx/site.js
CHANGED
|
@@ -80,6 +80,7 @@ import { resolveLocaleList } from '../i18n/locales.js'
|
|
|
80
80
|
import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
|
|
81
81
|
import { splitOpenGraph, openGraphValue, seoBesideOpenGraph } from './open-graph.js'
|
|
82
82
|
import { updateBackendState, readBackendState } from './sync-store.js'
|
|
83
|
+
import { readServicesRequest, mergeServiceRows } from './services-request.js'
|
|
83
84
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
84
85
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
85
86
|
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
@@ -1129,48 +1130,35 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
|
|
|
1129
1130
|
return out
|
|
1130
1131
|
}
|
|
1131
1132
|
|
|
1132
|
-
// ── `services` + `secrets` — a site
|
|
1133
|
+
// ── `services` + `secrets` — what a site asks of its host ─────────────────────────
|
|
1133
1134
|
//
|
|
1134
|
-
//
|
|
1135
|
-
// `
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1135
|
+
// ⭐ THE `services` SECTION IS THE OWNER'S REQUEST, AUTHORED IN `site.yml::services` — a
|
|
1136
|
+
// map from service name to `true`, `false` or the service's settings (spec:
|
|
1137
|
+
// kb/framework/reference/site-services-request.md). It is sent as a FULL LIST: the
|
|
1138
|
+
// site's rows, with the owner's asks applied by name (`mergeServiceRows`), because the
|
|
1139
|
+
// backend REPLACES the Section with what it is sent and a row left out would lose its
|
|
1140
|
+
// stored settings. The site's rows come from the caller when it has just read them
|
|
1141
|
+
// (`opts.serviceRows`, already merged), else from the record of the last agreement —
|
|
1142
|
+
// that backend's entry in `sync.json`, written by pull and by a push that succeeded.
|
|
1143
|
+
// ⛔ *The rows lived only in `sync.json` from 2026-09-20 to 2026-10-06, which left a
|
|
1144
|
+
// CLI user nowhere to ask; `site.yml::$services` / `$secrets` before that.*
|
|
1140
1145
|
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
// `services:` block there lands at `config.services` — the HOST tier — which is the
|
|
1144
|
-
// documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
|
|
1145
|
-
// Reusing the name would give one key two meanings that differ per lane, which is
|
|
1146
|
-
// the shape of bug nobody finds.
|
|
1147
|
-
//
|
|
1148
|
-
// `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
|
|
1149
|
-
// (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
|
|
1150
|
-
// config is bound where the service is provisioned, and arrives here by `pull`.
|
|
1146
|
+
// `secrets` stays in `sync.json`: an inventory of names a pull writes, whose values are
|
|
1147
|
+
// set in the app — `#ref` marks one as set.
|
|
1151
1148
|
//
|
|
1152
1149
|
// ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
|
|
1153
1150
|
// `assistant:`, `tracking:` — ride the `settings` Section and are untouched here.
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
// host tier, where a block's mere presence declines every service it does not name.
|
|
1159
|
-
// These Sections carry the services a site is PROVISIONED with — `api` above all,
|
|
1160
|
-
// which has no file-authored form because it is bought, not declared.
|
|
1161
|
-
//
|
|
1162
|
-
// ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
|
|
1163
|
-
// REPLACED by what we send, so `[]` means "drop every stored config row" while a
|
|
1164
|
-
// missing key means "I am not telling you about this". A project that has never
|
|
1165
|
-
// pulled has no `services` for that backend, and its ordinary push must not read as
|
|
1166
|
-
// a request to wipe a service the operator configured in the app. So: emit the
|
|
1167
|
-
// Section only when that backend's entry in `sync.json` carries the key. Clearing is
|
|
1168
|
-
// available and explicit — `services: []` there. *(Both said `site.yml::$services`
|
|
1169
|
-
// until the move on 2026-09-20.)*
|
|
1151
|
+
// Those are authored addresses and land at the SITE tier, `config.<name>`, which
|
|
1152
|
+
// `@uniweb/core`'s `resolveService` reads for any service the host does not offer.
|
|
1153
|
+
// A row here is read by the service's owner and reaches neither tier, so an address
|
|
1154
|
+
// filed here would reach no page.
|
|
1170
1155
|
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1156
|
+
// ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is REPLACED
|
|
1157
|
+
// by what we send, so `[]` means "drop every stored row" while a missing key means "I
|
|
1158
|
+
// am not telling you about this". So `services` is emitted only when `site.yml` asks
|
|
1159
|
+
// for something and there is a list to apply it to — never for a file that says
|
|
1160
|
+
// nothing, and never as a partial list for a site whose rows this project does not
|
|
1161
|
+
// know. A site with no entry in `sync.json` has nothing stored yet.
|
|
1174
1162
|
|
|
1175
1163
|
/**
|
|
1176
1164
|
* A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
|
|
@@ -1223,14 +1211,36 @@ function serviceRecords(declared, identify, label) {
|
|
|
1223
1211
|
}
|
|
1224
1212
|
|
|
1225
1213
|
/** One service per `name` — the same keyspace `config.services` uses at runtime. */
|
|
1226
|
-
function servicesNested(
|
|
1214
|
+
function servicesNested(rows) {
|
|
1227
1215
|
return serviceRecords(
|
|
1228
|
-
|
|
1216
|
+
rows,
|
|
1229
1217
|
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
1230
|
-
'
|
|
1218
|
+
'services'
|
|
1231
1219
|
)
|
|
1232
1220
|
}
|
|
1233
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* The `services` Section for this push, or undefined when there is nothing to send:
|
|
1224
|
+
* the caller's rows when it passes them (the site's, read just now, with the asks it
|
|
1225
|
+
* decided to send applied), else the file's asks over the record of the site's rows.
|
|
1226
|
+
*
|
|
1227
|
+
* @param {object} siteYml
|
|
1228
|
+
* @param {object|null} provisioned - the backend's entry in `sync.json`
|
|
1229
|
+
* @param {object[]} [serviceRows]
|
|
1230
|
+
* @returns {object[]|undefined}
|
|
1231
|
+
*/
|
|
1232
|
+
function requestedServices(siteYml, provisioned, serviceRows) {
|
|
1233
|
+
if (Array.isArray(serviceRows)) return servicesNested(serviceRows)
|
|
1234
|
+
const asks = readServicesRequest(siteYml.services)
|
|
1235
|
+
if (!asks) return undefined
|
|
1236
|
+
const base = Array.isArray(provisioned?.services)
|
|
1237
|
+
? provisioned.services
|
|
1238
|
+
: provisioned?.site?.uuid
|
|
1239
|
+
? null
|
|
1240
|
+
: []
|
|
1241
|
+
return base ? servicesNested(mergeServiceRows(base, asks)) : undefined
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1234
1244
|
/**
|
|
1235
1245
|
* One secret per `(service, name)` — the pair the backend merges on. A site-level
|
|
1236
1246
|
* secret belongs to no service, so `service` is optional and the handle degrades to
|
|
@@ -1342,12 +1352,14 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1342
1352
|
// carries the translation collector with it.
|
|
1343
1353
|
setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
|
|
1344
1354
|
|
|
1345
|
-
// Authored service declarations. ⛔ These must NOT be filed with the
|
|
1346
|
-
// Section
|
|
1347
|
-
//
|
|
1348
|
-
// a
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1355
|
+
// Authored service declarations. ⛔ These must NOT be filed with the `services`
|
|
1356
|
+
// Section. Here they are the SITE tier, `config.<name>` — the address
|
|
1357
|
+
// `@uniweb/core`'s `resolveService` uses wherever the host offers none. That
|
|
1358
|
+
// Section is a provisioning request whose rows reach neither tier, so filed there
|
|
1359
|
+
// a site's own search endpoint would reach no page, and on a host whose services
|
|
1360
|
+
// block does not name search the site would lose search with no error and no
|
|
1361
|
+
// message (`core/src/services.js`). *(Until 2026-10-06 this called that Section
|
|
1362
|
+
// "the HOST tier"; it is a request about what the host provides.)*
|
|
1351
1363
|
setIf(settings, 'search', siteYml.search)
|
|
1352
1364
|
setIf(settings, 'submit', siteYml.submit)
|
|
1353
1365
|
// ⛔ Credentials are stripped, not trusted — this block is published world-readable.
|
|
@@ -1403,6 +1415,10 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1403
1415
|
* known, `@/x` ships as written.
|
|
1404
1416
|
* @param {string[]} [opts.schemaless] - the queries whose records resolved no data schema
|
|
1405
1417
|
* (the records lane's `schemaless`): their declarations name no `schema`.
|
|
1418
|
+
* @param {object[]} [opts.serviceRows] - the `services` Section to send, as the caller
|
|
1419
|
+
* decided it (the site's rows with the owner's asks applied). Default: the
|
|
1420
|
+
* file's asks over the record in `sync.json`.
|
|
1421
|
+
* @param {boolean} [opts.declareServices] - `false` withholds `services` and `secrets`.
|
|
1406
1422
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1407
1423
|
* `{ $uuid?, $id, $schema, info, settings?, pages, layout_sections, extensions,
|
|
1408
1424
|
* queries, services?, secrets? }`
|
|
@@ -1558,7 +1574,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1558
1574
|
// comments above describe — there, a dropped block leaves a site with NO
|
|
1559
1575
|
// endpoint; here it leaves the site with the RIGHT answer.
|
|
1560
1576
|
//
|
|
1561
|
-
//
|
|
1577
|
+
// Asking for the `api` service is `site.yml::services` → the `services` Section (see
|
|
1578
|
+
// requestedServices); its address still comes from the host.
|
|
1562
1579
|
// ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
|
|
1563
1580
|
// (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
|
|
1564
1581
|
// renders, plus what a listing can filter on. Eighteen keys moved off it on
|
|
@@ -1722,31 +1739,22 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1722
1739
|
opts.queryFields,
|
|
1723
1740
|
Array.isArray(opts.schemaless) ? new Set(opts.schemaless) : null
|
|
1724
1741
|
)
|
|
1725
|
-
// Emitted ONLY when
|
|
1742
|
+
// Emitted ONLY when there is something to say — see the header above
|
|
1726
1743
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1727
1744
|
// requests and one of them is destructive.
|
|
1728
1745
|
//
|
|
1729
|
-
// ⭐
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
// ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
|
|
1735
|
-
// what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
|
|
1736
|
-
// OVERWRITES whatever the stored request has become since — including a decision
|
|
1737
|
-
// the owner made in the app, where the consent workflow's publish happens. But
|
|
1738
|
-
// "has it changed since we last agreed?" needs the last agreed state, which is
|
|
1739
|
-
// project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
|
|
1740
|
-
// ⇒ The CLI decides; this honours the decision.
|
|
1746
|
+
// ⭐ WHICH ASKS GO IS THE CALLER'S. Push and publish read the site's rows and the
|
|
1747
|
+
// record of the last agreement, apply only what the owner changed, and pass the
|
|
1748
|
+
// result as `opts.serviceRows` — so an ask the site has since decided otherwise is
|
|
1749
|
+
// never re-sent over that decision (`reconcileServices`). Without them, the file's
|
|
1750
|
+
// asks apply over the record, which is what `status` and a pull's re-bank compare.
|
|
1741
1751
|
//
|
|
1742
|
-
//
|
|
1743
|
-
// withholding is opt-in.
|
|
1752
|
+
// `opts.declareServices === false` withholds both Sections for THIS push.
|
|
1744
1753
|
const declare = opts.declareServices !== false
|
|
1745
|
-
// ⭐
|
|
1746
|
-
//
|
|
1747
|
-
// / `$secrets`, a single copy that could only ever describe one.
|
|
1754
|
+
// ⭐ The rows are per backend — a service is bought on the backend that sold it —
|
|
1755
|
+
// and the request is one, in `site.yml`.
|
|
1748
1756
|
const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
|
|
1749
|
-
const services = declare ?
|
|
1757
|
+
const services = declare ? requestedServices(siteYml, provisioned, opts.serviceRows) : undefined
|
|
1750
1758
|
if (services) doc.services = services
|
|
1751
1759
|
const secrets = declare ? secretsNested(provisioned) : undefined
|
|
1752
1760
|
if (secrets) doc.secrets = secrets
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -215,6 +215,8 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
215
215
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
216
216
|
* @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
|
|
217
217
|
* @param {boolean} [opts.sendAll] - bypass the prior-hash filter
|
|
218
|
+
* @param {object[]} [opts.serviceRows] - the `services` Section to send, as the
|
|
219
|
+
* caller decided it (site.js `requestedServices`)
|
|
218
220
|
* @param {Object<string,string>} [opts.itemUuids] - unit path → backend `$uuid`,
|
|
219
221
|
* stamped onto the site-content document so the backend matches our items
|
|
220
222
|
* instead of re-minting them (which deletes and recreates every page and
|
|
@@ -375,10 +377,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
375
377
|
...(Array.isArray(opts.queryFields) ? { queryFields: opts.queryFields } : {}),
|
|
376
378
|
...(Array.isArray(opts.pageFields) ? { pageFields: opts.pageFields } : {}),
|
|
377
379
|
...(Array.isArray(opts.settingsFields) ? { settingsFields: opts.settingsFields } : {}),
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
380
|
+
// The `services` Section as the caller decided it — the site's rows with the
|
|
381
|
+
// owner's changed asks applied — or the Sections withheld. Passed through
|
|
382
|
+
// rather than decided here: the site's rows and the last agreement are what
|
|
383
|
+
// the CLI reads. See site.js at `requestedServices`.
|
|
384
|
+
...(Array.isArray(opts.serviceRows) ? { serviceRows: opts.serviceRows } : {}),
|
|
382
385
|
...(opts.declareServices === false ? { declareServices: false } : {})
|
|
383
386
|
})
|
|
384
387
|
: null
|