@caelo-cms/shared 0.10.64 → 0.10.66
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/dist/build-page.d.ts +10 -0
- package/dist/build-page.d.ts.map +1 -1
- package/dist/build-page.js +43 -5
- package/dist/build-page.js.map +1 -1
- package/dist/google-oauth.d.ts +7 -0
- package/dist/google-oauth.d.ts.map +1 -1
- package/dist/google-oauth.js +9 -0
- package/dist/google-oauth.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +20 -2
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/markup-structure.d.ts +9 -1
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +90 -13
- package/dist/markup-structure.js.map +1 -1
- package/dist/mcp-access.d.ts +34 -0
- package/dist/mcp-access.d.ts.map +1 -0
- package/dist/mcp-access.js +58 -0
- package/dist/mcp-access.js.map +1 -0
- package/dist/module-id-tag.d.ts +26 -0
- package/dist/module-id-tag.d.ts.map +1 -0
- package/dist/module-id-tag.js +39 -0
- package/dist/module-id-tag.js.map +1 -0
- package/dist/nested-module-render.d.ts +4 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +7 -1
- package/dist/nested-module-render.js.map +1 -1
- package/dist/preview-compose.d.ts +2 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +12 -26
- package/dist/preview-compose.js.map +1 -1
- package/dist/release-manifest.d.ts +5 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +13 -0
- package/dist/release-manifest.js.map +1 -1
- package/dist/screenshot-capture.d.ts +32 -0
- package/dist/screenshot-capture.d.ts.map +1 -0
- package/dist/screenshot-capture.js +36 -0
- package/dist/screenshot-capture.js.map +1 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +9 -5
- package/dist/seo.js.map +1 -1
- package/dist/theme-literal-binding.d.ts.map +1 -1
- package/dist/theme-literal-binding.js +15 -4
- package/dist/theme-literal-binding.js.map +1 -1
- package/dist/theme-render.js +2 -1
- package/dist/theme-render.js.map +1 -1
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +6 -2
- package/dist/themes.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/build-page.test.ts +55 -0
- package/src/build-page.ts +45 -5
- package/src/google-oauth.ts +10 -0
- package/src/index.ts +2 -0
- package/src/link-canonicalise.test.ts +29 -0
- package/src/link-canonicalise.ts +19 -2
- package/src/markup-structure.test.ts +85 -0
- package/src/markup-structure.ts +104 -10
- package/src/mcp-access.ts +73 -0
- package/src/module-id-tag.ts +38 -0
- package/src/nested-module-render.test.ts +98 -5
- package/src/nested-module-render.ts +14 -1
- package/src/preview-compose-nested.test.ts +7 -2
- package/src/preview-compose.test.ts +27 -0
- package/src/preview-compose.ts +13 -28
- package/src/release-manifest.ts +13 -0
- package/src/screenshot-capture.ts +46 -0
- package/src/seo.test.ts +9 -0
- package/src/seo.ts +9 -5
- package/src/theme-importers/__tests__/proto-pollution.test.ts +9 -1
- package/src/theme-literal-binding.test.ts +20 -0
- package/src/theme-literal-binding.ts +16 -4
- package/src/theme-render-sanitize.test.ts +9 -0
- package/src/theme-render.ts +2 -1
- package/src/themes.ts +4 -2
- package/src/version.ts +1 -1
package/src/build-page.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { z } from "zod";
|
|
23
|
+
import { positionInputSchema } from "./ai-tools.js";
|
|
23
24
|
import {
|
|
24
25
|
contentInstanceCreateSchema,
|
|
25
26
|
MODULE_CSS_MAX,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
slugSchema,
|
|
32
33
|
syncModeSchema,
|
|
33
34
|
} from "./content.js";
|
|
35
|
+
import { SEO_DESCRIPTION_HARD_MAX } from "./seo.js";
|
|
34
36
|
|
|
35
37
|
/** Content values keyed by the module's declared field names. */
|
|
36
38
|
const contentValuesSchema = z.record(z.string(), z.unknown());
|
|
@@ -106,11 +108,25 @@ export const buildPageModuleSchema = z
|
|
|
106
108
|
* module / module-list field value. A detached entry requires `ref`.
|
|
107
109
|
*/
|
|
108
110
|
blockName: slugSchema.optional(),
|
|
111
|
+
/**
|
|
112
|
+
* Where in its block the entry lands — the same grammar as
|
|
113
|
+
* add_module's `position` ("top", "bottom", or a 0-based index).
|
|
114
|
+
* Omitted = "bottom": entries append in array order, so a new page
|
|
115
|
+
* needs none. Entries are inserted in array order, each into the
|
|
116
|
+
* block as it stands after the entries before it; an index past the
|
|
117
|
+
* end appends. Meaningless (and rejected) on a detached entry.
|
|
118
|
+
*
|
|
119
|
+
* Accepted because the AI carries `position` over from add_module,
|
|
120
|
+
* and on an existing page "put this at the top" is a real intent a
|
|
121
|
+
* pure append cannot express (e2e-livedit detected bugs).
|
|
122
|
+
*/
|
|
123
|
+
position: positionInputSchema.optional(),
|
|
109
124
|
/**
|
|
110
125
|
* Local handle other entries in the SAME call can reference: a
|
|
111
|
-
* module/module-list field value of `{"$ref": "<ref>"}`
|
|
112
|
-
* this entry's `{moduleId,
|
|
113
|
-
*
|
|
126
|
+
* module/module-list field value of `{"$ref": "<ref>"}` (or a
|
|
127
|
+
* `moduleId: {"$ref"}`) resolves to this entry's `{moduleId,
|
|
128
|
+
* contentInstanceId}`. Array position does not matter: the op mints
|
|
129
|
+
* referenced entries before the entries that embed them.
|
|
114
130
|
*/
|
|
115
131
|
ref: z
|
|
116
132
|
.string()
|
|
@@ -149,6 +165,14 @@ export const buildPageModuleSchema = z
|
|
|
149
165
|
})
|
|
150
166
|
.strict()
|
|
151
167
|
.superRefine((entry, ctx) => {
|
|
168
|
+
if (entry.blockName === undefined && entry.position !== undefined) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: z.ZodIssueCode.custom,
|
|
171
|
+
message:
|
|
172
|
+
"`position` places an entry inside its block, but this entry has no `blockName` (a DETACHED, " +
|
|
173
|
+
"nested-only module is not placed). Drop `position`, or pass `blockName` to place it.",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
152
176
|
if (entry.blockName === undefined && entry.ref === undefined) {
|
|
153
177
|
ctx.addIssue({
|
|
154
178
|
code: z.ZodIssueCode.custom,
|
|
@@ -211,6 +235,15 @@ export const buildPageTargetSchema = z
|
|
|
211
235
|
* Compatible with either target shape (create OR existing pageId).
|
|
212
236
|
*/
|
|
213
237
|
importPageId: z.string().uuid().optional(),
|
|
238
|
+
/**
|
|
239
|
+
* The new page's meta description, written in the same transaction
|
|
240
|
+
* with the fill-once semantics of `pages_seo.autofill` (CLAUDE.md §2).
|
|
241
|
+
* A staging build refuses a published page without one, so the page
|
|
242
|
+
* is Stage-ready from the call that creates it. Create mode (or an
|
|
243
|
+
* importPageId rebuild) only — an existing page gets its first
|
|
244
|
+
* description from `autofill_page_seo`.
|
|
245
|
+
*/
|
|
246
|
+
metaDescription: z.string().trim().min(1).max(SEO_DESCRIPTION_HARD_MAX).optional(),
|
|
214
247
|
})
|
|
215
248
|
.strict()
|
|
216
249
|
.superRefine((page, ctx) => {
|
|
@@ -218,6 +251,13 @@ export const buildPageTargetSchema = z
|
|
|
218
251
|
["slug", "title", "name", "templateId", "status", "parentPageId"] as const
|
|
219
252
|
).filter((k) => page[k] !== undefined);
|
|
220
253
|
if (page.pageId !== undefined) {
|
|
254
|
+
if (page.metaDescription !== undefined) {
|
|
255
|
+
ctx.addIssue({
|
|
256
|
+
code: z.ZodIssueCode.custom,
|
|
257
|
+
message:
|
|
258
|
+
"page.metaDescription is written only for a page this call CREATES — drop it with pageId; an existing page without a description gets one via autofill_page_seo.",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
221
261
|
if (createKeys.length > 0) {
|
|
222
262
|
ctx.addIssue({
|
|
223
263
|
code: z.ZodIssueCode.custom,
|
|
@@ -250,8 +290,8 @@ export const buildPageInputSchema = z
|
|
|
250
290
|
})
|
|
251
291
|
.strict()
|
|
252
292
|
.superRefine((input, ctx) => {
|
|
253
|
-
// `ref` handles must be unique — a duplicate would
|
|
254
|
-
//
|
|
293
|
+
// `ref` handles must be unique — a duplicate would make a {"$ref"}
|
|
294
|
+
// ambiguous.
|
|
255
295
|
const seen = new Set<string>();
|
|
256
296
|
for (const [i, entry] of input.modules.entries()) {
|
|
257
297
|
if (entry.ref === undefined) continue;
|
package/src/google-oauth.ts
CHANGED
|
@@ -25,3 +25,13 @@ export const GOOGLE_OAUTH_CLIENT_ID = /^[0-9]+-[a-z0-9]+\.apps\.googleuserconten
|
|
|
25
25
|
export function iapRedirectUri(clientId: string): string {
|
|
26
26
|
return `https://iap.googleapis.com/v1/oauth/clientIds/${clientId}:handleRedirect`;
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* #967 — the Google console page of one OAuth client, where its authorized
|
|
31
|
+
* redirect URIs and client secrets are edited. Shared so the setup page can
|
|
32
|
+
* link it as the Owner types the client ID, and the preflight can name it
|
|
33
|
+
* in every fix.
|
|
34
|
+
*/
|
|
35
|
+
export function oauthClientConsoleUrl(projectId: string, clientId: string): string {
|
|
36
|
+
return `https://console.cloud.google.com/auth/clients/${encodeURIComponent(clientId)}?project=${encodeURIComponent(projectId)}`;
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
type StructuredLogEntry,
|
|
44
44
|
} from "./logger.js";
|
|
45
45
|
export * from "./markup-structure.js";
|
|
46
|
+
export * from "./mcp-access.js";
|
|
46
47
|
export * from "./media.js";
|
|
47
48
|
export * from "./nested-module-render.js";
|
|
48
49
|
export * from "./page-log.js";
|
|
@@ -64,6 +65,7 @@ export * from "./release-manifest.js";
|
|
|
64
65
|
export * from "./responsive-images.js";
|
|
65
66
|
export * from "./result.js";
|
|
66
67
|
export * from "./safe-keys.js";
|
|
68
|
+
export * from "./screenshot-capture.js";
|
|
67
69
|
export * from "./seo.js";
|
|
68
70
|
export * from "./skills.js";
|
|
69
71
|
export * from "./snapshots.js";
|
|
@@ -180,6 +180,35 @@ describe("canonicaliseUrl", () => {
|
|
|
180
180
|
expect(strict.issues.map((i) => i.kind)).toEqual(["unknown-page-path"]);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// e2e-livedit detected bugs: the site header (brand link "/", nav
|
|
184
|
+
// "/#features") is built before the homepage exists and was refused.
|
|
185
|
+
it("keeps the site root literal while no page serves it, never an issue", () => {
|
|
186
|
+
const noRoot: LinkTargetLookup = {
|
|
187
|
+
...lookup,
|
|
188
|
+
pageIdForPath: (p) => (p === "/" ? null : lookup.pageIdForPath(p)),
|
|
189
|
+
};
|
|
190
|
+
const r = linkCanonicaliseRun(noRoot);
|
|
191
|
+
expect(canonicaliseUrl("/", r, "html")).toBe("/");
|
|
192
|
+
expect(canonicaliseUrl("/#features", r, "fields.nav.default[1].href")).toBe("/#features");
|
|
193
|
+
expect(canonicaliseUrl("https://example.com/?ref=nav", r, "x")).toBe(
|
|
194
|
+
"https://example.com/?ref=nav",
|
|
195
|
+
);
|
|
196
|
+
expect(canonicaliseHtmlLinks('<a href="/">Home</a><a href="/#features">F</a>', r, "html")).toBe(
|
|
197
|
+
'<a href="/">Home</a><a href="/#features">F</a>',
|
|
198
|
+
);
|
|
199
|
+
expect(r.issues).toEqual([]);
|
|
200
|
+
// A suffix the stored grammar cannot carry is still refused, and any
|
|
201
|
+
// other unknown path still is an issue: only the root is guaranteed.
|
|
202
|
+
canonicaliseUrl('/#bad"anchor', r, "y");
|
|
203
|
+
canonicaliseUrl("/get-started", r, "z");
|
|
204
|
+
expect(r.issues.map((i) => [i.kind, i.value])).toEqual([
|
|
205
|
+
["unsupported-link-form", '/#bad"anchor'],
|
|
206
|
+
["unknown-page-path", "/get-started"],
|
|
207
|
+
]);
|
|
208
|
+
// Once the homepage exists the root becomes its reference as usual.
|
|
209
|
+
expect(canonicaliseUrl("/#features", run(), "x")).toBe(`caelo:page/${HOME}#features`);
|
|
210
|
+
});
|
|
211
|
+
|
|
183
212
|
it("reports a malformed reference instead of passing it through", () => {
|
|
184
213
|
const r = run();
|
|
185
214
|
const bad = [
|
package/src/link-canonicalise.ts
CHANGED
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* that run not built yet is kept and listed in `run.pending`; the
|
|
40
40
|
* caller records it and the run's finalisation converts it (or
|
|
41
41
|
* reports it) once the run's pages exist.
|
|
42
|
+
* - the site root: `/` (with an optional `#anchor` / `?query`) while no
|
|
43
|
+
* page serves it yet is kept literal, not refused. Every site has a
|
|
44
|
+
* root page by the time it ships — the staged build refuses one
|
|
45
|
+
* without — so the link cannot dangle, and site chrome (the brand
|
|
46
|
+
* link, `/#features` nav items) is routinely written before the
|
|
47
|
+
* homepage. Once the homepage exists, `/` canonicalises as usual.
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
import {
|
|
@@ -322,6 +328,16 @@ export function canonicaliseUrl(
|
|
|
322
328
|
run.pending.push({ value: v, path: normalised, where });
|
|
323
329
|
return value;
|
|
324
330
|
}
|
|
331
|
+
if (pageId === null && normalised === "/") {
|
|
332
|
+
// The site root before its page exists (a header built ahead of the
|
|
333
|
+
// homepage). It cannot dangle: the staged build refuses a site with
|
|
334
|
+
// no page at "/" (CLAUDE.md §2: loud there, not here). Kept literal;
|
|
335
|
+
// the next save after the homepage exists stores its reference.
|
|
336
|
+
if ((query !== null && !QUERY_OK.test(query)) || (anchor !== null && !ANCHOR_OK.test(anchor))) {
|
|
337
|
+
if (!run.grandfathered.has(v)) issue(run, "unsupported-link-form", v, where);
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
325
341
|
if (pageId === null) {
|
|
326
342
|
const lastSegment = path.slice(path.lastIndexOf("/") + 1);
|
|
327
343
|
const isFile = FILE_EXT_RE.test(lastSegment);
|
|
@@ -592,7 +608,8 @@ export function describeLinkIssues(issues: readonly LinkCanonicaliseIssue[]): st
|
|
|
592
608
|
`Internal link check failed — ${lines.join("; ")}${more}. ` +
|
|
593
609
|
"Internal links must point at an existing page or media asset. Next step: use the exact path " +
|
|
594
610
|
"of an existing page (list_pages shows every page's path) or an existing media URL (find_media); " +
|
|
595
|
-
"if the page does not exist yet, create it first (
|
|
596
|
-
"
|
|
611
|
+
"if the page does not exist yet, create it first (build_page with `modules: []` makes an empty page " +
|
|
612
|
+
"shell) or leave the link out and add it once the page exists. The site root `/` is always valid. " +
|
|
613
|
+
"For a link to another site, write the full https:// URL."
|
|
597
614
|
);
|
|
598
615
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
stripMustache,
|
|
15
15
|
templateShape,
|
|
16
16
|
} from "./markup-structure.js";
|
|
17
|
+
import { tagModuleId } from "./module-id-tag.js";
|
|
17
18
|
|
|
18
19
|
const chain = (steps: string[]) =>
|
|
19
20
|
`<div class="chain">${steps
|
|
@@ -164,6 +165,90 @@ describe("templates and free regions", () => {
|
|
|
164
165
|
});
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
describe("#1027 — template children are matched by best fit, not first fit", () => {
|
|
169
|
+
// The three templates from the searchVIU audit: a classless (or less
|
|
170
|
+
// specific) left column before a classed right column. First-fit paired
|
|
171
|
+
// the left column's template node with the right column and reported
|
|
172
|
+
// the right column's own template markup as free HTML.
|
|
173
|
+
const DEMO_FORM =
|
|
174
|
+
'<div class="wrapc ctaform"><div><h2>{{title}}</h2><p>{{intro}}</p></div>' +
|
|
175
|
+
'<div class="box steps" data-steps data-topic="{{preselected_topic}}">' +
|
|
176
|
+
'<ol class="sbar"><li class="on">Thema</li><li>Kontakt</li><li>Termin</li></ol>' +
|
|
177
|
+
'<div class="step"><label>Thema</label><select name="topic"><option>SEO</option></select></div>' +
|
|
178
|
+
'<div class="step"><label>E-Mail</label><input type="email" name="email"></div>' +
|
|
179
|
+
'<div class="step"><button type="submit">Demo anfragen</button></div></div></div>';
|
|
180
|
+
const SPLIT_CHAT =
|
|
181
|
+
'<section class="split"><div class="rv"><h2>{{title}}</h2><p>{{lead}}</p></div>' +
|
|
182
|
+
'<div class="chat rv"><div class="q"><p>Wie steht meine Seite?</p></div>' +
|
|
183
|
+
'<div class="a"><p>Drei Seiten haben neue 404-Fehler.</p><ul><li>/a</li></ul></div></div></section>';
|
|
184
|
+
const HERO_WINDOW =
|
|
185
|
+
'<section class="hero"><div><h1>{{title}}</h1><p>{{lead}}</p><a class="btn" href="{{cta_href}}">{{cta}}</a></div>' +
|
|
186
|
+
'<div class="win"><div class="bar"><span></span><span></span></div>' +
|
|
187
|
+
'<div class="bd"><img src="{{image}}" alt=""><p class="cap">Monitoring</p></div></div></section>';
|
|
188
|
+
|
|
189
|
+
/** The rendered module: placeholders filled, root tagged as the composer does. */
|
|
190
|
+
const render = (template: string, fill = "x") =>
|
|
191
|
+
tagModuleId(template.replace(/\{\{\{?\w+\}?\}\}/g, fill), "m1");
|
|
192
|
+
const free = (template: string, rendered: string) => {
|
|
193
|
+
const root = moduleRoots(parseMarkup(rendered))[0];
|
|
194
|
+
if (!root) throw new Error("no module root");
|
|
195
|
+
return freeRegions(root, parseMarkup(stripMustache(template)));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
for (const [name, template] of [
|
|
199
|
+
["Demo-Formular in drei Schritten (div before div.box.steps)", DEMO_FORM],
|
|
200
|
+
["Split mit KI-Chat (div.rv before div.chat.rv)", SPLIT_CHAT],
|
|
201
|
+
["Hero Produktseite mit Fenster (div before div.win)", HERO_WINDOW],
|
|
202
|
+
] as const) {
|
|
203
|
+
it(`${name}: its own rendered output has no free regions`, () => {
|
|
204
|
+
expect(free(template, render(template))).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("a classless div before a div.card sibling: no free regions", () => {
|
|
209
|
+
const template =
|
|
210
|
+
'<div class="pair"><div><p>{{a}}</p></div><div class="card"><h3>{{t}}</h3><ul class="pts"><li>{{p}}</li></ul></div></div>';
|
|
211
|
+
expect(free(template, render(template))).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("two classless siblings with different insides: the one that accounts wins", () => {
|
|
215
|
+
const template =
|
|
216
|
+
'<div class="two"><div><h2>{{t}}</h2><p>{{p}}</p></div><div><ul class="x"><li>{{i}}</li></ul></div></div>';
|
|
217
|
+
expect(free(template, render(template))).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a section repeating a classed child still matches every iteration", () => {
|
|
221
|
+
const template =
|
|
222
|
+
'<div class="grid"><div><h2>{{title}}</h2></div>{{#cards}}<div class="card"><p>{{.}}</p></div>{{/cards}}</div>';
|
|
223
|
+
const rendered = tagModuleId(
|
|
224
|
+
'<div class="grid"><div><h2>T</h2></div><div class="card"><p>a</p></div><div class="card"><p>b</p></div></div>',
|
|
225
|
+
"m1",
|
|
226
|
+
);
|
|
227
|
+
expect(free(template, rendered)).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("stays fast when many rendered children each fit several template children", () => {
|
|
231
|
+
// Every rendered <div> fits all eight classless template <div>s, so the
|
|
232
|
+
// best-fit choice weighs each candidate; the per-pair memo keeps that
|
|
233
|
+
// linear in the rendered size.
|
|
234
|
+
const template = `<div class="wide">${"<div><div><p>{{t}}</p></div></div>".repeat(8)}</div>`;
|
|
235
|
+
const rendered = tagModuleId(
|
|
236
|
+
`<div class="wide">${"<div><div><p>x</p><span>y</span></div></div>".repeat(5000)}</div>`,
|
|
237
|
+
"m1",
|
|
238
|
+
);
|
|
239
|
+
const started = performance.now();
|
|
240
|
+
expect(free(template, rendered)?.length).toBe(5000);
|
|
241
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("markup pasted into a field value of such a template is still free (#820)", () => {
|
|
245
|
+
const pasted = chain(["a", "b"]);
|
|
246
|
+
const rendered = render(HERO_WINDOW).replace("<p>x</p>", `<p>x</p>${pasted}`);
|
|
247
|
+
const regions = free(HERO_WINDOW, rendered) ?? [];
|
|
248
|
+
expect(outermostPatterns(regions).map((f) => f.node.classes[0])).toEqual(["chain"]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
167
252
|
describe("bounds", () => {
|
|
168
253
|
it("stops collecting elements at MAX_NODES", () => {
|
|
169
254
|
const html = "<div>".repeat(10) + "<i></i>".repeat(MAX_NODES + 100) + "</div>".repeat(10);
|
package/src/markup-structure.ts
CHANGED
|
@@ -256,7 +256,15 @@ export function templateShape(templateHtml: string): SubtreeShape | null {
|
|
|
256
256
|
* walked in step with the template tree; a rendered child that no template
|
|
257
257
|
* child can account for (same tag, template's classes all present) starts
|
|
258
258
|
* a free region. List sections still match, because every iteration of a
|
|
259
|
-
* section renders the same template child.
|
|
259
|
+
* section renders the same template child. Nested module roots
|
|
260
|
+
* (`data-caelo-module-id`) are skipped: their own template accounts for
|
|
261
|
+
* them.
|
|
262
|
+
*
|
|
263
|
+
* Several template children can account for one rendered child — a
|
|
264
|
+
* classless `<div>` accounts for any `<div>` — so the walk picks the best
|
|
265
|
+
* one ({@link pickTemplateChild}), not the first: taking the first paired
|
|
266
|
+
* a classless left column with the classed right column and reported the
|
|
267
|
+
* right column's template markup as free (#1027).
|
|
260
268
|
*
|
|
261
269
|
* @returns the free region roots, or null when the rendered root does not
|
|
262
270
|
* match the template root at all (a module the composer renders from
|
|
@@ -268,17 +276,103 @@ export function freeRegions(
|
|
|
268
276
|
): MarkupNode[] | null {
|
|
269
277
|
const tRoot = templateRoots[0];
|
|
270
278
|
if (!tRoot || !accounts(tRoot, rendered)) return null;
|
|
279
|
+
return [...attribute(rendered, tRoot, new Map()).free];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** How one rendered subtree attributes against one template subtree. */
|
|
283
|
+
interface Attribution {
|
|
284
|
+
/** The free region roots below the rendered node. */
|
|
285
|
+
readonly free: readonly MarkupNode[];
|
|
286
|
+
/** Elements inside those regions: the cost the best match minimises. */
|
|
287
|
+
readonly freeNodes: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
type AttributionMemo = Map<MarkupNode, Map<MarkupNode, Attribution>>;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Attribute the children of `r`, rendered from template node `t`.
|
|
294
|
+
* Memoised per (rendered, template) pair, because the best-match choice
|
|
295
|
+
* evaluates a rendered child against every candidate that accounts for
|
|
296
|
+
* it; each pair is computed once, so the walk stays polynomial.
|
|
297
|
+
*/
|
|
298
|
+
function attribute(r: MarkupNode, t: MarkupNode, memo: AttributionMemo): Attribution {
|
|
299
|
+
let byTemplate = memo.get(r);
|
|
300
|
+
const hit = byTemplate?.get(t);
|
|
301
|
+
if (hit) return hit;
|
|
271
302
|
const free: MarkupNode[] = [];
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
let freeNodes = 0;
|
|
304
|
+
// Template index of the previous rendered child's match: siblings
|
|
305
|
+
// render in template order (a section repeats one child in place).
|
|
306
|
+
let cursor = 0;
|
|
307
|
+
for (const c of r.children) {
|
|
308
|
+
if (c.moduleId !== null) continue;
|
|
309
|
+
const match = pickTemplateChild(c, t.children, cursor, memo);
|
|
310
|
+
if (match) {
|
|
311
|
+
cursor = match.index;
|
|
312
|
+
for (const f of match.attribution.free) free.push(f);
|
|
313
|
+
freeNodes += match.attribution.freeNodes;
|
|
314
|
+
} else {
|
|
315
|
+
free.push(c);
|
|
316
|
+
freeNodes += countNodes(c);
|
|
278
317
|
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
318
|
+
}
|
|
319
|
+
const out: Attribution = { free, freeNodes };
|
|
320
|
+
if (!byTemplate) {
|
|
321
|
+
byTemplate = new Map();
|
|
322
|
+
memo.set(r, byTemplate);
|
|
323
|
+
}
|
|
324
|
+
byTemplate.set(t, out);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The template child that rendered `c`, among those that account for it:
|
|
330
|
+
* 1. the exact class set wins over a subset (`div.chat.rv` over `div.rv`),
|
|
331
|
+
* 2. then the most classes in common, so a classless template child
|
|
332
|
+
* wins only when no classed one accounts for `c`,
|
|
333
|
+
* 3. then the one whose subtree leaves the fewest elements unaccounted,
|
|
334
|
+
* 4. then sibling order: at or after the previous match, nearest first.
|
|
335
|
+
*/
|
|
336
|
+
function pickTemplateChild(
|
|
337
|
+
c: MarkupNode,
|
|
338
|
+
candidates: readonly MarkupNode[],
|
|
339
|
+
cursor: number,
|
|
340
|
+
memo: AttributionMemo,
|
|
341
|
+
): { readonly index: number; readonly attribution: Attribution } | null {
|
|
342
|
+
let best: {
|
|
343
|
+
readonly index: number;
|
|
344
|
+
readonly attribution: Attribution;
|
|
345
|
+
readonly rank: readonly number[];
|
|
346
|
+
} | null = null;
|
|
347
|
+
for (const [index, tc] of candidates.entries()) {
|
|
348
|
+
if (!accounts(tc, c)) continue;
|
|
349
|
+
const attribution = attribute(c, tc, memo);
|
|
350
|
+
const rank = [
|
|
351
|
+
tc.classes.length === c.classes.length ? 1 : 0,
|
|
352
|
+
tc.classes.length,
|
|
353
|
+
-attribution.freeNodes,
|
|
354
|
+
index >= cursor ? 1 : 0,
|
|
355
|
+
-Math.abs(index - cursor),
|
|
356
|
+
];
|
|
357
|
+
if (!best || outranks(rank, best.rank)) best = { index, attribution, rank };
|
|
358
|
+
}
|
|
359
|
+
return best;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function outranks(a: readonly number[], b: readonly number[]): boolean {
|
|
363
|
+
for (let i = 0; i < a.length; i++) {
|
|
364
|
+
const x = a[i] ?? 0;
|
|
365
|
+
const y = b[i] ?? 0;
|
|
366
|
+
if (x !== y) return x > y;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Elements in a subtree, nested module roots excluded (they are not free). */
|
|
372
|
+
function countNodes(n: MarkupNode): number {
|
|
373
|
+
let count = 1;
|
|
374
|
+
for (const c of n.children) if (c.moduleId === null) count += countNodes(c);
|
|
375
|
+
return count;
|
|
282
376
|
}
|
|
283
377
|
|
|
284
378
|
function accounts(template: MarkupNode, rendered: MarkupNode): boolean {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #968 — the facts about Caelo's MCP access paths that the admin UI,
|
|
5
|
+
* the remote resolver's error messages and the docs must agree on: which
|
|
6
|
+
* surface needs which permission, what each surface is for in plain words,
|
|
7
|
+
* and how a client app's key reads to a person. Client-safe (no server
|
|
8
|
+
* imports): Settings → MCP renders it, admin-core words refusals with it.
|
|
9
|
+
* The concept is docs/dev/mcp-access.md.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A remote MCP surface: talk to the site's AI, or drive its admin tools. */
|
|
13
|
+
export type McpSurfaceName = "chat" | "admin";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The permission each remote surface needs. Chat writes content through
|
|
17
|
+
* Caelo's own AI; admin exposes every tool the AI has, so it needs what an
|
|
18
|
+
* admin-scoped MCP token needs. Owners have both.
|
|
19
|
+
*/
|
|
20
|
+
export const MCP_SURFACE_PERMISSION: Readonly<
|
|
21
|
+
Record<McpSurfaceName, "content.write" | "settings.write">
|
|
22
|
+
> = {
|
|
23
|
+
chat: "content.write",
|
|
24
|
+
admin: "settings.write",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What a surface does and who may use it, in the words the UI and errors use. */
|
|
28
|
+
export const MCP_SURFACE_COPY: Readonly<
|
|
29
|
+
Record<McpSurfaceName, { readonly title: string; readonly does: string; readonly who: string }>
|
|
30
|
+
> = {
|
|
31
|
+
chat: {
|
|
32
|
+
title: "Chat with this site's AI",
|
|
33
|
+
does: "The AI tool sends requests to Caelo's own AI, which builds and edits the site like the Live Edit chat does.",
|
|
34
|
+
who: "Everyone who may edit content (content.write).",
|
|
35
|
+
},
|
|
36
|
+
admin: {
|
|
37
|
+
title: "Full admin access",
|
|
38
|
+
does: "The AI tool gets every tool Caelo's AI has and does the work itself, on a preview branch. Approval-gated actions still wait for an Owner's click.",
|
|
39
|
+
who: "Owners and roles that may change settings (settings.write).",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A suggested connector name, so two connectors to one site in the same AI
|
|
45
|
+
* tool are told apart: "Caelo viu.one (Chat)", "Caelo viu.one (Admin)".
|
|
46
|
+
*/
|
|
47
|
+
export function mcpConnectorName(siteHost: string, surface: McpSurfaceName): string {
|
|
48
|
+
return `Caelo ${siteHost} (${surface === "admin" ? "Admin" : "Chat"})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How a client app's key (the gateway's `mcpClientKey`) reads to a person.
|
|
53
|
+
* Known CIMD documents get their product name; a DCR key shows the name the
|
|
54
|
+
* app registered with (unverified — anyone can register any name).
|
|
55
|
+
*/
|
|
56
|
+
export function describeMcpClient(clientKey: string | null): string {
|
|
57
|
+
if (clientKey === null) return "An app from before per-app connections";
|
|
58
|
+
if (clientKey.startsWith("dcr:sha256:") || clientKey.startsWith("sha256:")) {
|
|
59
|
+
return "An app that did not give its name";
|
|
60
|
+
}
|
|
61
|
+
if (clientKey.startsWith("dcr:")) return clientKey.slice(4);
|
|
62
|
+
let url: URL;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(clientKey);
|
|
65
|
+
} catch {
|
|
66
|
+
return clientKey;
|
|
67
|
+
}
|
|
68
|
+
if (url.hostname === "chatgpt.com") return "ChatGPT";
|
|
69
|
+
if (url.hostname === "claude.ai") {
|
|
70
|
+
return url.pathname.includes("claude-code") ? "Claude Code" : "Claude";
|
|
71
|
+
}
|
|
72
|
+
return url.hostname;
|
|
73
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `data-caelo-module-id` stamp on a module's root element. Every
|
|
5
|
+
* module root carries it, page placements (the composer) and nested
|
|
6
|
+
* modules in `module` / `module-list` fields (nested-module-render.ts)
|
|
7
|
+
* alike, so anything reading rendered HTML — the live-edit overlay, the
|
|
8
|
+
* core `structure` and `links` quality checks — can tell which module
|
|
9
|
+
* rendered which element without another mapping (#1027).
|
|
10
|
+
*
|
|
11
|
+
* Its own file because both the composer and the nested renderer use
|
|
12
|
+
* it, and the composer already imports the nested renderer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The attribute every module root carries. */
|
|
16
|
+
export const MODULE_ID_ATTRIBUTE = "data-caelo-module-id";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Insert `data-caelo-module-id="<id>"` into the first opening tag of
|
|
20
|
+
* the module's HTML. Idempotent — an already-tagged first element keeps
|
|
21
|
+
* its id, so a nested module that renders as its parent's first element
|
|
22
|
+
* stays attributed to itself. Comments / DOCTYPE / leading whitespace
|
|
23
|
+
* before the first tag are tolerated. HTML without an opening tag (pure
|
|
24
|
+
* text, a failure comment) returns unchanged because there's nothing to
|
|
25
|
+
* attach to.
|
|
26
|
+
*
|
|
27
|
+
* @example tagModuleId("<p>hi</p>", "abc") // '<p data-caelo-module-id="abc">hi</p>'
|
|
28
|
+
*/
|
|
29
|
+
export function tagModuleId(html: string, moduleId: string): string {
|
|
30
|
+
if (!html) return html;
|
|
31
|
+
const firstOpen = /<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/;
|
|
32
|
+
const m = firstOpen.exec(html);
|
|
33
|
+
if (!m) return html;
|
|
34
|
+
const tagAttrs = m[2] ?? "";
|
|
35
|
+
if (/\sdata-caelo-module-id\s*=/.test(tagAttrs)) return html;
|
|
36
|
+
const replaced = `<${m[1]}${tagAttrs} ${MODULE_ID_ATTRIBUTE}="${moduleId}">`;
|
|
37
|
+
return html.slice(0, m.index) + replaced + html.slice(m.index + m[0].length);
|
|
38
|
+
}
|