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.
- package/CHANGELOG.md +61 -0
- package/README.md +3 -1
- package/dist/ai.cjs.js +36 -0
- package/dist/ai.cjs.js.map +1 -1
- package/dist/ai.esm.js +1 -1
- package/dist/astro/server.cjs.js +3 -3
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -3
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +164 -3
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +164 -3
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/agent.md +7 -1
- package/dist/guide/ai-cards.md +319 -0
- package/dist/guide/ai-chat.md +4 -0
- package/dist/guide/ai-compose.md +881 -0
- package/dist/guide/ai-decisions.md +16 -0
- package/dist/guide/http.md +1 -0
- package/dist/index.cjs.js +5695 -347
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +638 -8
- package/dist/index.esm.js +5685 -347
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +3 -2
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +3 -2
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/llms.txt +6 -4
- package/package.json +2 -1
- package/src/ai.d.ts +642 -7
- package/src/ai.ts +6 -0
- package/src/collection.ts +2 -1
- package/src/core/instance.ts +6 -3
- package/src/core/markers/lazy.ts +7 -3
- package/src/core/markers/portal.ts +5 -2
- package/src/core/markers/suspense.ts +3 -2
- package/src/core/markers/transition.ts +6 -3
- package/src/core/registry.ts +9 -5
- package/src/cycle/dom/IsolateModule.ts +25 -22
- package/src/extra/ai/chat/behavior.ts +354 -81
- package/src/extra/ai/chat/driver.ts +7 -4
- package/src/extra/ai/compose/agent.ts +55 -0
- package/src/extra/ai/compose/ask.ts +147 -0
- package/src/extra/ai/compose/behavior.ts +183 -0
- package/src/extra/ai/compose/catalog.ts +211 -0
- package/src/extra/ai/compose/composed.ts +61 -0
- package/src/extra/ai/compose/decisions.ts +1649 -0
- package/src/extra/ai/compose/diag.ts +33 -0
- package/src/extra/ai/compose/index.ts +23 -0
- package/src/extra/ai/compose/layout.ts +441 -0
- package/src/extra/ai/compose/llm.ts +1120 -0
- package/src/extra/ai/compose/render.ts +159 -0
- package/src/extra/ai/compose/testing.ts +71 -0
- package/src/extra/ai/compose/types.ts +159 -0
- package/src/extra/ai/decide.ts +97 -1
- package/src/extra/ai/index.ts +6 -1
- package/src/extra/diagnostics/checks/next.ts +86 -2
- package/src/extra/diagnostics/codes.ts +54 -0
- package/src/extra/fetchDriver.ts +58 -27
- package/src/extra/lens.ts +15 -0
- package/src/extra/ssr.ts +3 -3
- package/src/extra/testing.ts +2 -0
- package/src/extra/widget.ts +3 -3
- package/src/index.d.ts +41 -0
- package/src/index.ts +8 -0
- package/src/lazy.ts +3 -2
- package/src/portal.ts +5 -2
- package/src/pragma/index.ts +2 -1
- package/src/suspense.ts +5 -2
- package/src/switchable.ts +2 -1
- package/src/transition.ts +5 -2
- 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
|
+
}
|