@maias/core 0.2.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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +46 -0
- package/dist/diagnostics.d.ts +42 -0
- package/dist/diagnostics.js +29 -0
- package/dist/edit.d.ts +84 -0
- package/dist/edit.js +321 -0
- package/dist/fmt.d.ts +27 -0
- package/dist/fmt.js +257 -0
- package/dist/graph.d.ts +29 -0
- package/dist/graph.js +93 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lint.d.ts +8 -0
- package/dist/lint.js +176 -0
- package/dist/maias-schema.gen.d.ts +1 -0
- package/dist/maias-schema.gen.js +497 -0
- package/dist/model.d.ts +104 -0
- package/dist/model.js +58 -0
- package/dist/parse.d.ts +36 -0
- package/dist/parse.js +65 -0
- package/dist/schema-validate.d.ts +3 -0
- package/dist/schema-validate.js +97 -0
- package/dist/validate.d.ts +11 -0
- package/dist/validate.js +19 -0
- package/package.json +48 -0
package/dist/edit.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { isMap, isSeq } from 'yaml';
|
|
2
|
+
import { ORDER, orderKeys } from './fmt.js';
|
|
3
|
+
import { whatLinksHere } from './graph.js';
|
|
4
|
+
const ID_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
5
|
+
/**
|
|
6
|
+
* Rename a screen, cascading to every reference: flows (entry_screen, screens),
|
|
7
|
+
* the navigation registry, and all targets (navigation items, elements, state
|
|
8
|
+
* elements). Edits go through the YAML document so comments and unrelated
|
|
9
|
+
* formatting are untouched — the diff is exactly the renamed references.
|
|
10
|
+
*/
|
|
11
|
+
export function renameScreen(parsed, oldId, newId) {
|
|
12
|
+
if (!ID_PATTERN.test(newId))
|
|
13
|
+
return { ok: false, reason: `'${newId}' is not a valid screen id (snake_case)`, changes: 0 };
|
|
14
|
+
const doc = parsed.data;
|
|
15
|
+
const screens = doc.screens ?? [];
|
|
16
|
+
if (!screens.some((s) => s?.id === oldId))
|
|
17
|
+
return { ok: false, reason: `No screen with id '${oldId}'`, changes: 0 };
|
|
18
|
+
if (screens.some((s) => s?.id === newId))
|
|
19
|
+
return { ok: false, reason: `Screen id '${newId}' already exists`, changes: 0 };
|
|
20
|
+
const paths = [];
|
|
21
|
+
screens.forEach((screen, i) => {
|
|
22
|
+
if (screen?.id === oldId)
|
|
23
|
+
paths.push(['screens', i, 'id']);
|
|
24
|
+
});
|
|
25
|
+
(doc.app?.flows ?? []).forEach((flow, i) => {
|
|
26
|
+
if (flow?.entry_screen === oldId)
|
|
27
|
+
paths.push(['app', 'flows', i, 'entry_screen']);
|
|
28
|
+
(flow?.screens ?? []).forEach((id, j) => {
|
|
29
|
+
if (id === oldId)
|
|
30
|
+
paths.push(['app', 'flows', i, 'screens', j]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
['primary', 'secondary'].forEach((group) => {
|
|
34
|
+
(doc.app?.navigation?.[group]?.screens ?? []).forEach((id, j) => {
|
|
35
|
+
if (id === oldId)
|
|
36
|
+
paths.push(['app', 'navigation', group, 'screens', j]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
for (const ref of whatLinksHere(doc, oldId))
|
|
40
|
+
paths.push(ref.path);
|
|
41
|
+
for (const path of paths)
|
|
42
|
+
parsed.ydoc.setIn(path, newId);
|
|
43
|
+
return { ok: true, changes: paths.length };
|
|
44
|
+
}
|
|
45
|
+
/** Add a screen and register it. The screen object should follow canonical key order (use fmt afterwards to be sure). */
|
|
46
|
+
export function addScreen(parsed, screen, opts = {}) {
|
|
47
|
+
const registry = opts.registry ?? 'secondary';
|
|
48
|
+
const doc = parsed.data;
|
|
49
|
+
if (!ID_PATTERN.test(screen?.id ?? ''))
|
|
50
|
+
return { ok: false, reason: `'${screen?.id}' is not a valid screen id (snake_case)`, changes: 0 };
|
|
51
|
+
if ((doc.screens ?? []).some((s) => s?.id === screen.id))
|
|
52
|
+
return { ok: false, reason: `Screen id '${screen.id}' already exists`, changes: 0 };
|
|
53
|
+
if (opts.flow && !(doc.app?.flows ?? []).some((f) => f?.name === opts.flow)) {
|
|
54
|
+
return { ok: false, reason: `No flow named '${opts.flow}'`, changes: 0 };
|
|
55
|
+
}
|
|
56
|
+
let changes = 0;
|
|
57
|
+
parsed.ydoc.addIn(['screens'], parsed.ydoc.createNode(screen));
|
|
58
|
+
changes++;
|
|
59
|
+
parsed.ydoc.addIn(['app', 'navigation', registry, 'screens'], parsed.ydoc.createNode(screen.id));
|
|
60
|
+
changes++;
|
|
61
|
+
if (opts.flow) {
|
|
62
|
+
const flowIndex = (doc.app?.flows ?? []).findIndex((f) => f?.name === opts.flow);
|
|
63
|
+
parsed.ydoc.addIn(['app', 'flows', flowIndex, 'screens'], parsed.ydoc.createNode(screen.id));
|
|
64
|
+
changes++;
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, changes };
|
|
67
|
+
}
|
|
68
|
+
/** Remove a screen plus its registry and flow entries. Safe by default: refuses to leave dangling targets. */
|
|
69
|
+
export function removeScreen(parsed, id, opts = {}) {
|
|
70
|
+
const doc = parsed.data;
|
|
71
|
+
if (!(doc.screens ?? []).some((s) => s?.id === id)) {
|
|
72
|
+
return { ok: false, reason: `No screen with id '${id}'`, changes: 0, inboundRefs: [] };
|
|
73
|
+
}
|
|
74
|
+
const inboundRefs = whatLinksHere(doc, id);
|
|
75
|
+
if (inboundRefs.length > 0 && !opts.cascade) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: `${inboundRefs.length} reference(s) point at '${id}' (from: ${[...new Set(inboundRefs.map((r) => r.from))].join(', ')}) — remove them or pass cascade`,
|
|
79
|
+
changes: 0,
|
|
80
|
+
inboundRefs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Collect every deletion as a path, then apply children-first / high-index-first
|
|
84
|
+
// so earlier deletions never shift later ones.
|
|
85
|
+
const deletions = [];
|
|
86
|
+
(doc.screens ?? []).forEach((screen, i) => {
|
|
87
|
+
if (screen?.id === id)
|
|
88
|
+
deletions.push(['screens', i]);
|
|
89
|
+
});
|
|
90
|
+
(doc.app?.flows ?? []).forEach((flow, i) => {
|
|
91
|
+
(flow?.screens ?? []).forEach((sid, j) => {
|
|
92
|
+
if (sid === id)
|
|
93
|
+
deletions.push(['app', 'flows', i, 'screens', j]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
['primary', 'secondary'].forEach((group) => {
|
|
97
|
+
(doc.app?.navigation?.[group]?.screens ?? []).forEach((sid, j) => {
|
|
98
|
+
if (sid === id)
|
|
99
|
+
deletions.push(['app', 'navigation', group, 'screens', j]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
for (const ref of inboundRefs) {
|
|
103
|
+
if (ref.kind === 'element' || ref.kind === 'state_element') {
|
|
104
|
+
// The element survives, it just no longer navigates.
|
|
105
|
+
deletions.push(ref.path);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Navigation items and back links are objects around their target; delete the whole thing.
|
|
109
|
+
deletions.push(ref.path.slice(0, -1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
deletions
|
|
113
|
+
.sort(comparePathsForDeletion)
|
|
114
|
+
.forEach((path) => parsed.ydoc.deleteIn(path));
|
|
115
|
+
return { ok: true, changes: deletions.length, inboundRefs };
|
|
116
|
+
}
|
|
117
|
+
const STATE_KEYS = ['empty', 'loading', 'error'];
|
|
118
|
+
/**
|
|
119
|
+
* Edit a screen's `elements` list (or a declared state's list) in place:
|
|
120
|
+
* insert, update, remove, move. The batch is atomic — every op is checked
|
|
121
|
+
* against a simulated copy first, so a failing op leaves the document
|
|
122
|
+
* untouched. Ops apply sequentially: each op's `index` addresses the list as
|
|
123
|
+
* left by the previous ops. Safe by default: an op that would introduce a
|
|
124
|
+
* `target` not matching any screen is refused, as is editing a state the
|
|
125
|
+
* screen does not declare (declaring states is a screen-shape decision).
|
|
126
|
+
*/
|
|
127
|
+
export function editElements(parsed, screenId, ops) {
|
|
128
|
+
const doc = parsed.data;
|
|
129
|
+
const screens = doc.screens ?? [];
|
|
130
|
+
const screenIdx = screens.findIndex((s) => s?.id === screenId);
|
|
131
|
+
if (screenIdx === -1)
|
|
132
|
+
return { ok: false, reason: `No screen with id '${screenId}'`, changes: 0 };
|
|
133
|
+
if (ops.length === 0)
|
|
134
|
+
return { ok: false, reason: 'No operations given', changes: 0 };
|
|
135
|
+
const screen = screens[screenIdx];
|
|
136
|
+
const screenIds = new Set(screens.map((s) => s?.id));
|
|
137
|
+
// Pass 1 — validate every op against simulated lists; nothing is written unless all pass.
|
|
138
|
+
const sim = new Map();
|
|
139
|
+
const listFor = (state) => {
|
|
140
|
+
const key = state ?? '';
|
|
141
|
+
if (!sim.has(key)) {
|
|
142
|
+
const source = state ? screen.states?.[state]?.elements : screen.elements;
|
|
143
|
+
sim.set(key, (source ?? []).map((e) => ({ type: e?.type, label: e?.label })));
|
|
144
|
+
}
|
|
145
|
+
return sim.get(key);
|
|
146
|
+
};
|
|
147
|
+
for (let i = 0; i < ops.length; i++) {
|
|
148
|
+
const o = ops[i];
|
|
149
|
+
const listName = o.state ? `states.${o.state}.elements` : 'elements';
|
|
150
|
+
const fail = (message) => ({ ok: false, reason: `op ${i} (${o.op}): ${message}`, changes: 0 });
|
|
151
|
+
if (o.state !== undefined) {
|
|
152
|
+
if (!STATE_KEYS.includes(o.state))
|
|
153
|
+
return fail(`'${o.state}' is not a state (empty | loading | error)`);
|
|
154
|
+
const variant = screen.states?.[o.state];
|
|
155
|
+
if (variant == null || typeof variant !== 'object') {
|
|
156
|
+
return fail(`screen '${screenId}' does not declare state '${o.state}' — add the state to the screen first`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const list = listFor(o.state);
|
|
160
|
+
if (!Number.isInteger(o.index) || o.index < 0)
|
|
161
|
+
return fail(`index must be a non-negative integer, got ${o.index}`);
|
|
162
|
+
// Address check: insert may point one past the end (append); everything else needs an existing element.
|
|
163
|
+
const maxIndex = o.op === 'insert' ? list.length : list.length - 1;
|
|
164
|
+
if (o.index > maxIndex) {
|
|
165
|
+
return fail(`index ${o.index} out of range — ${listName} of '${screenId}' has ${list.length} element(s)`);
|
|
166
|
+
}
|
|
167
|
+
if (o.op !== 'insert' && o.expect) {
|
|
168
|
+
const found = list[o.index];
|
|
169
|
+
const mismatch = (o.expect.type !== undefined && o.expect.type !== found.type) ||
|
|
170
|
+
(o.expect.label !== undefined && o.expect.label !== found.label);
|
|
171
|
+
if (mismatch) {
|
|
172
|
+
return fail(`guard mismatch at ${listName}[${o.index}] of '${screenId}' — found type '${found.type}', label '${found.label}'`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
switch (o.op) {
|
|
176
|
+
case 'insert': {
|
|
177
|
+
const shapeError = checkElementFields(o.element, { requireType: true });
|
|
178
|
+
if (shapeError)
|
|
179
|
+
return fail(shapeError);
|
|
180
|
+
const target = o.element?.target;
|
|
181
|
+
if (typeof target === 'string' && !screenIds.has(target)) {
|
|
182
|
+
return fail(`target '${target}' does not match any screen`);
|
|
183
|
+
}
|
|
184
|
+
list.splice(o.index, 0, { type: o.element.type, label: o.element.label });
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'update': {
|
|
188
|
+
if (!o.set || Object.keys(o.set).length === 0)
|
|
189
|
+
return fail('set must contain at least one key');
|
|
190
|
+
if ('type' in o.set && o.set.type == null)
|
|
191
|
+
return fail(`'type' cannot be deleted`);
|
|
192
|
+
const shapeError = checkElementFields(o.set, { allowNull: true });
|
|
193
|
+
if (shapeError)
|
|
194
|
+
return fail(shapeError);
|
|
195
|
+
if (typeof o.set.target === 'string' && !screenIds.has(o.set.target)) {
|
|
196
|
+
return fail(`target '${o.set.target}' does not match any screen`);
|
|
197
|
+
}
|
|
198
|
+
const el = list[o.index];
|
|
199
|
+
if ('type' in o.set)
|
|
200
|
+
el.type = o.set.type;
|
|
201
|
+
if ('label' in o.set)
|
|
202
|
+
el.label = o.set.label ?? undefined;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'remove':
|
|
206
|
+
list.splice(o.index, 1);
|
|
207
|
+
break;
|
|
208
|
+
case 'move': {
|
|
209
|
+
if (!Number.isInteger(o.to) || o.to < 0 || o.to >= list.length) {
|
|
210
|
+
return fail(`'to' ${o.to} out of range — ${listName} of '${screenId}' has ${list.length} element(s)`);
|
|
211
|
+
}
|
|
212
|
+
const [el] = list.splice(o.index, 1);
|
|
213
|
+
list.splice(o.to, 0, el);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
default:
|
|
217
|
+
return fail(`unknown operation '${o.op}'`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Pass 2 — apply to the YAML document (comments and unrelated formatting untouched).
|
|
221
|
+
const screenPath = ['screens', screenIdx];
|
|
222
|
+
const seqPath = (state) => state ? [...screenPath, 'states', state, 'elements'] : [...screenPath, 'elements'];
|
|
223
|
+
const seqAt = (state) => {
|
|
224
|
+
const node = parsed.ydoc.getIn(seqPath(state));
|
|
225
|
+
return isSeq(node) ? node : undefined;
|
|
226
|
+
};
|
|
227
|
+
for (const o of ops) {
|
|
228
|
+
switch (o.op) {
|
|
229
|
+
case 'insert': {
|
|
230
|
+
const node = parsed.ydoc.createNode(canonicalElementLiteral(o.element));
|
|
231
|
+
const seq = seqAt(o.state);
|
|
232
|
+
if (seq) {
|
|
233
|
+
seq.items.splice(o.index, 0, node);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// No `elements` key yet (screen without one, or documentation-only state):
|
|
237
|
+
// create it, then restore canonical key order in the parent map.
|
|
238
|
+
parsed.ydoc.setIn(seqPath(o.state), parsed.ydoc.createNode([]));
|
|
239
|
+
seqAt(o.state).items.splice(0, 0, node);
|
|
240
|
+
const parentPath = o.state ? [...screenPath, 'states', o.state] : screenPath;
|
|
241
|
+
const parent = parsed.ydoc.getIn(parentPath);
|
|
242
|
+
if (isMap(parent))
|
|
243
|
+
orderKeys(parent, o.state ? ORDER.stateVariant : ORDER.screen);
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'update': {
|
|
248
|
+
const itemPath = [...seqPath(o.state), o.index];
|
|
249
|
+
for (const [key, value] of Object.entries(o.set)) {
|
|
250
|
+
if (value === null)
|
|
251
|
+
parsed.ydoc.deleteIn([...itemPath, key]);
|
|
252
|
+
else
|
|
253
|
+
parsed.ydoc.setIn([...itemPath, key], value);
|
|
254
|
+
}
|
|
255
|
+
const item = parsed.ydoc.getIn(itemPath);
|
|
256
|
+
if (isMap(item))
|
|
257
|
+
orderKeys(item, ORDER.item);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'remove': {
|
|
261
|
+
parsed.ydoc.deleteIn([...seqPath(o.state), o.index]);
|
|
262
|
+
// Omit-empty (spec §11.1): a now-empty list drops its key entirely.
|
|
263
|
+
if (seqAt(o.state)?.items.length === 0)
|
|
264
|
+
parsed.ydoc.deleteIn(seqPath(o.state));
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'move': {
|
|
268
|
+
const seq = seqAt(o.state);
|
|
269
|
+
const [node] = seq.items.splice(o.index, 1);
|
|
270
|
+
seq.items.splice(o.to, 0, node);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, changes: ops.length };
|
|
276
|
+
}
|
|
277
|
+
/** Scalar sanity for the four known element fields; deep shape errors stay validator territory. */
|
|
278
|
+
function checkElementFields(fields, opts = {}) {
|
|
279
|
+
if (!fields || typeof fields !== 'object')
|
|
280
|
+
return 'element requires an object with at least a type';
|
|
281
|
+
if (opts.requireType && (typeof fields.type !== 'string' || fields.type === '')) {
|
|
282
|
+
return `element requires a non-empty 'type'`;
|
|
283
|
+
}
|
|
284
|
+
for (const key of ['label', 'type', 'target', 'presentation']) {
|
|
285
|
+
const value = fields[key];
|
|
286
|
+
if (value === undefined)
|
|
287
|
+
continue;
|
|
288
|
+
if (value === null && opts.allowNull)
|
|
289
|
+
continue;
|
|
290
|
+
if (typeof value !== 'string')
|
|
291
|
+
return `'${key}' must be a string, got ${JSON.stringify(value)}`;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
/** Element literal with canonical key order (label, type, target, presentation, then x_ in author order). */
|
|
296
|
+
function canonicalElementLiteral(element) {
|
|
297
|
+
const out = {};
|
|
298
|
+
const source = element;
|
|
299
|
+
for (const key of ORDER.item) {
|
|
300
|
+
if (source[key] !== undefined)
|
|
301
|
+
out[key] = source[key];
|
|
302
|
+
}
|
|
303
|
+
for (const [key, value] of Object.entries(element)) {
|
|
304
|
+
if (!(key in out) && value !== undefined)
|
|
305
|
+
out[key] = value;
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/** Deeper paths first; within the same container, higher indices first. */
|
|
310
|
+
function comparePathsForDeletion(a, b) {
|
|
311
|
+
if (a.length !== b.length)
|
|
312
|
+
return b.length - a.length;
|
|
313
|
+
for (let i = 0; i < a.length; i++) {
|
|
314
|
+
if (a[i] === b[i])
|
|
315
|
+
continue;
|
|
316
|
+
if (typeof a[i] === 'number' && typeof b[i] === 'number')
|
|
317
|
+
return b[i] - a[i];
|
|
318
|
+
return String(a[i]).localeCompare(String(b[i]));
|
|
319
|
+
}
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
package/dist/fmt.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type YAMLMap } from 'yaml';
|
|
2
|
+
import type { ParsedDocument } from './parse.js';
|
|
3
|
+
/**
|
|
4
|
+
* Canonical form (spec chapter 11): fixed key order per object kind, derived
|
|
5
|
+
* screen ordering, omit-empty/default keys, quoted version header. Mutates the
|
|
6
|
+
* underlying YAML document; comments move with their nodes. Idempotent.
|
|
7
|
+
*/
|
|
8
|
+
export declare function canonicalize(parsed: ParsedDocument): ParsedDocument;
|
|
9
|
+
/** Canonical text for the document (fmt = canonicalize + serialise). */
|
|
10
|
+
export declare function format(parsed: ParsedDocument): string;
|
|
11
|
+
/** Canonical key order per object kind (spec §11.1). Shared with edit.ts; not part of the package surface. */
|
|
12
|
+
export declare const ORDER: {
|
|
13
|
+
root: string[];
|
|
14
|
+
app: string[];
|
|
15
|
+
links: string[];
|
|
16
|
+
registry: string[];
|
|
17
|
+
navGroup: string[];
|
|
18
|
+
flow: string[];
|
|
19
|
+
screen: string[];
|
|
20
|
+
screenNav: string[];
|
|
21
|
+
item: string[];
|
|
22
|
+
states: string[];
|
|
23
|
+
stateVariant: string[];
|
|
24
|
+
data: string[];
|
|
25
|
+
};
|
|
26
|
+
/** Reorder pairs: known keys in table order, then x_/unknown keys in author order. */
|
|
27
|
+
export declare function orderKeys(map: YAMLMap, order: string[]): void;
|
package/dist/fmt.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { isMap, isScalar, isSeq, Scalar } from 'yaml';
|
|
2
|
+
/**
|
|
3
|
+
* Canonical form (spec chapter 11): fixed key order per object kind, derived
|
|
4
|
+
* screen ordering, omit-empty/default keys, quoted version header. Mutates the
|
|
5
|
+
* underlying YAML document; comments move with their nodes. Idempotent.
|
|
6
|
+
*/
|
|
7
|
+
export function canonicalize(parsed) {
|
|
8
|
+
const root = parsed.ydoc.contents;
|
|
9
|
+
if (!isMap(root))
|
|
10
|
+
return parsed;
|
|
11
|
+
orderKeys(root, ORDER.root);
|
|
12
|
+
quoteVersionHeader(root);
|
|
13
|
+
forceBlockStyle(root);
|
|
14
|
+
const app = getMap(root, 'app');
|
|
15
|
+
if (app) {
|
|
16
|
+
orderKeys(app, ORDER.app);
|
|
17
|
+
const links = getMap(app, 'links');
|
|
18
|
+
if (links)
|
|
19
|
+
orderKeys(links, ORDER.links);
|
|
20
|
+
const navigation = getMap(app, 'navigation');
|
|
21
|
+
if (navigation) {
|
|
22
|
+
orderKeys(navigation, ORDER.registry);
|
|
23
|
+
for (const group of ['primary', 'secondary']) {
|
|
24
|
+
const groupMap = getMap(navigation, group);
|
|
25
|
+
if (groupMap)
|
|
26
|
+
orderKeys(groupMap, ORDER.navGroup);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const flows = getSeq(app, 'flows');
|
|
30
|
+
if (flows)
|
|
31
|
+
for (const flow of flows.items)
|
|
32
|
+
if (isMap(flow))
|
|
33
|
+
orderKeys(flow, ORDER.flow);
|
|
34
|
+
}
|
|
35
|
+
const screens = getSeq(root, 'screens');
|
|
36
|
+
if (screens) {
|
|
37
|
+
for (const screen of screens.items)
|
|
38
|
+
if (isMap(screen))
|
|
39
|
+
canonicalizeScreen(screen);
|
|
40
|
+
reorderScreens(parsed, screens);
|
|
41
|
+
syncSecondaryRegistry(parsed, root);
|
|
42
|
+
dropEmptyKeys(screens);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
/** Canonical text for the document (fmt = canonicalize + serialise). */
|
|
47
|
+
export function format(parsed) {
|
|
48
|
+
canonicalize(parsed);
|
|
49
|
+
return parsed.toString();
|
|
50
|
+
}
|
|
51
|
+
/** Canonical key order per object kind (spec §11.1). Shared with edit.ts; not part of the package surface. */
|
|
52
|
+
export const ORDER = {
|
|
53
|
+
root: ['maias', 'app', 'screens'],
|
|
54
|
+
app: ['name', 'description', 'links', 'navigation', 'flows'],
|
|
55
|
+
links: ['scheme'],
|
|
56
|
+
registry: ['primary', 'secondary'],
|
|
57
|
+
navGroup: ['label', 'screens'],
|
|
58
|
+
flow: ['name', 'description', 'entry_screen', 'screens'],
|
|
59
|
+
screen: ['id', 'title', 'type', 'path', 'description', 'presentation', 'auth', 'deep_link', 'back', 'features', 'actions', 'elements', 'states', 'navigation', 'data'],
|
|
60
|
+
screenNav: ['primary', 'secondary', 'actions'],
|
|
61
|
+
item: ['label', 'type', 'target', 'presentation', 'external'],
|
|
62
|
+
states: ['empty', 'loading', 'error'],
|
|
63
|
+
stateVariant: ['description', 'elements'],
|
|
64
|
+
data: ['reads', 'writes'],
|
|
65
|
+
};
|
|
66
|
+
function canonicalizeScreen(screen) {
|
|
67
|
+
orderKeys(screen, ORDER.screen);
|
|
68
|
+
removeDefaults(screen);
|
|
69
|
+
const back = getMap(screen, 'back');
|
|
70
|
+
if (back)
|
|
71
|
+
orderKeys(back, ORDER.item);
|
|
72
|
+
const elements = getSeq(screen, 'elements');
|
|
73
|
+
if (elements)
|
|
74
|
+
canonicalizeItems(elements);
|
|
75
|
+
const states = getMap(screen, 'states');
|
|
76
|
+
if (states) {
|
|
77
|
+
orderKeys(states, ORDER.states);
|
|
78
|
+
for (const state of ['empty', 'loading', 'error']) {
|
|
79
|
+
const variant = getMap(states, state);
|
|
80
|
+
if (variant) {
|
|
81
|
+
orderKeys(variant, ORDER.stateVariant);
|
|
82
|
+
const stateElements = getSeq(variant, 'elements');
|
|
83
|
+
if (stateElements)
|
|
84
|
+
canonicalizeItems(stateElements);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const navigation = getMap(screen, 'navigation');
|
|
89
|
+
if (navigation) {
|
|
90
|
+
orderKeys(navigation, ORDER.screenNav);
|
|
91
|
+
for (const group of ['primary', 'secondary', 'actions']) {
|
|
92
|
+
const items = getSeq(navigation, group);
|
|
93
|
+
if (items)
|
|
94
|
+
canonicalizeItems(items);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const data = getMap(screen, 'data');
|
|
98
|
+
if (data)
|
|
99
|
+
orderKeys(data, ORDER.data);
|
|
100
|
+
}
|
|
101
|
+
function canonicalizeItems(seq) {
|
|
102
|
+
for (const item of seq.items) {
|
|
103
|
+
if (isMap(item)) {
|
|
104
|
+
orderKeys(item, ORDER.item);
|
|
105
|
+
removeDefaults(item);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Reorder pairs: known keys in table order, then x_/unknown keys in author order. */
|
|
110
|
+
export function orderKeys(map, order) {
|
|
111
|
+
const rank = (pair) => {
|
|
112
|
+
const key = keyOf(pair);
|
|
113
|
+
const i = order.indexOf(key);
|
|
114
|
+
return i === -1 ? order.length : i;
|
|
115
|
+
};
|
|
116
|
+
// Stable sort keeps author order for keys outside the table (x_ extensions).
|
|
117
|
+
map.items = map.items
|
|
118
|
+
.map((pair, i) => ({ pair, i }))
|
|
119
|
+
.sort((a, b) => rank(a.pair) - rank(b.pair) || a.i - b.i)
|
|
120
|
+
.map(({ pair }) => pair);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Spec §11.1: block style everywhere — never inline (`[]`/`{}`) collections.
|
|
124
|
+
* Clears the flow flag on every non-empty map/seq so authored inline
|
|
125
|
+
* collections serialise as block. Empty collections keep flow style — `[]` is
|
|
126
|
+
* their only sensible rendering (schema-required empties like the calculator's
|
|
127
|
+
* secondary registry stay `screens: []`; optional empties are dropped by
|
|
128
|
+
* pruneEmpty anyway).
|
|
129
|
+
*/
|
|
130
|
+
function forceBlockStyle(node) {
|
|
131
|
+
if (isMap(node)) {
|
|
132
|
+
if (node.items.length > 0)
|
|
133
|
+
node.flow = false;
|
|
134
|
+
for (const pair of node.items)
|
|
135
|
+
forceBlockStyle(pair.value);
|
|
136
|
+
}
|
|
137
|
+
else if (isSeq(node)) {
|
|
138
|
+
if (node.items.length > 0)
|
|
139
|
+
node.flow = false;
|
|
140
|
+
for (const item of node.items)
|
|
141
|
+
forceBlockStyle(item);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Spec §11.1: omit keys at their default value. */
|
|
145
|
+
function removeDefaults(map) {
|
|
146
|
+
map.items = map.items.filter((pair) => {
|
|
147
|
+
const key = keyOf(pair);
|
|
148
|
+
const value = isScalar(pair.value) ? pair.value.value : undefined;
|
|
149
|
+
if (key === 'deep_link' && value === false)
|
|
150
|
+
return false;
|
|
151
|
+
if (key === 'auth' && value === 'none')
|
|
152
|
+
return false;
|
|
153
|
+
if (key === 'presentation' && value === 'push')
|
|
154
|
+
return false;
|
|
155
|
+
if (key === 'external' && value === false)
|
|
156
|
+
return false;
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Spec §11.1: omit empty collections, and the legacy `- None` placeholder lists
|
|
162
|
+
* for features/actions. Applied to screen subtrees (where every collection is
|
|
163
|
+
* optional) — recursing bottom-up so `data: {reads: [], writes: []}` collapses
|
|
164
|
+
* away entirely. Registry and flow lists are schema-required and untouched.
|
|
165
|
+
*/
|
|
166
|
+
function dropEmptyKeys(screens) {
|
|
167
|
+
for (const screen of screens.items) {
|
|
168
|
+
if (!isMap(screen))
|
|
169
|
+
continue;
|
|
170
|
+
pruneEmpty(screen);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function pruneEmpty(map) {
|
|
174
|
+
map.items = map.items.filter((pair) => {
|
|
175
|
+
const value = pair.value;
|
|
176
|
+
if (isMap(value)) {
|
|
177
|
+
pruneEmpty(value);
|
|
178
|
+
return value.items.length > 0;
|
|
179
|
+
}
|
|
180
|
+
if (isSeq(value)) {
|
|
181
|
+
if (value.items.length === 0)
|
|
182
|
+
return false;
|
|
183
|
+
const key = keyOf(pair);
|
|
184
|
+
if ((key === 'actions' || key === 'features') && value.items.length === 1) {
|
|
185
|
+
const only = value.items[0];
|
|
186
|
+
if (isScalar(only) && (only.value === 'None' || only.value === 'none'))
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Spec §11.2: screens ordered by flow membership — flows in document order,
|
|
195
|
+
* screens in within-flow order, multi-flow screens under their first flow,
|
|
196
|
+
* flowless screens last sorted by id. Fully derived, so formatters converge.
|
|
197
|
+
*/
|
|
198
|
+
function reorderScreens(parsed, screens) {
|
|
199
|
+
const doc = parsed.data;
|
|
200
|
+
const currentIds = (doc.screens ?? []).map((s) => s?.id);
|
|
201
|
+
const placed = new Set();
|
|
202
|
+
const desired = [];
|
|
203
|
+
for (const flow of doc.app?.flows ?? []) {
|
|
204
|
+
for (const id of flow?.screens ?? []) {
|
|
205
|
+
if (typeof id === 'string' && currentIds.includes(id) && !placed.has(id)) {
|
|
206
|
+
placed.add(id);
|
|
207
|
+
desired.push(id);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const rest = currentIds.filter((id) => typeof id === 'string' && !placed.has(id)).sort();
|
|
212
|
+
desired.push(...rest);
|
|
213
|
+
const byId = new Map();
|
|
214
|
+
screens.items.forEach((item, i) => {
|
|
215
|
+
const id = currentIds[i];
|
|
216
|
+
if (typeof id === 'string' && !byId.has(id))
|
|
217
|
+
byId.set(id, item);
|
|
218
|
+
});
|
|
219
|
+
// Only reorder if every screen resolved cleanly (duplicates/invalid shapes are validator territory).
|
|
220
|
+
if (byId.size === screens.items.length) {
|
|
221
|
+
screens.items = desired.map((id) => byId.get(id));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Spec §11.2: secondary registry list follows the screens ordering; primary keeps tab order. */
|
|
225
|
+
function syncSecondaryRegistry(parsed, root) {
|
|
226
|
+
const doc = parsed.data;
|
|
227
|
+
const screenOrder = new Map((doc.screens ?? []).map((s, i) => [s?.id, i]));
|
|
228
|
+
const navigation = getMap(root, 'app') && getMap(getMap(root, 'app'), 'navigation');
|
|
229
|
+
const secondary = navigation && getMap(navigation, 'secondary');
|
|
230
|
+
const list = secondary && getSeq(secondary, 'screens');
|
|
231
|
+
if (!list)
|
|
232
|
+
return;
|
|
233
|
+
const ids = list.items.filter(isScalar);
|
|
234
|
+
if (ids.length !== list.items.length)
|
|
235
|
+
return;
|
|
236
|
+
list.items = [...list.items].sort((a, b) => {
|
|
237
|
+
const ai = screenOrder.get(a.value) ?? Number.MAX_SAFE_INTEGER;
|
|
238
|
+
const bi = screenOrder.get(b.value) ?? Number.MAX_SAFE_INTEGER;
|
|
239
|
+
return ai - bi;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function quoteVersionHeader(root) {
|
|
243
|
+
const pair = root.items.find((p) => keyOf(p) === 'maias');
|
|
244
|
+
if (pair && isScalar(pair.value))
|
|
245
|
+
pair.value.type = Scalar.QUOTE_DOUBLE;
|
|
246
|
+
}
|
|
247
|
+
function keyOf(pair) {
|
|
248
|
+
return isScalar(pair.key) ? String(pair.key.value) : String(pair.key);
|
|
249
|
+
}
|
|
250
|
+
function getMap(map, key) {
|
|
251
|
+
const value = map.get(key, true);
|
|
252
|
+
return isMap(value) ? value : undefined;
|
|
253
|
+
}
|
|
254
|
+
function getSeq(map, key) {
|
|
255
|
+
const value = map.get(key, true);
|
|
256
|
+
return isSeq(value) ? value : undefined;
|
|
257
|
+
}
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { MaiasDocument, DocPath, Screen } from './model.js';
|
|
2
|
+
/** One outbound reference from a screen to another screen. */
|
|
3
|
+
export interface OutboundRef {
|
|
4
|
+
/** Screen the reference points at (the `target`). */
|
|
5
|
+
target: string;
|
|
6
|
+
/** Where the reference lives. */
|
|
7
|
+
kind: 'navigation.primary' | 'navigation.secondary' | 'element' | 'state_element' | 'back';
|
|
8
|
+
label?: string;
|
|
9
|
+
/** JSON path to the target scalar, relative to the document root. */
|
|
10
|
+
path: DocPath;
|
|
11
|
+
}
|
|
12
|
+
export interface InboundRef extends OutboundRef {
|
|
13
|
+
/** Screen the reference lives on. */
|
|
14
|
+
from: string;
|
|
15
|
+
}
|
|
16
|
+
/** All screen-to-screen references declared on a screen (nav items, elements, state elements). */
|
|
17
|
+
export declare function outboundRefs(screen: Screen, screenIndex: number): OutboundRef[];
|
|
18
|
+
/** Index of screens by ID. First occurrence wins when IDs are duplicated (lint reports the duplicate). */
|
|
19
|
+
export declare function screenIndex(doc: MaiasDocument): Map<string, Screen>;
|
|
20
|
+
export declare function getScreen(doc: MaiasDocument, id: string): Screen | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Screens reachable from the app's structure (spec §3.3): primary navigation,
|
|
23
|
+
* flow entry screens, then transitively via outbound references.
|
|
24
|
+
*/
|
|
25
|
+
export declare function reachableScreens(doc: MaiasDocument): Set<string>;
|
|
26
|
+
/** Screens in no flow AND unreachable (spec §3.2). */
|
|
27
|
+
export declare function orphanScreens(doc: MaiasDocument): string[];
|
|
28
|
+
/** All references across the document pointing at `id`. */
|
|
29
|
+
export declare function whatLinksHere(doc: MaiasDocument, id: string): InboundRef[];
|