sygnal 6.2.0 → 6.3.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +164 -3
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +164 -3
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/agent.md +7 -1
  15. package/dist/guide/ai-cards.md +319 -0
  16. package/dist/guide/ai-chat.md +4 -0
  17. package/dist/guide/ai-compose.md +881 -0
  18. package/dist/guide/ai-decisions.md +16 -0
  19. package/dist/guide/http.md +1 -0
  20. package/dist/index.cjs.js +5695 -347
  21. package/dist/index.cjs.js.map +1 -1
  22. package/dist/index.d.ts +638 -8
  23. package/dist/index.esm.js +5685 -347
  24. package/dist/index.esm.js.map +1 -1
  25. package/dist/sygnal.min.js +1 -1
  26. package/dist/sygnal.min.js.map +1 -1
  27. package/dist/vike/config/package.json +1 -1
  28. package/dist/vike/onRenderClient.cjs.js +3 -2
  29. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  30. package/dist/vike/onRenderClient.mjs +3 -2
  31. package/dist/vike/onRenderClient.mjs.map +1 -1
  32. package/llms.txt +6 -4
  33. package/package.json +2 -1
  34. package/src/ai.d.ts +642 -7
  35. package/src/ai.ts +6 -0
  36. package/src/collection.ts +2 -1
  37. package/src/core/instance.ts +6 -3
  38. package/src/core/markers/lazy.ts +7 -3
  39. package/src/core/markers/portal.ts +5 -2
  40. package/src/core/markers/suspense.ts +3 -2
  41. package/src/core/markers/transition.ts +6 -3
  42. package/src/core/registry.ts +9 -5
  43. package/src/cycle/dom/IsolateModule.ts +25 -22
  44. package/src/extra/ai/chat/behavior.ts +354 -81
  45. package/src/extra/ai/chat/driver.ts +7 -4
  46. package/src/extra/ai/compose/agent.ts +55 -0
  47. package/src/extra/ai/compose/ask.ts +147 -0
  48. package/src/extra/ai/compose/behavior.ts +183 -0
  49. package/src/extra/ai/compose/catalog.ts +211 -0
  50. package/src/extra/ai/compose/composed.ts +61 -0
  51. package/src/extra/ai/compose/decisions.ts +1649 -0
  52. package/src/extra/ai/compose/diag.ts +33 -0
  53. package/src/extra/ai/compose/index.ts +23 -0
  54. package/src/extra/ai/compose/layout.ts +441 -0
  55. package/src/extra/ai/compose/llm.ts +1120 -0
  56. package/src/extra/ai/compose/render.ts +159 -0
  57. package/src/extra/ai/compose/testing.ts +71 -0
  58. package/src/extra/ai/compose/types.ts +159 -0
  59. package/src/extra/ai/decide.ts +97 -1
  60. package/src/extra/ai/index.ts +6 -1
  61. package/src/extra/diagnostics/checks/next.ts +86 -2
  62. package/src/extra/diagnostics/codes.ts +54 -0
  63. package/src/extra/fetchDriver.ts +58 -27
  64. package/src/extra/lens.ts +15 -0
  65. package/src/extra/ssr.ts +3 -3
  66. package/src/extra/testing.ts +2 -0
  67. package/src/extra/widget.ts +3 -3
  68. package/src/index.d.ts +41 -0
  69. package/src/index.ts +8 -0
  70. package/src/lazy.ts +3 -2
  71. package/src/portal.ts +5 -2
  72. package/src/pragma/index.ts +2 -1
  73. package/src/suspense.ts +5 -2
  74. package/src/switchable.ts +2 -1
  75. package/src/transition.ts +5 -2
  76. package/src/vike/onRenderClient.ts +3 -2
@@ -0,0 +1,33 @@
1
+ /*
2
+ * PLAN-9 X-1: the compose modules' runtime diagnostics. Reported through report() with the
3
+ * severity passed (the codes are in DEV_CODE_SEVERITY, so the main bundle's table doesn't grow):
4
+ * a no-op with diagnostics off, so they are dev-only, as the agent layer's are. 'error' mode
5
+ * rethrows outside the caller, so a check never breaks the composition.
6
+ *
7
+ * SYG161 error a container's `render` is a component with a model (its children would stop
8
+ * being the host's)
9
+ * SYG162 error a part with neither `component` nor `render`
10
+ * SYG250 warn a layout names an unknown part or slot: the node is dropped
11
+ * SYG251 warn an invalid param value in a layout: the default is used (a record param: the
12
+ * node is dropped)
13
+ * SYG252 warn a record param's `from` doesn't return an array of `{ value }` (or threw)
14
+ * SYG450 warn `in` names an unknown container or slot
15
+ * SYG451 warn `<Composed>` without a catalog, `from` not a composition, `card` not a tool part
16
+ * SYG452 warn a node key listed twice in a layout (only the first place is kept)
17
+ * SYG681 error a backend option problem (compose.decisions() with no `model`; no backend or catalog: thrown)
18
+ */
19
+ import {report} from '../../diagnostics/index'
20
+
21
+ const said = /*#__PURE__*/ new WeakMap<object, Set<string>>()
22
+
23
+ /** report `code` (once per `owner` and `what` when an owner is given) */
24
+ export const dev = (code: string, component: string, message: string, severity: 'error' | 'warn' | 'info' = 'warn', owner?: object, what?: string): void => {
25
+ if (owner) {
26
+ let s = said.get(owner)
27
+ if (!s) said.set(owner, s = new Set())
28
+ const k = code + (what ?? message)
29
+ if (s.has(k)) return
30
+ s.add(k)
31
+ }
32
+ try { report(code, {component, message, severity}) } catch (e) { queueMicrotask(() => { throw e }) }
33
+ }
@@ -0,0 +1,23 @@
1
+ /*
2
+ * PLAN-9: composing UI from prepared parts (phase1-contract.md). `defineCatalog` (K-1),
3
+ * `catalog.render` (K-2), the layout utilities (K-3), the `compose` behavior (C-1) with its
4
+ * backends chained on it (D451) and `compose.answer` (D462), and `composeWith` (T-1). Side-effect
5
+ * free; exported from src/extra/ai/index.ts (and so from 'sygnal/ai').
6
+ */
7
+ import {composeBehavior} from './behavior'
8
+ import {decisions} from './decisions'
9
+ import {llm} from './llm'
10
+ import {composeAction, composeSummary} from './agent'
11
+
12
+ /** a result part's PARENT value: `{ scope, node, value }` (the answer for the card it is in) */
13
+ export const answer = (props: any, value: any) => ({scope: props?.scope, node: props?.node, value})
14
+
15
+ /** compose(catalog, { backend, form?, prompt?, key? }) + `.decisions()`, `.llm()`, `.answer()`, `.action()` / `.summary()` (C-2) */
16
+ export const compose = /*#__PURE__*/ Object.assign((catalog: any, options?: any) => composeBehavior(catalog, options), {decisions, llm, answer, action: composeAction, summary: composeSummary})
17
+
18
+ export {defineCatalog} from './catalog'
19
+ export {fromTree, toTree, removeNode} from './layout'
20
+ export {composeWith} from './testing'
21
+ export {askCard} from './ask'
22
+ // M-1 (D466): <Composed catalog from />, registered when a tag uses it (G-751)
23
+ export {Composed} from './composed'
@@ -0,0 +1,441 @@
1
+ /*
2
+ * PLAN-9 K-3: the layout utilities, shared by both backends (phase1-contract.md).
3
+ *
4
+ * Layout: `{ root: 'page', nodes: { [key]: { part, params?, slots?: { [slot]: [key] } } } }`.
5
+ * Node keys are identities: the part id; `part:recordValue` for a record node; repeats `~2`, `~3`;
6
+ * containers by id (`row`, `row~2`, `tabs`). `params` holds only non-default values.
7
+ *
8
+ * - fromTree(catalog, tree, previous?): a nested tree (what a model writes, what tests use) → a
9
+ * layout. Keys come from `previous` where they can: the same (part, params) first, then the same
10
+ * identity (a param change keeps the node key, so its instance and state), then new ones.
11
+ * Children given as an array go into the container's `default` slot (else its first);
12
+ * `{ slot: [...] }` names them. Not validated: finish() does that.
13
+ * - toTree(layout): the inverse (keys dropped).
14
+ * - removeNode(layout, key): the node and its subtree removed (not finished).
15
+ * - finish(catalog, layout, state): the deterministic finishing step, idempotent:
16
+ * 1. validation (reported, ./diag.ts): an unknown node, part or slot is dropped (SYG250; the
17
+ * children of an unknown container or slot take its place); an invalid param value falls
18
+ * back to the default, an invalid or missing record (or a missing required text / list) drops
19
+ * the node (SYG251); a key listed twice keeps its first place (SYG452; also the cycle guard);
20
+ * 2. then, repeated until nothing changes (at most 6 rounds): required parts (at the end of the
21
+ * page; parts that need a record or a required text are never added), `companions` (right
22
+ * after their part), `maxUses`, `in` (a node outside its places moves to its first place
23
+ * that exists, else into a new container of its first place; rows are transparent: a part
24
+ * in a row is where the row is; a row in an `arranged` slot or in a row is unwrapped),
25
+ * `maxDepth` (a container deeper than it moves to the page), `maxParts` (the last
26
+ * non-required parts go), empty containers removed, `minSlots` (a container with fewer
27
+ * filled slots, or a slot with fewer than its `min` children, is replaced by its children
28
+ * when they may go where it is; a single-part row unwrapped), a container's `max` per slot
29
+ * (the extra children go after it; an over-full row is split into even rows), `accepts:
30
+ * 'leaves'` (nested containers go after it), then `pin` (start / end of their slot).
31
+ * `maxUses` counts per identity (`part`, `part:record`); in a `group` the first part (depth
32
+ * first) wins, the group's other parts go.
33
+ * Container params are checked like a part's.
34
+ */
35
+ import {dev} from './diag'
36
+ import type {Catalog, Container, Layout, Node, Part, Tree} from './types'
37
+
38
+ /** a node of the working tree */
39
+ interface T {key: string; part: string; params?: Record<string, any>; slots?: Record<string, T[]>}
40
+
41
+ const own = (o: any, k: string) => !!o && Object.prototype.hasOwnProperty.call(o, k)
42
+ const same = (a: any, b: any) => a === b || (a !== null && typeof a == 'object' && JSON.stringify(a) === JSON.stringify(b))
43
+
44
+ export const emptyLayout = (): Layout => ({root: 'page', nodes: {page: {part: 'page', slots: {default: []}}}})
45
+
46
+ /** a node's identity: the part id, or `part:recordValue` for a record part */
47
+ export function identityOf(cat: Catalog, part: string, params?: Record<string, any> | null): string {
48
+ const e: any = cat.get(part)
49
+ const r = e?.kind == 'part' && e.record
50
+ return r && params && params[r] != null ? `${part}:${params[r]}` : part
51
+ }
52
+
53
+ /** `base`, else `base~2`, `base~3`, … (the first not in `used`; added to it) */
54
+ function uniqueKey(base: string, used: Set<string>): string {
55
+ let k = base
56
+ for (let i = 2; used.has(k); i++) k = `${base}~${i}`
57
+ used.add(k)
58
+ return k
59
+ }
60
+
61
+ /** params without the values equal to their default (undefined when none is left) */
62
+ function stripDefaults(cat: Catalog, part: string, params: any): Record<string, any> | undefined {
63
+ if (!params || typeof params != 'object') return
64
+ const e: any = cat.get(part), out: any = {}
65
+ for (const k in params) {
66
+ const d = e?.kind == 'part' && e.params[k]
67
+ if (params[k] === undefined || (d && same(params[k], d.default))) continue
68
+ out[k] = params[k]
69
+ }
70
+ return Object.keys(out).length ? out : undefined
71
+ }
72
+
73
+ /** the working tree → a layout (empty slots omitted, but the page's `default`) */
74
+ function emit(root: T): Layout {
75
+ const nodes: Record<string, Node> = {}
76
+ const visit = (t: T): string => {
77
+ const n: Node = {part: t.part}
78
+ if (t.params && Object.keys(t.params).length) n.params = t.params
79
+ if (t.slots) {
80
+ n.slots = {}
81
+ for (const s in t.slots) if (t.slots[s].length || t === root) n.slots[s] = t.slots[s].map(visit)
82
+ }
83
+ nodes[t.key] = n
84
+ return t.key
85
+ }
86
+ visit(root)
87
+ return {root: 'page', nodes}
88
+ }
89
+
90
+ // ------------------------------------------------------------------ fromTree / toTree / removeNode
91
+
92
+ /** a nested tree → a layout, reusing `previous`'s keys (see the header) */
93
+ export function fromTree(cat: Catalog, tree: Tree, previous?: Layout | null): Layout {
94
+ const items: Array<{t: T; base: string; pj: string}> = []
95
+ const build = (n: any): T | null => {
96
+ if (!n || typeof n != 'object' || typeof n.part != 'string') return null
97
+ const e: any = cat.get(n.part)
98
+ const params = stripDefaults(cat, n.part, n.params)
99
+ const t: T = {key: '', part: n.part, ...(params && {params})}
100
+ items.push({t, base: identityOf(cat, n.part, params), pj: JSON.stringify(params ?? {})})
101
+ const ch = n.children
102
+ if (e?.kind == 'container' || ch) {
103
+ t.slots = {}
104
+ const names: string[] = e?.kind == 'container' ? e.slotNames : ['default']
105
+ if (Array.isArray(ch)) t.slots[names.includes('default') ? 'default' : names[0]] = ch.map(build).filter(Boolean) as T[]
106
+ else if (ch && typeof ch == 'object') for (const s in ch) t.slots[s] = (Array.isArray(ch[s]) ? ch[s] : []).map(build).filter(Boolean) as T[]
107
+ }
108
+ return t
109
+ }
110
+ const root: T = {key: 'page', part: 'page', slots: {default: (Array.isArray(tree) ? tree : []).map(build).filter(Boolean) as T[]}}
111
+ const prev = previous?.nodes && typeof previous.nodes == 'object' ? previous.nodes : {}
112
+ const byBase = new Map<string, Array<{k: string; pj: string}>>()
113
+ for (const k in prev) {
114
+ const n = prev[k]
115
+ if (k == (previous!.root || 'page') || !n || typeof n.part != 'string') continue
116
+ const params = stripDefaults(cat, n.part, n.params), b = identityOf(cat, n.part, params)
117
+ if (!byBase.has(b)) byBase.set(b, [])
118
+ byBase.get(b)!.push({k, pj: JSON.stringify(params ?? {})})
119
+ }
120
+ const used = new Set(['page'])
121
+ const take = (match: (x: {k: string; pj: string}, pj: string) => boolean) => {
122
+ for (const it of items) {
123
+ if (it.t.key) continue
124
+ const c = byBase.get(it.base)?.find(x => !used.has(x.k) && match(x, it.pj))
125
+ if (c) { it.t.key = c.k; used.add(c.k) }
126
+ }
127
+ }
128
+ take((x, pj) => x.pj === pj)
129
+ take(() => true)
130
+ for (const it of items) if (!it.t.key) it.t.key = uniqueKey(it.base, used)
131
+ return emit(root)
132
+ }
133
+
134
+ /** a layout → the nested tree (keys dropped; a container with only `default` filled: an array) */
135
+ export function toTree(layout: Layout | null | undefined): Tree {
136
+ const nodes: any = layout?.nodes || {}
137
+ const rk: string = layout?.root || 'page', seen = new Set<string>([rk])
138
+ const walk = (k: string): any => {
139
+ const n = nodes[k]
140
+ if (seen.has(k) || !n) return null
141
+ seen.add(k)
142
+ const o: any = {part: n.part}
143
+ if (n.params && Object.keys(n.params).length) o.params = n.params
144
+ if (n.slots) {
145
+ const names = Object.keys(n.slots).filter(s => Array.isArray(n.slots[s]) && n.slots[s].length)
146
+ if (names.length == 1 && names[0] == 'default') o.children = n.slots.default.map(walk).filter(Boolean)
147
+ else if (names.length) {
148
+ o.children = {}
149
+ for (const s of names) o.children[s] = n.slots[s].map(walk).filter(Boolean)
150
+ }
151
+ }
152
+ return o
153
+ }
154
+ return (nodes[rk]?.slots?.default || []).map(walk).filter(Boolean)
155
+ }
156
+
157
+ /** the layout without `key` and its subtree (not finished) */
158
+ export function removeNode(layout: Layout, key: string): Layout {
159
+ if (!layout?.nodes || !own(layout.nodes, key) || key == layout.root) return layout
160
+ const drop = new Set<string>()
161
+ const mark = (k: string) => {
162
+ if (drop.has(k)) return
163
+ drop.add(k)
164
+ const s = layout.nodes[k]?.slots
165
+ for (const n in s) (s[n] || []).forEach(mark)
166
+ }
167
+ mark(key)
168
+ const nodes: Record<string, Node> = {}
169
+ for (const k in layout.nodes) {
170
+ if (drop.has(k)) continue
171
+ const n = layout.nodes[k]
172
+ if (!n.slots) { nodes[k] = n; continue }
173
+ const slots: Record<string, string[]> = {}
174
+ for (const s in n.slots) slots[s] = (n.slots[s] || []).filter(c => !drop.has(c))
175
+ nodes[k] = {...n, slots}
176
+ }
177
+ return {...layout, nodes}
178
+ }
179
+
180
+ // ------------------------------------------------------------------ finish
181
+
182
+ /**
183
+ * a part's params checked against its declaration (SYG251): the non-default values, or null when
184
+ * the node can't exist (no valid record; a required text or list missing)
185
+ */
186
+ function checkParams(cat: Catalog, part: Part | Container, raw: any, state: any, key: string): Record<string, any> | null {
187
+ const bad = (msg: string) => dev('SYG251', 'compose', `node '${key}' (${part.id}): ${msg}`)
188
+ const p = raw && typeof raw == 'object' ? raw : {}
189
+ const out: Record<string, any> = {}
190
+ for (const name in p) if (!own(part.params, name)) bad(`unknown param '${name}'; it is dropped`)
191
+ for (const name in part.params) {
192
+ const d: any = part.params[name]
193
+ let v = p[name]
194
+ const needed = d.kind == 'record' || ((d.kind == 'text' || d.kind == 'list') && d.required && d.default === undefined)
195
+ if (v == null || (needed && d.kind == 'text' && v === '')) {
196
+ if (needed) { bad(d.kind == 'record' ? `no record for '${name}'; the node is dropped` : `the required param '${name}' is missing; the node is dropped`); return null }
197
+ continue
198
+ }
199
+ if (d.kind == 'record') {
200
+ const r = cat.records(part as Part, state).find((x: any) => String(x.value) === String(v))
201
+ if (!r) { bad(`'${v}' is not one of the records of '${name}'; the node is dropped`); return null }
202
+ v = r.value
203
+ } else if (d.kind == 'options') {
204
+ if (!own(d.options, String(v))) { bad(`'${v}' is not an option of '${name}'; the default is used`); continue }
205
+ } else if (d.kind == 'number') {
206
+ if (typeof v == 'string' && v.trim() && isFinite(+v)) v = +v
207
+ if (typeof v != 'number' || !isFinite(v)) { bad(`'${name}' is not a number; the default is used`); continue }
208
+ const c = Math.min(d.max ?? Infinity, Math.max(d.min ?? -Infinity, v))
209
+ if (c !== v) { bad(`'${name}' ${v} is outside ${d.min ?? '-∞'}…${d.max ?? '∞'}; it is clamped to ${c}`); v = c }
210
+ } else if (d.kind == 'text') {
211
+ if (typeof v != 'string') { bad(`'${name}' is not text; the default is used`); if (needed) return null; continue }
212
+ if (d.maxLength && v.length > d.maxLength) v = v.slice(0, d.maxLength)
213
+ } else if (d.kind == 'list') {
214
+ if (!Array.isArray(v)) { bad(`'${name}' is not a list; the default is used`); if (needed) return null; continue }
215
+ const n = d.items?.maxLength
216
+ v = v.filter((x: any) => typeof x == 'string').map((x: string) => n && x.length > n ? x.slice(0, n) : x)
217
+ if (d.max != null) v = v.slice(0, d.max)
218
+ if (d.min != null && v.length < d.min) { bad(`'${name}' has fewer than ${d.min} items; the default is used`); if (needed) return null; continue }
219
+ }
220
+ if (!same(v, d.default)) out[name] = v
221
+ }
222
+ return out
223
+ }
224
+
225
+ /** step 1: the layout as a working tree, validated and reported */
226
+ function validate(cat: Catalog, layout: any, state: any): T {
227
+ const nodes = layout?.nodes && typeof layout.nodes == 'object' ? layout.nodes : {}
228
+ const rk = typeof layout?.root == 'string' && nodes[layout.root] ? layout.root : 'page'
229
+ const root: T = {key: 'page', part: 'page', slots: {default: []}}
230
+ const seen = new Set([rk, 'page'])
231
+ const place = (keys: any, into: T[]) => { if (Array.isArray(keys)) for (const k of keys) visit(String(k), into) }
232
+ const visit = (k: string, into: T[]) => {
233
+ if (seen.has(k)) {
234
+ dev('SYG452', 'compose', `the layout lists the node key '${k}' ${k == rk ? 'inside the page' : 'twice (or inside itself)'}; only its first place is kept`)
235
+ return
236
+ }
237
+ const n = nodes[k]
238
+ if (!n || typeof n != 'object') { dev('SYG250', 'compose', `the layout lists '${k}', which is not one of its nodes; it is dropped`); return }
239
+ seen.add(k)
240
+ const e: any = cat.get(n.part), kids = n.slots && typeof n.slots == 'object' ? n.slots : null
241
+ if (!e || e.id == 'page') {
242
+ dev('SYG250', 'compose', `node '${k}' names ${e ? 'the page' : `an unknown part '${n.part}'`}; it is dropped${kids ? ' (its children take its place)' : ''}`)
243
+ for (const s in kids) place(kids[s], into)
244
+ return
245
+ }
246
+ if (e.kind == 'part') {
247
+ const params = checkParams(cat, e, n.params, state, k)
248
+ if (params) into.push({key: k, part: e.id, ...(Object.keys(params).length && {params})})
249
+ if (kids && Object.keys(kids).some(s => Array.isArray(kids[s]) && kids[s].length)) {
250
+ dev('SYG250', 'compose', `node '${k}' (${e.id}) is a part, which has no slots; its children take its place`)
251
+ for (const s in kids) place(kids[s], into)
252
+ }
253
+ return
254
+ }
255
+ const cp = Object.keys(e.params).length ? checkParams(cat, e, n.params, state, k) : null
256
+ const t: T = {key: k, part: e.id, ...(cp && Object.keys(cp).length && {params: cp}), slots: {}}
257
+ for (const s of e.slotNames) t.slots![s] = []
258
+ into.push(t)
259
+ for (const s in kids) {
260
+ if (own(e.slots, s)) place(kids[s], t.slots![s])
261
+ else if (Array.isArray(kids[s]) && kids[s].length) {
262
+ dev('SYG250', 'compose', `node '${k}' (${e.id}) has no slot '${s}'; its children go into '${e.slotNames[0]}'`)
263
+ place(kids[s], t.slots![e.slotNames[0]])
264
+ }
265
+ }
266
+ }
267
+ const rs = nodes[rk]?.slots
268
+ for (const s in rs) {
269
+ if (s != 'default' && Array.isArray(rs[s]) && rs[s].length) dev('SYG250', 'compose', `the page has no slot '${s}'; its children go into 'default'`)
270
+ place(rs[s], root.slots!.default)
271
+ }
272
+ return root
273
+ }
274
+
275
+ /** visit every node below the root: (t, its array, index, parent, slot, effective place, depth) */
276
+ type Visit = (t: T, arr: T[], i: number, parent: T, slot: string, eff: string, depth: number) => void
277
+ function each(root: T, f: Visit) {
278
+ const go = (t: T, eff: (s: string) => string, depth: number) => {
279
+ for (const s in t.slots) {
280
+ const arr = t.slots[s]
281
+ for (let i = 0; i < arr.length; i++) {
282
+ const c = arr[i]
283
+ f(c, arr, i, t, s, eff(s), depth + 1)
284
+ if (c.slots) go(c, c.part == 'row' ? () => eff(s) : (x: string) => c.part + ':' + x, depth + 1)
285
+ }
286
+ }
287
+ }
288
+ go(root, (s: string) => 'page:' + s, 0)
289
+ }
290
+
291
+ const without = (arr: T[], t: T) => { const i = arr.indexOf(t); if (i > -1) arr.splice(i, 1) }
292
+
293
+ /** a part a finishing step may add (needs no record and no required free text) */
294
+ const addable = (p: Part) => !p.record && !Object.values(p.params).some((d: any) => (d.kind == 'text' || d.kind == 'list') && d.required && d.default === undefined)
295
+
296
+ /** step 2: one round of the deterministic rules */
297
+ function round(cat: Catalog, root: T) {
298
+ const used = new Set<string>(['page'])
299
+ each(root, t => { used.add(t.key) })
300
+ const make = (part: string): T => {
301
+ const e: any = cat.get(part)
302
+ const t: T = {key: uniqueKey(part, used), part}
303
+ if (e?.kind == 'container') { t.slots = {}; for (const s of e.slotNames) t.slots[s] = [] }
304
+ return t
305
+ }
306
+ const present = new Set<string>()
307
+ each(root, t => { present.add(t.part) })
308
+ // required parts, then companions
309
+ for (const id in cat.parts) {
310
+ const p = cat.parts[id]
311
+ if (p.required && !present.has(id) && addable(p)) { root.slots!.default.push(make(id)); present.add(id) }
312
+ }
313
+ const leaves: Array<[T, T[]]> = []
314
+ each(root, (t, arr) => { if (!t.slots) leaves.push([t, arr]) })
315
+ for (const [t, arr] of leaves) {
316
+ let at = arr.indexOf(t)
317
+ for (const c of cat.parts[t.part]?.companions || []) {
318
+ if (present.has(c) || !addable(cat.parts[c])) continue
319
+ arr.splice(++at, 0, make(c))
320
+ present.add(c)
321
+ }
322
+ }
323
+ // maxUses (per identity: `part`, or `part:record`), then groups (the first part of a group wins)
324
+ const uses = new Map<string, number>(), over: Array<[T, T[]]> = [], groups = new Map<string, string>()
325
+ each(root, (t, arr) => {
326
+ const p = cat.parts[t.part]
327
+ if (!p) return
328
+ const id = identityOf(cat, t.part, t.params), n = (uses.get(id) || 0) + 1
329
+ uses.set(id, n)
330
+ if (p.group) { if (!groups.has(p.group)) groups.set(p.group, p.id); if (groups.get(p.group) != p.id) { over.push([t, arr]); return } }
331
+ if (n > p.maxUses) over.push([t, arr])
332
+ })
333
+ for (const [t, arr] of over) without(arr, t)
334
+ // `in` (rows are transparent; a row in an arranged slot or in a row is unwrapped)
335
+ const moves: Array<[T, T[]]> = [], unwrap: Array<[T, T[]]> = []
336
+ each(root, (t, arr, _i, parent, _s, eff) => {
337
+ if (t.part == 'row') {
338
+ const c: any = cat.get(eff.split(':')[0])
339
+ if (parent.part == 'row' || c?.arranged) unwrap.push([t, arr])
340
+ return
341
+ }
342
+ const e: any = cat.get(t.part)
343
+ if (e && !e.in.includes(eff)) moves.push([t, arr])
344
+ })
345
+ for (const [t, arr] of unwrap) {
346
+ const i = arr.indexOf(t)
347
+ if (i > -1) arr.splice(i, 1, ...(t.slots?.default || []))
348
+ }
349
+ for (const [t, arr] of moves) {
350
+ without(arr, t)
351
+ const e: any = cat.get(t.part)
352
+ let into: T[] | undefined
353
+ for (const pl of e.in) {
354
+ const [c, s] = pl.split(':')
355
+ if (c == 'page') { into = root.slots!.default; break }
356
+ let found: T | undefined
357
+ each(root, x => { if (!found && x.part == c) found = x })
358
+ if (found) { into = found.slots![s] ||= []; break }
359
+ }
360
+ if (!into) {
361
+ const [c, s] = e.in[0].split(':'), ct = make(c)
362
+ root.slots!.default.push(ct)
363
+ into = ct.slots![s]
364
+ }
365
+ into.push(t)
366
+ }
367
+ // maxDepth: a container deeper than it moves to the page
368
+ const deep: Array<[T, T[]]> = []
369
+ each(root, (t, arr, _i, _p, _s, _e, depth) => { if (t.slots && depth > cat.limits.maxDepth) deep.push([t, arr]) })
370
+ for (const [t, arr] of deep) { without(arr, t); root.slots!.default.push(t) }
371
+ // maxParts: the last non-required parts go
372
+ const parts: Array<[T, T[]]> = []
373
+ each(root, (t, arr) => { if (!t.slots) parts.push([t, arr]) })
374
+ for (let n = parts.length, i = parts.length - 1; n > cat.limits.maxParts && i >= 0; i--) {
375
+ const [t, arr] = parts[i]
376
+ if (cat.parts[t.part]?.required) continue
377
+ without(arr, t)
378
+ n--
379
+ }
380
+ // empty containers, minSlots, single-part rows (bottom up)
381
+ const clean = (t: T, effOf: (s: string) => string) => {
382
+ for (const s in t.slots) {
383
+ const out: T[] = [], eff = effOf(s)
384
+ for (const c of t.slots[s]) {
385
+ if (!c.slots) { out.push(c); continue }
386
+ clean(c, c.part == 'row' ? () => eff : (x: string) => c.part + ':' + x)
387
+ const ct: any = cat.get(c.part), after: T[] = []
388
+ // accepts: 'leaves': nested containers go after it; max: a slot's extra children too (a row
389
+ // is split into even rows instead)
390
+ for (const x in c.slots) {
391
+ if (ct?.accepts == 'leaves' && c.slots[x].some(k => k.slots)) {
392
+ after.push(...c.slots[x].filter(k => k.slots))
393
+ c.slots[x] = c.slots[x].filter(k => !k.slots)
394
+ }
395
+ const a = c.slots[x]
396
+ if (ct?.max != null && a.length > ct.max && c.part != 'row') after.push(...a.splice(ct.max))
397
+ }
398
+ const filled = Object.keys(c.slots).filter(x => c.slots![x].length)
399
+ const kids = filled.flatMap(x => c.slots![x])
400
+ const fits = kids.every(k => k.part == 'row' || (cat.get(k.part) as any)?.in.includes(eff))
401
+ if (!filled.length) { /* an empty container goes */ }
402
+ else if (c.part == 'row' && kids.length == 1) out.push(...kids)
403
+ else if (fits && ((ct?.minSlots && filled.length < ct.minSlots) || (ct?.min != null && filled.some(x => c.slots![x].length < ct.min)))) out.push(...kids)
404
+ else if (c.part == 'row' && ct?.max != null && kids.length > ct.max) {
405
+ const n = Math.ceil(kids.length / ct.max), size = Math.ceil(kids.length / n)
406
+ for (let i = 0; i < n; i++) {
407
+ const r = i ? make('row') : c
408
+ r.slots = {default: kids.slice(i * size, (i + 1) * size)}
409
+ out.push(r)
410
+ }
411
+ } else out.push(c)
412
+ out.push(...after)
413
+ }
414
+ t.slots[s] = out
415
+ }
416
+ }
417
+ clean(root, s => 'page:' + s)
418
+ // pins: start first, end last (stable)
419
+ const pin = (t: T) => (cat.parts[t.part]?.pin == 'start' ? 0 : cat.parts[t.part]?.pin == 'end' ? 2 : 1)
420
+ const order = (t: T) => {
421
+ for (const s in t.slots) {
422
+ const a = t.slots[s]
423
+ if (a.some(x => pin(x) != 1)) t.slots[s] = [...a.filter(x => pin(x) == 0), ...a.filter(x => pin(x) == 1), ...a.filter(x => pin(x) == 2)]
424
+ t.slots[s].forEach(x => x.slots && order(x))
425
+ }
426
+ }
427
+ order(root)
428
+ }
429
+
430
+ /** the deterministic finishing step (see the header); idempotent */
431
+ export function finish(cat: Catalog, layout: Layout | null | undefined, state?: any): Layout {
432
+ const root = validate(cat, layout, state)
433
+ let last = ''
434
+ for (let i = 0; i < 6; i++) {
435
+ round(cat, root)
436
+ const s = JSON.stringify(root)
437
+ if (s === last) break
438
+ last = s
439
+ }
440
+ return emit(root)
441
+ }