@cloudhal/mcp-server 0.0.0-stage → 0.1.1
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/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation menu tools (RFC-0003 §6). Reading menus is `site:read`; every write (`update_menu`,
|
|
3
|
+
* `set_menu`, `delete_menu`) is `site:write` and waits for a person in every mode except local
|
|
4
|
+
* `bypass`. Targets are resolved from ids or handles ("/about", "posts/hello-world",
|
|
5
|
+
* "category:news") before anyone is asked, the tree is validated and the delta computed, so a call
|
|
6
|
+
* that cannot succeed fails before approval. Each write is one store CAS and exactly one snapshot.
|
|
7
|
+
*/
|
|
8
|
+
import { menuLookup } from "@cloudhal/astro-renderer/navigation";
|
|
9
|
+
|
|
10
|
+
import { MenuSchema, SnapshotSchema } from "@cloudhal/core-contracts";
|
|
11
|
+
|
|
12
|
+
import { ID_PREFIX, newId } from "@cloudhal/core-ts";
|
|
13
|
+
import {
|
|
14
|
+
applyMenuOps,
|
|
15
|
+
buildMenuItems,
|
|
16
|
+
countMenuItems,
|
|
17
|
+
defaultMaxDepth,
|
|
18
|
+
|
|
19
|
+
indexMenu,
|
|
20
|
+
isMenuHref,
|
|
21
|
+
MENU_LIMITS,
|
|
22
|
+
MENU_MAX_OPS,
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
menuDelta,
|
|
28
|
+
menuStateChanges,
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
resolveMenu,
|
|
32
|
+
|
|
33
|
+
validateMenuTree,
|
|
34
|
+
} from "@cloudhal/core-ts/menu";
|
|
35
|
+
import {
|
|
36
|
+
autoSnapshot,
|
|
37
|
+
id,
|
|
38
|
+
|
|
39
|
+
obj,
|
|
40
|
+
quote,
|
|
41
|
+
requireSite,
|
|
42
|
+
SLUG_PATTERN,
|
|
43
|
+
str,
|
|
44
|
+
strip,
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ToolError,
|
|
48
|
+
toToolError,
|
|
49
|
+
} from "./common.js";
|
|
50
|
+
|
|
51
|
+
const MENU = strip(MenuSchema);
|
|
52
|
+
const SNAPSHOT = strip(SnapshotSchema);
|
|
53
|
+
|
|
54
|
+
const READ = {
|
|
55
|
+
readOnlyHint: true,
|
|
56
|
+
destructiveHint: false,
|
|
57
|
+
idempotentHint: true,
|
|
58
|
+
openWorldHint: false,
|
|
59
|
+
} ;
|
|
60
|
+
const WRITE = {
|
|
61
|
+
readOnlyHint: false,
|
|
62
|
+
destructiveHint: false,
|
|
63
|
+
idempotentHint: false,
|
|
64
|
+
openWorldHint: false,
|
|
65
|
+
} ;
|
|
66
|
+
|
|
67
|
+
/** Where the blog theme shows the well-known menus (prompts name them). */
|
|
68
|
+
const PLACES = { primary: "Header", footer: "Footer", sidebar: "Sidebar" };
|
|
69
|
+
const WELL_KNOWN = ["primary", "footer"] ;
|
|
70
|
+
|
|
71
|
+
const MENU_KEY = str(
|
|
72
|
+
"Menu key: primary (header), footer, or sidebar (docs); other slugs work too",
|
|
73
|
+
{
|
|
74
|
+
pattern: SLUG_PATTERN,
|
|
75
|
+
maxLength: 128,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
const LOCALE = { type: "string", pattern: "^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$" };
|
|
79
|
+
const LABELS = {
|
|
80
|
+
type: "object",
|
|
81
|
+
description:
|
|
82
|
+
"Label per locale (plain text, 1–100 characters). Page, article and term items may omit it: they show the target's title. Collection, URL and group items need one in the site's default locale.",
|
|
83
|
+
propertyNames: LOCALE,
|
|
84
|
+
additionalProperties: { type: "string", minLength: 1, maxLength: 100 },
|
|
85
|
+
maxProperties: 20,
|
|
86
|
+
};
|
|
87
|
+
const TARGET = {
|
|
88
|
+
type: "object",
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
required: ["kind"],
|
|
91
|
+
description:
|
|
92
|
+
'What the item links to. page: ref = page id or path ("/about"); contentEntry: ref = entry id or "<collection>/<slug>"; term: ref = term id or "<taxonomy>:<slug or name>" ("category:news"); collection: the collection\'s index; url: an external https link (shown with its host to the approver); group: a label holding children.',
|
|
93
|
+
properties: {
|
|
94
|
+
kind: { type: "string", enum: ["page", "contentEntry", "term", "collection", "url", "group"] },
|
|
95
|
+
ref: str("page, contentEntry, term: an id or a handle", { minLength: 1, maxLength: 300 }),
|
|
96
|
+
collection: str("collection: its slug, e.g. posts", { pattern: SLUG_PATTERN }),
|
|
97
|
+
href: str("url: https://…", { maxLength: 2048 }),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const ITEM_INPUT = {
|
|
101
|
+
type: "object",
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
required: ["target"],
|
|
104
|
+
properties: {
|
|
105
|
+
id: { ...id, description: "set_menu: keep this existing item's id (its identity in diffs)" },
|
|
106
|
+
target: TARGET,
|
|
107
|
+
labels: LABELS,
|
|
108
|
+
newTab: { type: "boolean", description: "url items only: open in a new tab" },
|
|
109
|
+
display: {
|
|
110
|
+
type: "string",
|
|
111
|
+
enum: ["mega"],
|
|
112
|
+
description:
|
|
113
|
+
"top-level items with children: open as one wide panel in the header, children as columns (a group's children are the column's links)",
|
|
114
|
+
},
|
|
115
|
+
children: { type: "array", items: { $ref: "#/$defs/MenuItemInput" }, maxItems: 100 },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const DEFS = { MenuItemInput: ITEM_INPUT };
|
|
119
|
+
const OP = {
|
|
120
|
+
type: "object",
|
|
121
|
+
additionalProperties: false,
|
|
122
|
+
required: ["op"],
|
|
123
|
+
properties: {
|
|
124
|
+
op: { type: "string", enum: ["add", "remove", "move", "update"] },
|
|
125
|
+
item: { $ref: "#/$defs/MenuItemInput" },
|
|
126
|
+
itemId: { ...id, description: "remove, move, update: the item (get_menu ids)" },
|
|
127
|
+
parent: {
|
|
128
|
+
type: ["string", "null"],
|
|
129
|
+
description: "add, move: parent item id; null or absent = top level",
|
|
130
|
+
},
|
|
131
|
+
index: {
|
|
132
|
+
type: "integer",
|
|
133
|
+
minimum: 0,
|
|
134
|
+
description: "add, move: 0-based position; absent = last",
|
|
135
|
+
},
|
|
136
|
+
labels: {
|
|
137
|
+
...LABELS,
|
|
138
|
+
description: "update: labels to set; null removes a locale's label",
|
|
139
|
+
additionalProperties: { type: ["string", "null"], minLength: 1, maxLength: 100 },
|
|
140
|
+
},
|
|
141
|
+
target: TARGET,
|
|
142
|
+
newTab: { type: ["boolean", "null"], description: "update: url items only; null clears" },
|
|
143
|
+
display: {
|
|
144
|
+
type: ["string", "null"],
|
|
145
|
+
enum: ["mega", null],
|
|
146
|
+
description: "update: mega for a top-level item with children; null clears",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const DELTA_OUT = obj(
|
|
151
|
+
{
|
|
152
|
+
added: { type: "integer" },
|
|
153
|
+
removed: { type: "integer" },
|
|
154
|
+
moved: { type: "integer" },
|
|
155
|
+
changed: { type: "integer" },
|
|
156
|
+
items: { type: "integer", description: "Items in the menu after the write" },
|
|
157
|
+
},
|
|
158
|
+
["added", "removed", "moved", "changed", "items"],
|
|
159
|
+
);
|
|
160
|
+
const RESOLVED_ITEM = {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
id: { type: "string" },
|
|
164
|
+
kind: { type: "string" },
|
|
165
|
+
state: { type: "string", enum: ["shown", "hidden"] },
|
|
166
|
+
reason: { type: "string" },
|
|
167
|
+
label: { type: "string" },
|
|
168
|
+
labelSource: { type: "string", enum: ["item", "default-locale", "target", "none"] },
|
|
169
|
+
href: { type: "string" },
|
|
170
|
+
children: { type: "array", items: { $ref: "#/$defs/ResolvedMenuItem" } },
|
|
171
|
+
},
|
|
172
|
+
required: ["id", "kind", "state", "label", "labelSource", "children"],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ───────────────────────────── resolution ─────────────────────────────
|
|
176
|
+
|
|
177
|
+
const ID_RE = /^[a-z]+_[A-Za-z0-9]{6,32}$/;
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
/** Resolves a target handle to an id of a linkable node of this site (invariant 9, before approval). */
|
|
206
|
+
async function resolveTarget(
|
|
207
|
+
ctx ,
|
|
208
|
+
site ,
|
|
209
|
+
t ,
|
|
210
|
+
) {
|
|
211
|
+
const need = (field , value ) => {
|
|
212
|
+
if (!value) throw new ToolError("invalid", `${t.kind} targets need "${field}"`);
|
|
213
|
+
return value;
|
|
214
|
+
};
|
|
215
|
+
const unknown = (what ) =>
|
|
216
|
+
new ToolError(
|
|
217
|
+
"unknown_target",
|
|
218
|
+
`${what} is not in this site; list_pages, list_entries or list_terms show what is`,
|
|
219
|
+
);
|
|
220
|
+
switch (t.kind) {
|
|
221
|
+
case "group":
|
|
222
|
+
return { kind: "group" };
|
|
223
|
+
case "collection": {
|
|
224
|
+
const c = need("collection", t.collection);
|
|
225
|
+
if (!new RegExp(SLUG_PATTERN).test(c))
|
|
226
|
+
throw new ToolError("invalid", `"${c}" is not a collection slug`);
|
|
227
|
+
return { kind: "collection", collection: c };
|
|
228
|
+
}
|
|
229
|
+
case "url": {
|
|
230
|
+
const href = need("href", t.href);
|
|
231
|
+
if (!isMenuHref(href))
|
|
232
|
+
throw new ToolError(
|
|
233
|
+
"invalid_menu",
|
|
234
|
+
`external links must be https:// with an ASCII host (punycode for international names), no user-info, whitespace or backslashes; link this site's pages, articles and terms by kind and ref instead (got "${quote(href, 80)}")`,
|
|
235
|
+
);
|
|
236
|
+
return { kind: "url", href };
|
|
237
|
+
}
|
|
238
|
+
case "page": {
|
|
239
|
+
const ref = need("ref", t.ref);
|
|
240
|
+
if (ID_RE.test(ref)) {
|
|
241
|
+
const page = await ctx.store.getPage(ref);
|
|
242
|
+
if (!page || page.siteId !== site.id) throw unknown(`page ${ref}`);
|
|
243
|
+
return { kind: "page", ref };
|
|
244
|
+
}
|
|
245
|
+
const pages = (await ctx.store.listPages(site.id)).filter((p) => p.path === ref);
|
|
246
|
+
const page = pages.find((p) => p.locale === site.defaultLocale) ?? pages[0];
|
|
247
|
+
if (!page) throw unknown(`page "${quote(ref, 80)}"`);
|
|
248
|
+
return { kind: "page", ref: page.id };
|
|
249
|
+
}
|
|
250
|
+
case "contentEntry": {
|
|
251
|
+
const ref = need("ref", t.ref);
|
|
252
|
+
if (ID_RE.test(ref)) {
|
|
253
|
+
const entry = await ctx.store.getContentEntry(ref);
|
|
254
|
+
if (!entry || entry.siteId !== site.id) throw unknown(`article ${ref}`);
|
|
255
|
+
return { kind: "contentEntry", ref };
|
|
256
|
+
}
|
|
257
|
+
const m = /^\/?([a-z0-9]+(?:-[a-z0-9]+)*)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(ref);
|
|
258
|
+
if (!m)
|
|
259
|
+
throw new ToolError(
|
|
260
|
+
"invalid",
|
|
261
|
+
`article refs are ids or "<collection>/<slug>" (got "${quote(ref, 80)}")`,
|
|
262
|
+
);
|
|
263
|
+
const entries = (
|
|
264
|
+
await ctx.store.listContentEntries(site.id, { collection: m[1] })
|
|
265
|
+
).filter((e) => e.slug === m[2]);
|
|
266
|
+
const entry = entries.find((e) => e.locale === site.defaultLocale) ?? entries[0];
|
|
267
|
+
if (!entry) throw unknown(`article "${quote(ref, 80)}"`);
|
|
268
|
+
return { kind: "contentEntry", ref: entry.id };
|
|
269
|
+
}
|
|
270
|
+
case "term": {
|
|
271
|
+
const ref = need("ref", t.ref);
|
|
272
|
+
let termId ;
|
|
273
|
+
if (ID_RE.test(ref)) termId = ref;
|
|
274
|
+
else {
|
|
275
|
+
const at = ref.indexOf(":");
|
|
276
|
+
if (at < 1)
|
|
277
|
+
throw new ToolError(
|
|
278
|
+
"invalid",
|
|
279
|
+
`term refs are ids or "<taxonomy>:<slug or name>", e.g. category:news (got "${quote(ref, 80)}")`,
|
|
280
|
+
);
|
|
281
|
+
const key = ref.slice(0, at);
|
|
282
|
+
if (!(await ctx.store.getTaxonomyByKey(site.id, key)))
|
|
283
|
+
throw new ToolError(
|
|
284
|
+
"unknown_taxonomy",
|
|
285
|
+
`this site has no taxonomy "${key}"; call list_taxonomies`,
|
|
286
|
+
);
|
|
287
|
+
const [r] = await ctx.store.resolveTerms(site.id, key, [ref.slice(at + 1)]);
|
|
288
|
+
if (!r?.matched) {
|
|
289
|
+
const hints = (r?.suggestions ?? []).map((s) => `${s.slug} (${s.termId})`).join(", ");
|
|
290
|
+
throw new ToolError(
|
|
291
|
+
r?.reason === "ambiguous" ? "ambiguous_term" : "unknown_term",
|
|
292
|
+
`${key} "${quote(ref.slice(at + 1), 60)}" ${r?.reason === "ambiguous" ? "matches several terms" : "matches no term"}${hints ? `; candidates: ${hints}` : ""}; pass the term id`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
termId = r.termId;
|
|
296
|
+
}
|
|
297
|
+
const term = await ctx.store.getTerm(termId);
|
|
298
|
+
if (!term || term.siteId !== site.id) throw unknown(`term ${termId}`);
|
|
299
|
+
if (term.status === "retired")
|
|
300
|
+
throw new ToolError(
|
|
301
|
+
"unknown_target",
|
|
302
|
+
`term ${term.slug} is retired; link the term that replaced it (resolve_terms)`,
|
|
303
|
+
);
|
|
304
|
+
return { kind: "term", ref: term.id };
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
throw new ToolError("invalid", `unknown target kind "${t.kind}"`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function resolveItem(ctx , site , raw ) {
|
|
312
|
+
return {
|
|
313
|
+
...(raw.id ? { id: raw.id } : {}),
|
|
314
|
+
target: await resolveTarget(ctx, site, raw.target),
|
|
315
|
+
...(raw.labels ? { labels: raw.labels } : {}),
|
|
316
|
+
...(raw.newTab !== undefined ? { newTab: raw.newTab } : {}),
|
|
317
|
+
...(raw.display ? { display: raw.display } : {}),
|
|
318
|
+
...(raw.children?.length
|
|
319
|
+
? { children: await Promise.all(raw.children.map((c) => resolveItem(ctx, site, c))) }
|
|
320
|
+
: {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function resolveOps(ctx , site , raw ) {
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const [i, op] of raw.entries()) {
|
|
327
|
+
const at = (e ) => {
|
|
328
|
+
const err = toToolError(e);
|
|
329
|
+
if (err instanceof ToolError)
|
|
330
|
+
return new ToolError(err.code, `operation ${i}: ${err.message}`, {
|
|
331
|
+
...err.details,
|
|
332
|
+
opIndex: i,
|
|
333
|
+
});
|
|
334
|
+
return err;
|
|
335
|
+
};
|
|
336
|
+
try {
|
|
337
|
+
switch (op.op) {
|
|
338
|
+
case "add":
|
|
339
|
+
if (!op.item) throw new ToolError("invalid", "add needs an item");
|
|
340
|
+
out.push({
|
|
341
|
+
op: "add",
|
|
342
|
+
item: await resolveItem(ctx, site, op.item),
|
|
343
|
+
...(op.parent !== undefined ? { parent: op.parent } : {}),
|
|
344
|
+
...(op.index !== undefined ? { index: op.index } : {}),
|
|
345
|
+
});
|
|
346
|
+
break;
|
|
347
|
+
case "remove":
|
|
348
|
+
case "move":
|
|
349
|
+
case "update": {
|
|
350
|
+
if (!op.itemId) throw new ToolError("invalid", `${op.op} needs an itemId`);
|
|
351
|
+
if (op.op === "remove") out.push({ op: "remove", itemId: op.itemId });
|
|
352
|
+
else if (op.op === "move")
|
|
353
|
+
out.push({
|
|
354
|
+
op: "move",
|
|
355
|
+
itemId: op.itemId,
|
|
356
|
+
...(op.parent !== undefined ? { parent: op.parent } : {}),
|
|
357
|
+
...(op.index !== undefined ? { index: op.index } : {}),
|
|
358
|
+
});
|
|
359
|
+
else {
|
|
360
|
+
const target = op.target ? await resolveTarget(ctx, site, op.target) : undefined;
|
|
361
|
+
if (target?.kind === "group")
|
|
362
|
+
throw new ToolError(
|
|
363
|
+
"invalid",
|
|
364
|
+
"an item cannot become a group; add a group and move the items under it",
|
|
365
|
+
);
|
|
366
|
+
out.push({
|
|
367
|
+
op: "update",
|
|
368
|
+
itemId: op.itemId,
|
|
369
|
+
...(op.labels ? { labels: op.labels } : {}),
|
|
370
|
+
...(target ? { target } : {}),
|
|
371
|
+
...(op.newTab !== undefined ? { newTab: op.newTab } : {}),
|
|
372
|
+
...(op.display !== undefined ? { display: op.display } : {}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
default:
|
|
378
|
+
throw new ToolError("invalid", `unknown operation "${String(op.op)}"`);
|
|
379
|
+
}
|
|
380
|
+
} catch (e) {
|
|
381
|
+
throw at(e);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const newItemId = () => newId(ID_PREFIX.menuItem);
|
|
388
|
+
|
|
389
|
+
/** The menu a write starts from: the stored one (its `rev` must match) or a new empty one. */
|
|
390
|
+
async function baseMenu(
|
|
391
|
+
ctx ,
|
|
392
|
+
site ,
|
|
393
|
+
key ,
|
|
394
|
+
rev ,
|
|
395
|
+
create ,
|
|
396
|
+
tool ,
|
|
397
|
+
) {
|
|
398
|
+
const existing = await ctx.store.getMenuByKey(site.id, key);
|
|
399
|
+
if (existing) {
|
|
400
|
+
if (rev === undefined)
|
|
401
|
+
throw new ToolError(
|
|
402
|
+
"conflict",
|
|
403
|
+
`menu "${key}" exists (rev ${existing.rev}); send its rev (get_menu)`,
|
|
404
|
+
);
|
|
405
|
+
if (existing.rev !== rev)
|
|
406
|
+
throw new ToolError(
|
|
407
|
+
"conflict",
|
|
408
|
+
`menu "${key}" is at rev ${existing.rev}, you sent ${rev}; reload (get_menu) and retry`,
|
|
409
|
+
);
|
|
410
|
+
return { existing, base: existing };
|
|
411
|
+
}
|
|
412
|
+
if (!create)
|
|
413
|
+
throw new ToolError(
|
|
414
|
+
"not_found",
|
|
415
|
+
`this site has no "${key}" menu (it shows the theme's derived default); ${tool === "update_menu" ? "pass create: {} to create it" : "omit rev to create it"}`,
|
|
416
|
+
);
|
|
417
|
+
const ts = new Date(0).toISOString();
|
|
418
|
+
return {
|
|
419
|
+
base: {
|
|
420
|
+
schemaVersion: 1,
|
|
421
|
+
id: "menu_pending0",
|
|
422
|
+
siteId: site.id,
|
|
423
|
+
key,
|
|
424
|
+
maxDepth: create.maxDepth ?? defaultMaxDepth(key),
|
|
425
|
+
items: [],
|
|
426
|
+
rev: 0,
|
|
427
|
+
createdAt: ts,
|
|
428
|
+
updatedAt: ts,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async function plan(ctx , tool , a ) {
|
|
441
|
+
const site = await requireSite(ctx, a.siteId );
|
|
442
|
+
const key = a.key ;
|
|
443
|
+
try {
|
|
444
|
+
if (tool === "update_menu") {
|
|
445
|
+
const ops = (a.ops ) ?? [];
|
|
446
|
+
if (ops.length === 0 || ops.length > MENU_MAX_OPS)
|
|
447
|
+
throw new ToolError("invalid", `send 1–${MENU_MAX_OPS} operations`);
|
|
448
|
+
const { existing, base } = await baseMenu(
|
|
449
|
+
ctx,
|
|
450
|
+
site,
|
|
451
|
+
key,
|
|
452
|
+
a.rev ,
|
|
453
|
+
a.create ,
|
|
454
|
+
tool,
|
|
455
|
+
);
|
|
456
|
+
const resolved = await resolveOps(ctx, site, ops);
|
|
457
|
+
const next = applyMenuOps(base, resolved, newItemId, site);
|
|
458
|
+
validateMenuTree(next, site, existing);
|
|
459
|
+
return { site, ...(existing ? { existing } : {}), next, delta: menuDelta(existing, next) };
|
|
460
|
+
}
|
|
461
|
+
// set_menu
|
|
462
|
+
const rev = a.rev ;
|
|
463
|
+
const { existing, base } = await baseMenu(
|
|
464
|
+
ctx,
|
|
465
|
+
site,
|
|
466
|
+
key,
|
|
467
|
+
rev,
|
|
468
|
+
rev === undefined
|
|
469
|
+
? { ...(a.maxDepth !== undefined ? { maxDepth: a.maxDepth } : {}) }
|
|
470
|
+
: undefined,
|
|
471
|
+
tool,
|
|
472
|
+
);
|
|
473
|
+
const raw = (a.items ) ?? [];
|
|
474
|
+
const inputs = await Promise.all(raw.map((r) => resolveItem(ctx, site, r)));
|
|
475
|
+
const next = {
|
|
476
|
+
...base,
|
|
477
|
+
...(a.maxDepth !== undefined ? { maxDepth: a.maxDepth } : {}),
|
|
478
|
+
items: buildMenuItems(inputs, newItemId),
|
|
479
|
+
};
|
|
480
|
+
validateMenuTree(next, site, existing);
|
|
481
|
+
return { site, ...(existing ? { existing } : {}), next, delta: menuDelta(existing, next) };
|
|
482
|
+
} catch (e) {
|
|
483
|
+
throw toToolError(e);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A lookup over the site's current nodes, as the next publish would serve them. */
|
|
488
|
+
async function currentData(ctx , site ) {
|
|
489
|
+
const [pages, entries, taxonomies, terms] = await Promise.all([
|
|
490
|
+
ctx.store.listPages(site.id),
|
|
491
|
+
ctx.store.listContentEntries(site.id),
|
|
492
|
+
ctx.store.listTaxonomies(site.id),
|
|
493
|
+
ctx.store.listTerms(site.id),
|
|
494
|
+
]);
|
|
495
|
+
return { site, pages, entries, taxonomies, terms };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ───────────────────────────── prompt (§6.3) ─────────────────────────────
|
|
499
|
+
|
|
500
|
+
const REASON_TEXT = {
|
|
501
|
+
missing: (k) => `its ${k} was deleted`,
|
|
502
|
+
unpublished: (k) => `its ${k} is not published`,
|
|
503
|
+
"other-locale": (k) => `its ${k} is in another locale`,
|
|
504
|
+
"not-served": () => "its archive is not served (no published posts, not public, or archives off)",
|
|
505
|
+
retired: () => "its term is retired",
|
|
506
|
+
"no-index": () => "no page lists that collection",
|
|
507
|
+
"empty-group": () => "none of its items is shown",
|
|
508
|
+
"parent-hidden": () => "an item above it is hidden",
|
|
509
|
+
};
|
|
510
|
+
const KIND_WORD = {
|
|
511
|
+
page: "page",
|
|
512
|
+
contentEntry: "article",
|
|
513
|
+
term: "term",
|
|
514
|
+
collection: "collection",
|
|
515
|
+
url: "link",
|
|
516
|
+
group: "group",
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
function flatResolved(
|
|
520
|
+
items ,
|
|
521
|
+
out = new Map (),
|
|
522
|
+
) {
|
|
523
|
+
for (const i of items) {
|
|
524
|
+
out.set(i.id, i);
|
|
525
|
+
flatResolved(i.children, out);
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** `"About"`, or `"About" (title)` when the label falls back to the target's title. */
|
|
531
|
+
function shownLabel(r , fallbackId ) {
|
|
532
|
+
if (!r?.label) return fallbackId;
|
|
533
|
+
return `"${quote(r.label, 50)}"${r.labelSource === "target" ? " (title)" : ""}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Where an item links, for a person: path and kind, or the external host. */
|
|
537
|
+
function where(item , lookup , locale ) {
|
|
538
|
+
switch (item.kind) {
|
|
539
|
+
case "page": {
|
|
540
|
+
const p = lookup.page(item.pageId);
|
|
541
|
+
return `${p?.path ?? item.pageId} (page)`;
|
|
542
|
+
}
|
|
543
|
+
case "contentEntry": {
|
|
544
|
+
const e = lookup.entry(item.entryId);
|
|
545
|
+
return `${e?.path ?? item.entryId} (article)`;
|
|
546
|
+
}
|
|
547
|
+
case "term": {
|
|
548
|
+
const t = lookup.term(item.termId, locale);
|
|
549
|
+
return t
|
|
550
|
+
? `${t.path ?? "no archive"} (term "${quote(t.name, 40)}")`
|
|
551
|
+
: `${item.termId} (term)`;
|
|
552
|
+
}
|
|
553
|
+
case "collection":
|
|
554
|
+
return `${lookup.collectionIndex(item.collection, locale) ?? "no index"} (collection ${item.collection})`;
|
|
555
|
+
case "url": {
|
|
556
|
+
let host = "?";
|
|
557
|
+
try {
|
|
558
|
+
host = new URL(item.href).host;
|
|
559
|
+
} catch {}
|
|
560
|
+
return `${quote(item.href, 50)} (external: ${host}${item.newTab ? ", new tab" : ""})`;
|
|
561
|
+
}
|
|
562
|
+
case "group":
|
|
563
|
+
return "(group)";
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function targetText(item , lookup , locale ) {
|
|
568
|
+
switch (item.kind) {
|
|
569
|
+
case "page":
|
|
570
|
+
return `page "${quote(lookup.page(item.pageId)?.title ?? item.pageId, 40)}"`;
|
|
571
|
+
case "contentEntry":
|
|
572
|
+
return `article "${quote(lookup.entry(item.entryId)?.title ?? item.entryId, 40)}"`;
|
|
573
|
+
case "term":
|
|
574
|
+
return `term "${quote(lookup.term(item.termId, locale)?.name ?? item.termId, 40)}"`;
|
|
575
|
+
case "collection":
|
|
576
|
+
return `collection ${item.collection}`;
|
|
577
|
+
case "url":
|
|
578
|
+
return where(item, lookup, locale);
|
|
579
|
+
case "group":
|
|
580
|
+
return "group";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const MAX_LINES = 20;
|
|
585
|
+
|
|
586
|
+
/** The approval prompt of a menu write, in the RFC-0003 §6.3 format. */
|
|
587
|
+
export async function menuPrompt(ctx , p , tool ) {
|
|
588
|
+
const { site, existing, next, delta } = p;
|
|
589
|
+
const locale = site.defaultLocale;
|
|
590
|
+
const lookup = menuLookup(await currentData(ctx, site));
|
|
591
|
+
const before = existing ? resolveMenu(existing, lookup, locale) : undefined;
|
|
592
|
+
const after = resolveMenu({ ...next, id: existing?.id ?? "menu_pending0" }, lookup, locale);
|
|
593
|
+
const was = before ? flatResolved(before.items) : new Map ();
|
|
594
|
+
const now = flatResolved(after.items);
|
|
595
|
+
const oldIndex = indexMenu(existing?.items ?? []);
|
|
596
|
+
const newIndex = indexMenu(next.items);
|
|
597
|
+
const place = PLACES[next.key] ? ` (${PLACES[next.key]})` : "";
|
|
598
|
+
const label = (id ) => shownLabel(now.get(id) ?? was.get(id), id);
|
|
599
|
+
const parentText = (parentId ) =>
|
|
600
|
+
parentId ? `under ${label(parentId)}` : "top level";
|
|
601
|
+
const lines = [];
|
|
602
|
+
for (const a of delta.added) {
|
|
603
|
+
const item = newIndex.get(a.id)?.item ;
|
|
604
|
+
lines.push(
|
|
605
|
+
`+ ${label(a.id)} → ${where(item, lookup, locale)}, ${parentText(a.parentId)}, position ${a.index + 1}${a.descendants ? `, with ${a.descendants} item${a.descendants === 1 ? "" : "s"} below it` : ""}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
for (const r of delta.removed) {
|
|
609
|
+
const item = oldIndex.get(r.id)?.item ;
|
|
610
|
+
lines.push(
|
|
611
|
+
r.descendants
|
|
612
|
+
? `− ${shownLabel(was.get(r.id), r.id)} and ${r.descendants} item${r.descendants === 1 ? "" : "s"} below it`
|
|
613
|
+
: `− ${shownLabel(was.get(r.id), r.id)} → ${where(item, lookup, locale)}`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
for (const m of delta.moved)
|
|
617
|
+
lines.push(
|
|
618
|
+
m.reparented
|
|
619
|
+
? `↳ ${label(m.id)} moved ${parentText(m.to.parentId)} (was ${m.from.parentId ? `under ${label(m.from.parentId)}` : "top level"})`
|
|
620
|
+
: `↕ ${label(m.id)}: position ${m.from.index + 1} → ${m.to.index + 1}`,
|
|
621
|
+
);
|
|
622
|
+
for (const c of delta.changed) {
|
|
623
|
+
for (const l of c.labels ?? [])
|
|
624
|
+
lines.push(
|
|
625
|
+
`~ ${label(c.id)}: label [${l.locale}] ${l.from !== undefined ? `"${quote(l.from, 40)}"` : "(none)"} → ${l.to !== undefined ? `"${quote(l.to, 40)}"` : "(none)"}`,
|
|
626
|
+
);
|
|
627
|
+
if (c.target)
|
|
628
|
+
lines.push(
|
|
629
|
+
`~ ${label(c.id)}: target ${targetText(c.target.from, lookup, locale)} → ${targetText(c.target.to, lookup, locale)}`,
|
|
630
|
+
);
|
|
631
|
+
if (c.newTab)
|
|
632
|
+
lines.push(
|
|
633
|
+
`~ ${label(c.id)}: ${c.newTab.to ? "opens in a new tab" : "opens in the same tab"}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
// Items this write adds or edits that the site will not show (yet).
|
|
637
|
+
const touched = new Set([...delta.added.map((x) => x.id), ...delta.changed.map((x) => x.id)]);
|
|
638
|
+
for (const id of touched) {
|
|
639
|
+
const r = now.get(id);
|
|
640
|
+
if (r?.state === "hidden" && r.reason)
|
|
641
|
+
lines.push(
|
|
642
|
+
`! ${label(id)} stays hidden: ${REASON_TEXT[r.reason](KIND_WORD[r.kind] ?? r.kind)}`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
const shown = lines.slice(0, MAX_LINES);
|
|
646
|
+
if (lines.length > MAX_LINES) shown.push(`… and ${lines.length - MAX_LINES} more`);
|
|
647
|
+
const head =
|
|
648
|
+
tool === "delete_menu"
|
|
649
|
+
? ""
|
|
650
|
+
: existing
|
|
651
|
+
? `Change menu "${next.key}"${place}: ${delta.changes} change${delta.changes === 1 ? "" : "s"}, ${delta.before} → ${delta.after} items. It goes live with the next publish.`
|
|
652
|
+
: `Create menu "${next.key}"${place} with ${delta.after} item${delta.after === 1 ? "" : "s"} (maxDepth ${next.maxDepth}); it replaces the theme's derived ${place ? place.slice(2, -1).toLowerCase() : "menu"} with the next publish.`;
|
|
653
|
+
return shown.length ? `${head}\n${shown.join("\n")}` : head;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** The audit row's `input.effect` (counts and item ids, no labels). */
|
|
657
|
+
function effect(key , delta , created ) {
|
|
658
|
+
return {
|
|
659
|
+
menu: {
|
|
660
|
+
key,
|
|
661
|
+
...(created ? { created: true } : {}),
|
|
662
|
+
added: delta.added.map((x) => x.id),
|
|
663
|
+
removed: delta.removed.map((x) => x.id),
|
|
664
|
+
moved: delta.moved.map((x) => x.id),
|
|
665
|
+
changed: delta.changed.map((x) => x.id),
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function deltaOut(delta ) {
|
|
671
|
+
return {
|
|
672
|
+
added: delta.added.length,
|
|
673
|
+
removed: delta.removed.length,
|
|
674
|
+
moved: delta.moved.length,
|
|
675
|
+
changed: delta.changed.length,
|
|
676
|
+
items: delta.after,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function write(ctx , tool , a ) {
|
|
681
|
+
const p = await plan(ctx, tool, a);
|
|
682
|
+
let menu ;
|
|
683
|
+
try {
|
|
684
|
+
menu = p.existing
|
|
685
|
+
? await ctx.store.updateMenu({ ...p.next, id: p.existing.id, rev: p.existing.rev })
|
|
686
|
+
: await ctx.store.createMenu({
|
|
687
|
+
siteId: p.site.id,
|
|
688
|
+
key: p.next.key,
|
|
689
|
+
maxDepth: p.next.maxDepth,
|
|
690
|
+
items: p.next.items,
|
|
691
|
+
});
|
|
692
|
+
} catch (e) {
|
|
693
|
+
throw toToolError(e);
|
|
694
|
+
}
|
|
695
|
+
ctx.report?.(effect(menu.key, p.delta, !p.existing));
|
|
696
|
+
const snapshot = await autoSnapshot(ctx, p.site.id, tool, {
|
|
697
|
+
"cloudhal.tool": tool,
|
|
698
|
+
"cloudhal.menu": menu.key,
|
|
699
|
+
});
|
|
700
|
+
return { menu, delta: deltaOut(p.delta), snapshot };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ───────────────────────────── publish and other prompts ─────────────────────────────
|
|
704
|
+
|
|
705
|
+
/** Nodes of the live snapshot: menus always, the rest only when a menu needs resolving. */
|
|
706
|
+
async function liveState(
|
|
707
|
+
ctx ,
|
|
708
|
+
site ,
|
|
709
|
+
currentIds ,
|
|
710
|
+
) {
|
|
711
|
+
if (!site.publishedSnapshotId) return { menus: [] };
|
|
712
|
+
const snap = await ctx.store.getSnapshot(site.publishedSnapshotId);
|
|
713
|
+
if (!snap) return { menus: [] };
|
|
714
|
+
const read = async (kind ) =>
|
|
715
|
+
Promise.all(
|
|
716
|
+
snap.nodes
|
|
717
|
+
.filter((n) => (n.kind ) === kind)
|
|
718
|
+
.map((n) => ctx.store.readSnapshotNode(n)),
|
|
719
|
+
) ;
|
|
720
|
+
const menus = await read ("menu");
|
|
721
|
+
// Effects are only computed for menus live now and still there; skip the rest of the snapshot
|
|
722
|
+
// when there are none (most publishes of most sites).
|
|
723
|
+
if (!menus.some((m) => currentIds.has(m.id))) return { menus };
|
|
724
|
+
const [[liveSite], pages, entries, taxonomies, terms] = await Promise.all([
|
|
725
|
+
read ("site"),
|
|
726
|
+
read ("page"),
|
|
727
|
+
read ("contentEntry"),
|
|
728
|
+
read ("taxonomy"),
|
|
729
|
+
read ("term"),
|
|
730
|
+
]);
|
|
731
|
+
return { menus, data: { site: liveSite ?? site, pages, entries, taxonomies, terms } };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Menu lines of the `publish` prompt (§6.3): the menu effects of other changes — items that become
|
|
736
|
+
* hidden or shown again because a page was deleted, an entry unpublished or a term retired,
|
|
737
|
+
* computed by resolving each menu against the live and the new state — and deleted menus. Each
|
|
738
|
+
* changed menu's own summary (`~ menu "primary" (+2 −1)`) comes from `describeChanges`.
|
|
739
|
+
*/
|
|
740
|
+
export async function publishMenuLines(ctx , site ) {
|
|
741
|
+
const current = await ctx.store.listMenus(site.id);
|
|
742
|
+
const live = await liveState(ctx, site, new Set(current.map((m) => m.id)));
|
|
743
|
+
if (current.length === 0 && live.menus.length === 0) return [];
|
|
744
|
+
const locale = site.defaultLocale;
|
|
745
|
+
const lines = [];
|
|
746
|
+
if (live.data) {
|
|
747
|
+
const newLookup = menuLookup(await currentData(ctx, site));
|
|
748
|
+
const oldLookup = menuLookup(live.data);
|
|
749
|
+
for (const menu of current) {
|
|
750
|
+
const was = live.menus.find((m) => m.id === menu.id);
|
|
751
|
+
if (!was) continue;
|
|
752
|
+
const before = resolveMenu(was, oldLookup, locale);
|
|
753
|
+
const after = resolveMenu(menu, newLookup, locale);
|
|
754
|
+
for (const { item, was: w } of menuStateChanges(before, after)) {
|
|
755
|
+
const label = shownLabel(item.label ? item : w, item.id);
|
|
756
|
+
lines.push(
|
|
757
|
+
item.state === "hidden" && item.reason
|
|
758
|
+
? `! menu "${menu.key}": ${label} hidden (${REASON_TEXT[item.reason](KIND_WORD[item.kind] ?? item.kind)})`
|
|
759
|
+
: `! menu "${menu.key}": ${label} shown again`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
for (const was of live.menus)
|
|
765
|
+
if (!current.some((m) => m.id === was.id))
|
|
766
|
+
lines.push(`! menu "${was.key}" deleted: the theme's derived default returns`);
|
|
767
|
+
return lines.length > 10
|
|
768
|
+
? [...lines.slice(0, 10), `… and ${lines.length - 10} more menu effects`]
|
|
769
|
+
: lines;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** `(+2 −1 ↕1 ~1)` for a changed menu in `describeChanges`. */
|
|
773
|
+
export async function menuChangeSummary(
|
|
774
|
+
ctx ,
|
|
775
|
+
ref ,
|
|
776
|
+
before ,
|
|
777
|
+
) {
|
|
778
|
+
const now = await ctx.store.getMenu(ref.id);
|
|
779
|
+
if (!now) return "";
|
|
780
|
+
const was = before ? await ctx.store.readSnapshotNode (before) : undefined;
|
|
781
|
+
const d = menuDelta(was, now);
|
|
782
|
+
if (!was) return ` (${countMenuItems(now.items)} items)`;
|
|
783
|
+
const parts = [
|
|
784
|
+
d.added.length ? `+${d.added.length}` : "",
|
|
785
|
+
d.removed.length ? `−${d.removed.length}` : "",
|
|
786
|
+
d.moved.length ? `↕${d.moved.length}` : "",
|
|
787
|
+
d.changed.length ? `~${d.changed.length}` : "",
|
|
788
|
+
].filter(Boolean);
|
|
789
|
+
return parts.length ? ` (${parts.join(" ")})` : "";
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* For delete prompts: the menu items that will be hidden when `target` goes (invariant 10 — the
|
|
794
|
+
* menu itself is not written).
|
|
795
|
+
*/
|
|
796
|
+
export async function menuItemsHiddenBy(
|
|
797
|
+
ctx ,
|
|
798
|
+
siteId ,
|
|
799
|
+
target ,
|
|
800
|
+
) {
|
|
801
|
+
const refs = await ctx.store.menuReferences(siteId, target);
|
|
802
|
+
if (refs.length === 0) return "";
|
|
803
|
+
const parts = refs.map(
|
|
804
|
+
(r) => `${r.itemIds.length} item${r.itemIds.length === 1 ? "" : "s"} in menu "${r.menu.key}"`,
|
|
805
|
+
);
|
|
806
|
+
return ` Menus keep linking it but hide it: ${parts.join(", ")}.`;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* For create results: after a site adopts a `primary` menu, new pages no longer join the header on
|
|
811
|
+
* their own (RFC-0003 compatibility), so say so.
|
|
812
|
+
*/
|
|
813
|
+
export async function menuHint(
|
|
814
|
+
ctx ,
|
|
815
|
+
siteId ,
|
|
816
|
+
target ,
|
|
817
|
+
) {
|
|
818
|
+
const primary = await ctx.store.getMenuByKey(siteId, "primary");
|
|
819
|
+
if (!primary) return undefined;
|
|
820
|
+
const refs = await ctx.store.menuReferences(siteId, target);
|
|
821
|
+
if (refs.length > 0) return undefined;
|
|
822
|
+
return `not in any menu: this site has a "primary" menu, so new ${target.kind === "page" ? "pages" : "articles"} do not join the header on their own; use update_menu to add it`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ───────────────────────────── tools ─────────────────────────────
|
|
826
|
+
|
|
827
|
+
export function menuTools() {
|
|
828
|
+
const writeCommon = {
|
|
829
|
+
scopes: ["site:write" ],
|
|
830
|
+
requiresApproval: true,
|
|
831
|
+
};
|
|
832
|
+
return [
|
|
833
|
+
{
|
|
834
|
+
name: "list_menus",
|
|
835
|
+
title: "List menus",
|
|
836
|
+
description:
|
|
837
|
+
"The site's navigation menus (primary = header, footer, sidebar, custom keys) with id, rev, maxDepth, item count and how many items are hidden on the site. Well-known keys without a menu use the theme's derived default (top-level pages, categories).",
|
|
838
|
+
inputSchema: obj({ siteId: id }, ["siteId"]),
|
|
839
|
+
outputSchema: obj(
|
|
840
|
+
{
|
|
841
|
+
menus: {
|
|
842
|
+
type: "array",
|
|
843
|
+
items: obj(
|
|
844
|
+
{
|
|
845
|
+
id: { type: "string" },
|
|
846
|
+
key: { type: "string" },
|
|
847
|
+
rev: { type: "integer" },
|
|
848
|
+
maxDepth: { type: "integer" },
|
|
849
|
+
items: { type: "integer" },
|
|
850
|
+
hidden: { type: "integer" },
|
|
851
|
+
},
|
|
852
|
+
["id", "key", "rev", "maxDepth", "items", "hidden"],
|
|
853
|
+
),
|
|
854
|
+
},
|
|
855
|
+
derived: {
|
|
856
|
+
type: "array",
|
|
857
|
+
items: { type: "string" },
|
|
858
|
+
description: "Well-known keys without a menu (the theme's derived default shows)",
|
|
859
|
+
},
|
|
860
|
+
limits: { type: "object" },
|
|
861
|
+
},
|
|
862
|
+
["menus", "derived", "limits"],
|
|
863
|
+
),
|
|
864
|
+
annotations: READ,
|
|
865
|
+
scopes: ["site:read"],
|
|
866
|
+
handler: async (a, ctx) => {
|
|
867
|
+
const site = await requireSite(ctx, a.siteId );
|
|
868
|
+
const menus = await ctx.store.listMenus(site.id);
|
|
869
|
+
const lookup = menus.length ? menuLookup(await currentData(ctx, site)) : undefined;
|
|
870
|
+
return {
|
|
871
|
+
menus: menus.map((m) => ({
|
|
872
|
+
id: m.id,
|
|
873
|
+
key: m.key,
|
|
874
|
+
rev: m.rev,
|
|
875
|
+
maxDepth: m.maxDepth,
|
|
876
|
+
items: countMenuItems(m.items),
|
|
877
|
+
hidden: lookup ? resolveMenu(m, lookup, site.defaultLocale).hidden : 0,
|
|
878
|
+
})),
|
|
879
|
+
derived: WELL_KNOWN.filter((k) => !menus.some((m) => m.key === k)),
|
|
880
|
+
limits: { ...MENU_LIMITS, operationsPerCall: MENU_MAX_OPS },
|
|
881
|
+
};
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
name: "get_menu",
|
|
886
|
+
title: "Get menu",
|
|
887
|
+
description:
|
|
888
|
+
"One menu: the stored tree (send its rev back when writing) and, per item for a locale, whether the site shows it (`state`), why not (`reason`: missing, unpublished, other-locale, not-served, retired, no-index, empty-group, parent-hidden), the label shown and where it comes from (item, default-locale, target title) and the href. Hidden items are never rendered as dead links.",
|
|
889
|
+
inputSchema: obj(
|
|
890
|
+
{
|
|
891
|
+
siteId: id,
|
|
892
|
+
key: MENU_KEY,
|
|
893
|
+
locale: str("BCP 47 locale; defaults to the site's default", { pattern: LOCALE.pattern }),
|
|
894
|
+
},
|
|
895
|
+
["siteId", "key"],
|
|
896
|
+
),
|
|
897
|
+
outputSchema: {
|
|
898
|
+
...obj(
|
|
899
|
+
{
|
|
900
|
+
menu: MENU,
|
|
901
|
+
locale: { type: "string" },
|
|
902
|
+
items: { type: "array", items: { $ref: "#/$defs/ResolvedMenuItem" } },
|
|
903
|
+
shown: { type: "integer" },
|
|
904
|
+
hidden: { type: "integer" },
|
|
905
|
+
},
|
|
906
|
+
["menu", "locale", "items", "shown", "hidden"],
|
|
907
|
+
),
|
|
908
|
+
$defs: { ResolvedMenuItem: RESOLVED_ITEM },
|
|
909
|
+
},
|
|
910
|
+
annotations: READ,
|
|
911
|
+
scopes: ["site:read"],
|
|
912
|
+
handler: async (a, ctx) => {
|
|
913
|
+
const site = await requireSite(ctx, a.siteId );
|
|
914
|
+
const menu = await ctx.store.getMenuByKey(site.id, a.key );
|
|
915
|
+
if (!menu)
|
|
916
|
+
throw new ToolError(
|
|
917
|
+
"not_found",
|
|
918
|
+
`this site has no "${a.key}" menu; it shows the theme's derived default. update_menu with create: {} (or set_menu without rev) creates one`,
|
|
919
|
+
);
|
|
920
|
+
const locale = (a.locale ) ?? site.defaultLocale;
|
|
921
|
+
const resolved = resolveMenu(menu, menuLookup(await currentData(ctx, site)), locale);
|
|
922
|
+
const slim = (items ) =>
|
|
923
|
+
items.map(({ external: _e, newTab: _n, target: _t, ...rest }) => ({
|
|
924
|
+
...rest,
|
|
925
|
+
children: slim(rest.children),
|
|
926
|
+
}));
|
|
927
|
+
return {
|
|
928
|
+
menu,
|
|
929
|
+
locale,
|
|
930
|
+
items: slim(resolved.items),
|
|
931
|
+
shown: resolved.shown,
|
|
932
|
+
hidden: resolved.hidden,
|
|
933
|
+
};
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
name: "update_menu",
|
|
938
|
+
title: "Edit menu",
|
|
939
|
+
description:
|
|
940
|
+
'Edit a menu with operations, applied in order and all or nothing: add {item, parent?, index?}, remove {itemId} (with its subtree), move {itemId, parent?, index?}, update {itemId, labels?, target?, newTab?}. Items link pages, articles and terms by id or handle ({kind:"page", ref:"/about"}, {kind:"term", ref:"category:news"}), a collection index, an external https link, or group children. Requires the menu\'s rev; `create: {}` creates the menu when the key is free. Always waits for a person, who sees every change. Snapshots afterwards.',
|
|
941
|
+
inputSchema: {
|
|
942
|
+
...obj(
|
|
943
|
+
{
|
|
944
|
+
siteId: id,
|
|
945
|
+
key: MENU_KEY,
|
|
946
|
+
rev: { type: "integer", minimum: 0, description: "The menu's current rev (get_menu)" },
|
|
947
|
+
create: obj(
|
|
948
|
+
{ maxDepth: { type: "integer", minimum: 1, maximum: MENU_LIMITS.maxDepth } },
|
|
949
|
+
[],
|
|
950
|
+
{
|
|
951
|
+
description:
|
|
952
|
+
"Create the menu if the key is free (maxDepth: primary 3, footer 2, sidebar 6, others 3)",
|
|
953
|
+
},
|
|
954
|
+
),
|
|
955
|
+
ops: { type: "array", items: OP, minItems: 1, maxItems: MENU_MAX_OPS },
|
|
956
|
+
},
|
|
957
|
+
["siteId", "key", "ops"],
|
|
958
|
+
),
|
|
959
|
+
$defs: DEFS,
|
|
960
|
+
},
|
|
961
|
+
outputSchema: obj({ menu: MENU, delta: DELTA_OUT, snapshot: SNAPSHOT }, [
|
|
962
|
+
"menu",
|
|
963
|
+
"delta",
|
|
964
|
+
"snapshot",
|
|
965
|
+
]),
|
|
966
|
+
annotations: WRITE,
|
|
967
|
+
...writeCommon,
|
|
968
|
+
precheck: async (a, ctx) => {
|
|
969
|
+
await plan(ctx, "update_menu", a);
|
|
970
|
+
},
|
|
971
|
+
approvalPrompt: async (a, ctx) =>
|
|
972
|
+
menuPrompt(ctx, await plan(ctx, "update_menu", a), "update_menu"),
|
|
973
|
+
handler: (a, ctx) => write(ctx, "update_menu", a),
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
name: "set_menu",
|
|
977
|
+
title: "Replace menu",
|
|
978
|
+
description:
|
|
979
|
+
"Replace a menu's whole tree (or create the menu: omit rev). Items that keep their id keep their identity; items without one get new ids. Same item shape and rules as update_menu. Always waits for a person, who sees the delta. Snapshots afterwards.",
|
|
980
|
+
inputSchema: {
|
|
981
|
+
...obj(
|
|
982
|
+
{
|
|
983
|
+
siteId: id,
|
|
984
|
+
key: MENU_KEY,
|
|
985
|
+
rev: {
|
|
986
|
+
type: "integer",
|
|
987
|
+
minimum: 0,
|
|
988
|
+
description: "The menu's current rev; omit only to create it",
|
|
989
|
+
},
|
|
990
|
+
maxDepth: { type: "integer", minimum: 1, maximum: MENU_LIMITS.maxDepth },
|
|
991
|
+
items: { type: "array", items: { $ref: "#/$defs/MenuItemInput" }, maxItems: 100 },
|
|
992
|
+
},
|
|
993
|
+
["siteId", "key", "items"],
|
|
994
|
+
),
|
|
995
|
+
$defs: DEFS,
|
|
996
|
+
},
|
|
997
|
+
outputSchema: obj({ menu: MENU, delta: DELTA_OUT, snapshot: SNAPSHOT }, [
|
|
998
|
+
"menu",
|
|
999
|
+
"delta",
|
|
1000
|
+
"snapshot",
|
|
1001
|
+
]),
|
|
1002
|
+
annotations: WRITE,
|
|
1003
|
+
...writeCommon,
|
|
1004
|
+
precheck: async (a, ctx) => {
|
|
1005
|
+
await plan(ctx, "set_menu", a);
|
|
1006
|
+
},
|
|
1007
|
+
approvalPrompt: async (a, ctx) => menuPrompt(ctx, await plan(ctx, "set_menu", a), "set_menu"),
|
|
1008
|
+
handler: (a, ctx) => write(ctx, "set_menu", a),
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
name: "delete_menu",
|
|
1012
|
+
title: "Delete menu",
|
|
1013
|
+
description:
|
|
1014
|
+
"Delete a menu; the site falls back to the theme's derived default for that key (e.g. top-level pages in the header). Destructive: always waits for a person. Snapshots afterwards.",
|
|
1015
|
+
inputSchema: obj({ siteId: id, key: MENU_KEY, rev: { type: "integer", minimum: 0 } }, [
|
|
1016
|
+
"siteId",
|
|
1017
|
+
"key",
|
|
1018
|
+
"rev",
|
|
1019
|
+
]),
|
|
1020
|
+
outputSchema: obj({ snapshot: SNAPSHOT }, ["snapshot"]),
|
|
1021
|
+
annotations: { ...WRITE, destructiveHint: true },
|
|
1022
|
+
...writeCommon,
|
|
1023
|
+
precheck: async (a, ctx) => {
|
|
1024
|
+
const site = await requireSite(ctx, a.siteId );
|
|
1025
|
+
await baseMenu(ctx, site, a.key , a.rev , undefined, "delete_menu");
|
|
1026
|
+
},
|
|
1027
|
+
approvalPrompt: async (a, ctx) => {
|
|
1028
|
+
const site = await requireSite(ctx, a.siteId );
|
|
1029
|
+
const menu = await ctx.store.getMenuByKey(site.id, a.key );
|
|
1030
|
+
const place = PLACES[a.key ];
|
|
1031
|
+
return `Delete menu "${a.key }"${place ? ` (${place})` : ""} with ${menu ? countMenuItems(menu.items) : 0} items? With the next publish the site shows the theme's derived default there; earlier snapshots keep the menu.`;
|
|
1032
|
+
},
|
|
1033
|
+
handler: async (a, ctx) => {
|
|
1034
|
+
const site = await requireSite(ctx, a.siteId );
|
|
1035
|
+
const { existing } = await baseMenu(
|
|
1036
|
+
ctx,
|
|
1037
|
+
site,
|
|
1038
|
+
a.key ,
|
|
1039
|
+
a.rev ,
|
|
1040
|
+
undefined,
|
|
1041
|
+
"delete_menu",
|
|
1042
|
+
);
|
|
1043
|
+
try {
|
|
1044
|
+
await ctx.store.deleteMenu(existing );
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
throw toToolError(e);
|
|
1047
|
+
}
|
|
1048
|
+
ctx.report?.({ menu: { key: a.key , deleted: true } });
|
|
1049
|
+
return {
|
|
1050
|
+
snapshot: await autoSnapshot(ctx, site.id, "delete_menu", {
|
|
1051
|
+
"cloudhal.tool": "delete_menu",
|
|
1052
|
+
"cloudhal.menu": a.key ,
|
|
1053
|
+
}),
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
];
|
|
1058
|
+
}
|