@cloudhal/mcp-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/src/analytics-reports.d.ts +164 -0
  4. package/dist/src/analytics-reports.js +632 -0
  5. package/dist/src/analytics-tools.d.ts +4 -0
  6. package/dist/src/analytics-tools.js +597 -0
  7. package/dist/src/asset-tools.d.ts +5 -0
  8. package/dist/src/asset-tools.js +685 -0
  9. package/dist/src/body-tools.d.ts +46 -0
  10. package/dist/src/body-tools.js +708 -0
  11. package/dist/src/changes.d.ts +10 -0
  12. package/dist/src/changes.js +112 -0
  13. package/dist/src/chrome-tools.d.ts +8 -0
  14. package/dist/src/chrome-tools.js +207 -0
  15. package/dist/src/comment-tools.d.ts +9 -0
  16. package/dist/src/comment-tools.js +743 -0
  17. package/dist/src/comments.d.ts +184 -0
  18. package/dist/src/comments.js +408 -0
  19. package/dist/src/common.d.ts +258 -0
  20. package/dist/src/common.js +488 -0
  21. package/dist/src/content-shared.d.ts +50 -0
  22. package/dist/src/content-shared.js +102 -0
  23. package/dist/src/design-tools.d.ts +9 -0
  24. package/dist/src/design-tools.js +349 -0
  25. package/dist/src/elevation-tools.d.ts +3 -0
  26. package/dist/src/elevation-tools.js +331 -0
  27. package/dist/src/entry-terms.d.ts +14 -0
  28. package/dist/src/entry-terms.js +51 -0
  29. package/dist/src/entry-tools.d.ts +4 -0
  30. package/dist/src/entry-tools.js +442 -0
  31. package/dist/src/http.d.ts +48 -0
  32. package/dist/src/http.js +101 -0
  33. package/dist/src/index.d.ts +10 -0
  34. package/dist/src/index.js +10 -0
  35. package/dist/src/menu-tools.d.ts +41 -0
  36. package/dist/src/menu-tools.js +1058 -0
  37. package/dist/src/page-tools.d.ts +5 -0
  38. package/dist/src/page-tools.js +311 -0
  39. package/dist/src/preview-doc.d.ts +9 -0
  40. package/dist/src/preview-doc.js +56 -0
  41. package/dist/src/preview-view.d.ts +19 -0
  42. package/dist/src/preview-view.js +71 -0
  43. package/dist/src/publish-tools.d.ts +10 -0
  44. package/dist/src/publish-tools.js +248 -0
  45. package/dist/src/render/cdp.d.ts +29 -0
  46. package/dist/src/render/cdp.js +190 -0
  47. package/dist/src/render/checks/components.d.ts +5 -0
  48. package/dist/src/render/checks/components.js +95 -0
  49. package/dist/src/render/checks/content.d.ts +5 -0
  50. package/dist/src/render/checks/content.js +63 -0
  51. package/dist/src/render/checks/contrast.d.ts +5 -0
  52. package/dist/src/render/checks/contrast.js +72 -0
  53. package/dist/src/render/checks/index.d.ts +1 -0
  54. package/dist/src/render/checks/index.js +29 -0
  55. package/dist/src/render/checks/layout.d.ts +5 -0
  56. package/dist/src/render/checks/layout.js +44 -0
  57. package/dist/src/render/checks/motion.d.ts +5 -0
  58. package/dist/src/render/checks/motion.js +66 -0
  59. package/dist/src/render/checks/navigation.d.ts +5 -0
  60. package/dist/src/render/checks/navigation.js +78 -0
  61. package/dist/src/render/checks/prelude.d.ts +1 -0
  62. package/dist/src/render/checks/prelude.js +64 -0
  63. package/dist/src/render/limiter.d.ts +11 -0
  64. package/dist/src/render/limiter.js +29 -0
  65. package/dist/src/render/page.d.ts +19 -0
  66. package/dist/src/render/page.js +135 -0
  67. package/dist/src/render/types.d.ts +89 -0
  68. package/dist/src/render/types.js +140 -0
  69. package/dist/src/render-tools.d.ts +14 -0
  70. package/dist/src/render-tools.js +314 -0
  71. package/dist/src/render.d.ts +14 -0
  72. package/dist/src/render.js +33 -0
  73. package/dist/src/schedule-tools.d.ts +2 -0
  74. package/dist/src/schedule-tools.js +1596 -0
  75. package/dist/src/schedules.d.ts +292 -0
  76. package/dist/src/schedules.js +890 -0
  77. package/dist/src/schema-transforms.d.ts +14 -0
  78. package/dist/src/schema-transforms.js +50 -0
  79. package/dist/src/search-tools.d.ts +2 -0
  80. package/dist/src/search-tools.js +112 -0
  81. package/dist/src/server-approvals.d.ts +51 -0
  82. package/dist/src/server-approvals.js +273 -0
  83. package/dist/src/server-call.d.ts +28 -0
  84. package/dist/src/server-call.js +259 -0
  85. package/dist/src/server-instructions.d.ts +4 -0
  86. package/dist/src/server-instructions.js +74 -0
  87. package/dist/src/server-resources.d.ts +4 -0
  88. package/dist/src/server-resources.js +83 -0
  89. package/dist/src/server-schema.d.ts +20 -0
  90. package/dist/src/server-schema.js +82 -0
  91. package/dist/src/server.d.ts +80 -0
  92. package/dist/src/server.js +135 -0
  93. package/dist/src/stdio.d.ts +9 -0
  94. package/dist/src/stdio.js +18 -0
  95. package/dist/src/surface-tools.d.ts +4 -0
  96. package/dist/src/surface-tools.js +357 -0
  97. package/dist/src/taxonomy-tools.d.ts +16 -0
  98. package/dist/src/taxonomy-tools.js +1433 -0
  99. package/dist/src/terms.d.ts +47 -0
  100. package/dist/src/terms.js +271 -0
  101. package/dist/src/tools.d.ts +39 -0
  102. package/dist/src/tools.js +511 -0
  103. package/dist/src/translation-tools.d.ts +3 -0
  104. package/dist/src/translation-tools.js +422 -0
  105. package/package.json +86 -3
@@ -0,0 +1,248 @@
1
+ /** Snapshot, diff, preview, publish and rollback tools (approval-gated publishing). */
2
+
3
+
4
+ import { ownOnlyChangeSet } from "@cloudhal/agent-runtime";
5
+ import { describeChanges } from "./changes.js";
6
+ import {
7
+ ACTOR_OWNERSHIP,
8
+ callerPerson,
9
+ id,
10
+ mapError,
11
+ obj,
12
+ quote,
13
+ requireSite,
14
+ str,
15
+
16
+
17
+ ToolError,
18
+ } from "./common.js";
19
+ import { SITE, SNAPSHOT } from "./content-shared.js";
20
+ import { publishMenuLines } from "./menu-tools.js";
21
+ import { previewDoc } from "./preview-doc.js";
22
+ import { PREVIEW_UI_URI } from "./preview-view.js";
23
+
24
+ const createSnapshotTool = {
25
+ name: "create_snapshot",
26
+ title: "Create snapshot",
27
+ description: "Freeze the current state of the site as an immutable, labelled snapshot.",
28
+ inputSchema: obj({ siteId: id, label: str("Short label, e.g. 'Before: retitle about page'") }, [
29
+ "siteId",
30
+ ]),
31
+ outputSchema: obj({ snapshot: SNAPSHOT }, ["snapshot"]),
32
+ annotations: {
33
+ readOnlyHint: false,
34
+ destructiveHint: false,
35
+ idempotentHint: false,
36
+ openWorldHint: false,
37
+ },
38
+ scopes: ["content:draft:own"],
39
+ handler: async (a, ctx) => {
40
+ await requireSite(ctx, a.siteId );
41
+ return {
42
+ snapshot: await ctx.store.createSnapshot(a.siteId , {
43
+ actor: ctx.principal.actor,
44
+ reason: "manual",
45
+ ...(a.label ? { label: a.label } : {}),
46
+ }),
47
+ };
48
+ },
49
+ };
50
+
51
+ const listSnapshotsTool = {
52
+ name: "list_snapshots",
53
+ title: "List snapshots",
54
+ description: "Snapshots of a site, newest first, and which one is live.",
55
+ inputSchema: obj(
56
+ { siteId: id, limit: { type: "integer", minimum: 1, maximum: 200, default: 20 } },
57
+ ["siteId"],
58
+ ),
59
+ outputSchema: obj(
60
+ {
61
+ snapshots: { type: "array", items: SNAPSHOT },
62
+ publishedSnapshotId: { type: ["string", "null"] },
63
+ },
64
+ ["snapshots", "publishedSnapshotId"],
65
+ ),
66
+ annotations: {
67
+ readOnlyHint: true,
68
+ destructiveHint: false,
69
+ idempotentHint: true,
70
+ openWorldHint: false,
71
+ },
72
+ scopes: ["site:read"],
73
+ handler: async (a, ctx) => {
74
+ const site = await requireSite(ctx, a.siteId );
75
+ return {
76
+ snapshots: await ctx.store.listSnapshots(site.id, (a.limit ) ?? 20),
77
+ publishedSnapshotId: site.publishedSnapshotId ?? null,
78
+ };
79
+ },
80
+ };
81
+
82
+ const diffSnapshotsTool = {
83
+ name: "diff_snapshots",
84
+ title: "Diff snapshots",
85
+ description: "Added, changed and removed nodes between two snapshots.",
86
+ inputSchema: obj({ siteId: id, fromSnapshotId: id, toSnapshotId: id }, [
87
+ "siteId",
88
+ "fromSnapshotId",
89
+ "toSnapshotId",
90
+ ]),
91
+ outputSchema: obj(
92
+ { added: { type: "array" }, removed: { type: "array" }, changed: { type: "array" } },
93
+ ["added", "removed", "changed"],
94
+ ),
95
+ annotations: {
96
+ readOnlyHint: true,
97
+ destructiveHint: false,
98
+ idempotentHint: true,
99
+ openWorldHint: false,
100
+ },
101
+ scopes: ["site:read"],
102
+ handler: async (a, ctx) => {
103
+ try {
104
+ for (const id of [a.fromSnapshotId , a.toSnapshotId ]) {
105
+ const snap = await ctx.store.getSnapshot(id);
106
+ if (!snap || snap.siteId !== a.siteId)
107
+ throw new ToolError("not_found", `snapshot ${id} not found`);
108
+ }
109
+ return await ctx.store.diffSnapshots(a.fromSnapshotId , a.toSnapshotId );
110
+ } catch (e) {
111
+ if (e instanceof ToolError) throw e;
112
+ mapError(e);
113
+ }
114
+ },
115
+ };
116
+
117
+ const previewPageTool = {
118
+ name: "preview_page",
119
+ title: "Preview page",
120
+ description:
121
+ "Render a page (or an article by entryId) to HTML as the site would show it, plus a preview link (signed, to the deployed site, or the `cloudhal dev` URL). To see how it looks in a browser, with screenshots and automatic checks, use render_page.",
122
+ inputSchema: obj({ siteId: id, pageId: id, entryId: id }, ["siteId"]),
123
+ outputSchema: obj(
124
+ {
125
+ title: { type: "string" },
126
+ path: { type: "string" },
127
+ html: { type: "string" },
128
+ url: { type: ["string", "null"] },
129
+ },
130
+ ["title", "path", "html", "url"],
131
+ ),
132
+ annotations: {
133
+ readOnlyHint: true,
134
+ destructiveHint: false,
135
+ idempotentHint: true,
136
+ openWorldHint: false,
137
+ },
138
+ scopes: ["preview:create"],
139
+ // RFC-0020 §6: MCP Apps hosts show the rendered page in Cloudhal's View.
140
+ ui: { resourceUri: PREVIEW_UI_URI },
141
+ handler: (a, ctx) => previewDoc(a, ctx),
142
+ };
143
+
144
+ const publishTool = {
145
+ name: "publish",
146
+ title: "Publish site",
147
+ description: "Snapshot the current state and make it live. Always asks for human approval.",
148
+ inputSchema: obj({ siteId: id, label: str("Label for the published snapshot") }, ["siteId"]),
149
+ outputSchema: obj({ site: SITE, snapshot: SNAPSHOT }, ["site", "snapshot"]),
150
+ annotations: {
151
+ readOnlyHint: false,
152
+ destructiveHint: true,
153
+ idempotentHint: false,
154
+ openWorldHint: false,
155
+ },
156
+ scopes: ["site:publish"],
157
+ own: "changeset",
158
+ requiresApproval: true,
159
+ approvalPrompt: async (a, ctx) => {
160
+ const site = await requireSite(ctx, a.siteId );
161
+ // What the person approves is what goes live: the current state against the live snapshot.
162
+ const diff = await ctx.store.pendingChanges(site.id);
163
+ const lines = [
164
+ ...(await describeChanges(ctx, diff, site)),
165
+ ...(await publishMenuLines(ctx, site)),
166
+ ];
167
+ const counts = `+${diff.added.length} added, ~${diff.changed.length} changed, −${diff.removed.length} removed`;
168
+ return `Publish "${quote(site.name)}" to production (${counts} since the live version)?${lines.length ? `\n${lines.join("\n")}` : ""}`;
169
+ },
170
+ handler: async (a, ctx) => {
171
+ const site = await requireSite(ctx, a.siteId );
172
+ const snapshot = await ctx.store.createSnapshot(site.id, {
173
+ actor: ctx.principal.actor,
174
+ reason: "publish",
175
+ ...(a.label ? { label: a.label } : {}),
176
+ });
177
+ try {
178
+ return { site: await ctx.store.publish(site, snapshot.id), snapshot };
179
+ } catch (e) {
180
+ mapError(e);
181
+ }
182
+ },
183
+ };
184
+
185
+ const rollbackTool = {
186
+ name: "rollback",
187
+ title: "Roll back site",
188
+ description:
189
+ "Point production at an earlier snapshot (must be an ancestor of the live one). Always asks for human approval.",
190
+ inputSchema: obj({ siteId: id, snapshotId: id }, ["siteId", "snapshotId"]),
191
+ outputSchema: obj({ site: SITE, snapshot: SNAPSHOT }, ["site", "snapshot"]),
192
+ annotations: {
193
+ readOnlyHint: false,
194
+ destructiveHint: true,
195
+ idempotentHint: false,
196
+ openWorldHint: false,
197
+ },
198
+ scopes: ["site:rollback"],
199
+ requiresApproval: true,
200
+ approvalPrompt: async (a, ctx) => {
201
+ const snap = await ctx.store.getSnapshot(a.snapshotId );
202
+ return `Roll back to snapshot "${snap?.label ?? a.snapshotId}" (${snap?.createdAt ?? "unknown time"})? The current live version stays in history.`;
203
+ },
204
+ handler: async (a, ctx) => {
205
+ const site = await requireSite(ctx, a.siteId );
206
+ try {
207
+ return await ctx.store.rollback(site, a.snapshotId , ctx.principal.actor);
208
+ } catch (e) {
209
+ if (e instanceof Error && /not an ancestor|no published snapshot/.test(e.message))
210
+ throw new ToolError("invalid", e.message);
211
+ mapError(e);
212
+ }
213
+ },
214
+ };
215
+
216
+ /** create_snapshot … rollback, in `tools/list` order. */
217
+ export function publishTools() {
218
+ return [
219
+ createSnapshotTool,
220
+ listSnapshotsTool,
221
+ diffSnapshotsTool,
222
+ previewPageTool,
223
+ publishTool,
224
+ rollbackTool,
225
+ ];
226
+ }
227
+
228
+ /**
229
+ * Is publishing the current state own-only for the caller (RFC-0005 §4, I10)? Every publicly
230
+ * visible pending change must be an entry the caller's person owns.
231
+ */
232
+ export async function ownOnlyPublish(ctx , siteId ) {
233
+ const store = ctx.store;
234
+ return ownOnlyChangeSet(
235
+ await store.pendingChanges(siteId),
236
+ callerPerson(ctx),
237
+ ctx.ownership ?? ACTOR_OWNERSHIP,
238
+ {
239
+ before: (ref) => store.readSnapshotNode(ref).catch(() => undefined),
240
+ after: async (ref) =>
241
+ ref.kind === "contentEntry"
242
+ ? store.getContentEntry(ref.id)
243
+ : ref.kind === "site"
244
+ ? store.getSite(ref.id)
245
+ : undefined,
246
+ },
247
+ );
248
+ }
@@ -0,0 +1,29 @@
1
+ /** A minimal DevTools client over a browser-level socket (flat sessions), and base64 helpers. */
2
+ import { type CdpSocket, type RenderSession } from "./types.js";
3
+ /**
4
+ * A minimal DevTools client over a browser-level socket (flat sessions). Enough for
5
+ * `render_page`: a browser context per session, one page per width, evaluate and screenshot.
6
+ */
7
+ export declare class Cdp {
8
+ #private;
9
+ constructor(socket: CdpSocket);
10
+ send<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<T>;
11
+ /** Resolves on the next event `method` of `sessionId` (or after `ms`, whichever comes first). */
12
+ waitFor(method: string, sessionId: string, ms: number): Promise<void>;
13
+ close(): void;
14
+ }
15
+ export interface CdpEvent {
16
+ method?: string;
17
+ sessionId?: string;
18
+ params?: Record<string, unknown>;
19
+ }
20
+ /**
21
+ * A render session over a DevTools socket: an isolated browser context (no cookies shared
22
+ * between calls). `closeBrowser` also closes the browser (Browser Rendering sessions).
23
+ */
24
+ export declare function cdpSession(socket: CdpSocket, options?: {
25
+ closeBrowser?: boolean;
26
+ loadTimeoutMs?: number;
27
+ }): Promise<RenderSession>;
28
+ export declare function base64ToBytes(b64: string): Uint8Array;
29
+ export declare function bytesToBase64(bytes: Uint8Array): string;
@@ -0,0 +1,190 @@
1
+ /** A minimal DevTools client over a browser-level socket (flat sessions), and base64 helpers. */
2
+ import { RenderError, } from "./types.js";
3
+
4
+ /**
5
+ * A minimal DevTools client over a browser-level socket (flat sessions). Enough for
6
+ * `render_page`: a browser context per session, one page per width, evaluate and screenshot.
7
+ */
8
+ export class Cdp {
9
+ #id = 0;
10
+ #pending = new Map
11
+
12
+
13
+ ();
14
+ #listeners = new Set ();
15
+ #socket ;
16
+ #closed = false;
17
+ constructor(socket ) {
18
+ this.#socket = socket;
19
+ socket.onMessage((raw) => {
20
+ let m ;
21
+ try {
22
+ m = JSON.parse(raw);
23
+ } catch {
24
+ return;
25
+ }
26
+ if (m.id !== undefined) {
27
+ const p = this.#pending.get(m.id);
28
+ if (!p) return;
29
+ this.#pending.delete(m.id);
30
+ if (m.error) p.reject(new RenderError(m.error.message ?? "DevTools error"));
31
+ else p.resolve(m.result);
32
+ } else if (m.method) {
33
+ for (const l of this.#listeners) l(m);
34
+ }
35
+ });
36
+ socket.onClose(() => {
37
+ this.#closed = true;
38
+ for (const p of this.#pending.values()) p.reject(new RenderError("the browser went away"));
39
+ this.#pending.clear();
40
+ });
41
+ }
42
+ send (
43
+ method ,
44
+ params = {},
45
+ sessionId ,
46
+ ) {
47
+ if (this.#closed) return Promise.reject(new RenderError("the browser went away"));
48
+ return new Promise ((resolve, reject) => {
49
+ const id = ++this.#id;
50
+ this.#pending.set(id, { resolve: resolve , reject });
51
+ this.#socket.send(
52
+ JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }),
53
+ );
54
+ });
55
+ }
56
+ /** Resolves on the next event `method` of `sessionId` (or after `ms`, whichever comes first). */
57
+ waitFor(method , sessionId , ms ) {
58
+ return new Promise((resolve) => {
59
+ const done = () => {
60
+ clearTimeout(timer);
61
+ this.#listeners.delete(listener);
62
+ resolve();
63
+ };
64
+ const listener = (m ) => {
65
+ if (m.method === method && m.sessionId === sessionId) done();
66
+ };
67
+ const timer = setTimeout(done, ms);
68
+ this.#listeners.add(listener);
69
+ });
70
+ }
71
+ close() {
72
+ if (!this.#closed) this.#socket.close();
73
+ this.#closed = true;
74
+ }
75
+ }
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+ /**
84
+ * A render session over a DevTools socket: an isolated browser context (no cookies shared
85
+ * between calls). `closeBrowser` also closes the browser (Browser Rendering sessions).
86
+ */
87
+ export async function cdpSession(
88
+ socket ,
89
+ options = {},
90
+ ) {
91
+ const cdp = new Cdp(socket);
92
+ const { browserContextId } = await cdp.send (
93
+ "Target.createBrowserContext",
94
+ { disposeOnDetach: true },
95
+ );
96
+ return {
97
+ async open(url, viewport) {
98
+ const { targetId } = await cdp.send ("Target.createTarget", {
99
+ url: "about:blank",
100
+ browserContextId,
101
+ });
102
+ const { sessionId } = await cdp.send ("Target.attachToTarget", {
103
+ targetId,
104
+ flatten: true,
105
+ });
106
+ const s = (method , params = {}) =>
107
+ cdp.send(method, params, sessionId);
108
+ await s("Page.enable");
109
+ await s("Emulation.setDeviceMetricsOverride", {
110
+ width: viewport.width,
111
+ height: viewport.height,
112
+ deviceScaleFactor: 1,
113
+ mobile: viewport.width < 768,
114
+ });
115
+ if (viewport.width < 768) await s("Emulation.setTouchEmulationEnabled", { enabled: true });
116
+ // The same render on every machine: the scheme asked for, whatever the host prefers.
117
+ await s("Emulation.setEmulatedMedia", {
118
+ features: [
119
+ { name: "prefers-color-scheme", value: viewport.scheme ?? "light" },
120
+ {
121
+ name: "prefers-reduced-motion",
122
+ value: viewport.reducedMotion === false ? "no-preference" : "reduce",
123
+ },
124
+ ],
125
+ });
126
+ const loaded = cdp.waitFor("Page.loadEventFired", sessionId, options.loadTimeoutMs ?? 12_000);
127
+ const nav = await s("Page.navigate", { url });
128
+ if (typeof nav.errorText === "string" && nav.errorText)
129
+ throw new RenderError(`could not open the page: ${nav.errorText}`);
130
+ await loaded;
131
+ // Phones zoom out to fit a page that is too wide; stay at 100 % so the screenshot shows
132
+ // what overflows (the overflow-x check names it).
133
+ if (viewport.width < 768) await s("Emulation.setPageScaleFactor", { pageScaleFactor: 1 });
134
+ return {
135
+ async evaluate (expression ) {
136
+ const r = await cdp.send
137
+
138
+
139
+ (
140
+ "Runtime.evaluate",
141
+ { expression, awaitPromise: true, returnByValue: true },
142
+ sessionId,
143
+ );
144
+ if (r.exceptionDetails)
145
+ throw new RenderError(
146
+ r.exceptionDetails.exception?.description ??
147
+ r.exceptionDetails.text ??
148
+ "script failed",
149
+ );
150
+ return r.result?.value ;
151
+ },
152
+ async screenshot(quality) {
153
+ const r = await cdp.send (
154
+ "Page.captureScreenshot",
155
+ { format: "jpeg", quality },
156
+ sessionId,
157
+ );
158
+ return base64ToBytes(r.data);
159
+ },
160
+ async close() {
161
+ await cdp.send("Target.closeTarget", { targetId });
162
+ },
163
+ };
164
+ },
165
+ async close() {
166
+ try {
167
+ await cdp.send("Target.disposeBrowserContext", { browserContextId });
168
+ if (options.closeBrowser) await cdp.send("Browser.close");
169
+ } catch {
170
+ // already gone
171
+ } finally {
172
+ cdp.close();
173
+ }
174
+ },
175
+ };
176
+ }
177
+
178
+ export function base64ToBytes(b64 ) {
179
+ const bin = atob(b64);
180
+ const out = new Uint8Array(bin.length);
181
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
182
+ return out;
183
+ }
184
+
185
+ export function bytesToBase64(bytes ) {
186
+ let bin = "";
187
+ for (let i = 0; i < bytes.length; i += 0x8000)
188
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
189
+ return btoa(bin);
190
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: carousel-label / -lcp / -focus / -autoplay, image-fit, overlay-open; ends at the top of the page.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const COMPONENT_CHECKS = " // A static preview runs no scripts (its CSP), so checks of what a module does wait for the site.\n const staticPreview = !!document.querySelector('meta[name=\"generator\"][content=\"Cloudhal preview\"]');\n // carousel-label and carousel-lcp: named carousels and slides; a hero's first slide not lazy.\n for (const c of document.querySelectorAll('[aria-roledescription=\"carousel\"], [aria-roledescription=\"kayd\u0131rmal\u0131 alan\"]')) {\n const name = (c.getAttribute(\"aria-label\") || \"\").trim();\n if (!name) add(\"carousel-label\", c, \"a carousel without a name (aria-label)\");\n else if (/carousel|karusel/i.test(name)) add(\"carousel-label\", c, \"its name says carousel; name what it shows\");\n const slides = [...c.querySelectorAll('[aria-roledescription=\"slide\"], [aria-roledescription=\"slayt\"]')];\n for (const s of slides) if (!(s.getAttribute(\"aria-label\") || \"\").trim()) add(\"carousel-label\", s, \"a slide without a name\");\n if (!staticPreview && c.querySelector('[data-ch-carousel=\"prev\"][data-ch-pending],[data-ch-carousel=\"next\"][data-ch-pending]'))\n add(\"carousel-focus\", c, \"its arrows never appeared: the carousel module did not run\");\n if (!staticPreview && c.getAttribute(\"data-variant\") === \"hero\")\n for (const s of slides.slice(1)) {\n if (s.closest(\"[inert]\")) continue;\n const f = s.querySelector('a[href],button,input,select,textarea,[tabindex]:not([tabindex=\"-1\"])');\n if (f) add(\"carousel-focus\", f, \"Tab can land in a slide out of view (make off-screen slides inert)\");\n }\n if (c.hasAttribute(\"data-autoplay\")) {\n const first = c.querySelector('a[href],button,input,select,textarea,[tabindex]:not([tabindex=\"-1\"])');\n if (!first || first.getAttribute(\"data-ch-carousel\") !== \"rotate\")\n add(\"carousel-autoplay\", c, \"it plays on its own without a rotation control first in Tab order\");\n else if (!(first.getAttribute(\"aria-label\") || \"\").trim())\n add(\"carousel-autoplay\", first, \"its rotation control has no name\");\n }\n if (c.getAttribute(\"data-variant\") === \"hero\")\n slides.forEach((s, i) => {\n for (const img of s.querySelectorAll(\"img\")) {\n const lazy = wasLazy.has(img);\n if (i === 0 && lazy) add(\"carousel-lcp\", img, \"the first hero slide's image is lazy; it is what the reader sees first\");\n if (i > 0 && !lazy) add(\"carousel-lcp\", img, \"slide \" + (i + 1) + \"'s image loads before anyone sees it; make it lazy\");\n }\n });\n }\n\n // image-fit: cover crops that cut away much of a picture without a focal point (object-position).\n for (const img of document.querySelectorAll(\"[data-section-id] img\")) {\n const box = img.getBoundingClientRect();\n if (!img.naturalWidth || !img.naturalHeight || box.width < 80 || box.height < 80) continue;\n const cs = getComputedStyle(img);\n if (cs.objectFit !== \"cover\" || /object-position/.test(img.getAttribute(\"style\") || \"\")) continue;\n const own = img.naturalWidth / img.naturalHeight;\n const frame = box.width / box.height;\n const kept = Math.min(own, frame) / Math.max(own, frame);\n if (kept < 0.6)\n add(\"image-fit\", img, \"the frame keeps \" + Math.round(kept * 100) + \"% of the picture with no focal point; set one (update_asset focal) or use fit blur-fill\");\n }\n\n const playing = document.querySelectorAll(\"[data-autoplay]\");\n if (playing.length > 1) add(\"carousel-autoplay\", playing[1], \"a second carousel plays on its own; one per page at most\");\n\n // overlay-open: every overlay of the page's sections, opened the way a reader would.\n const isOpen = (t) => !!t.open || t.matches(\":popover-open\");\n const overlaysOf = [...document.querySelectorAll(\"[data-section-id] dialog.ch-dialog, [data-section-id] [popover].ch-popover\")];\n for (const t of overlaysOf) {\n const sel = '[commandfor=\"' + t.id + '\"]:not([command=\"close\"]),[popovertarget=\"' + t.id + '\"]:not([popovertargetaction=\"hide\"]),[data-ch-open=\"' + t.id + '\"],[data-ch-lightbox=\"' + t.id + '\"]';\n const trigger = [...document.querySelectorAll(sel)].find(shown);\n if (!trigger) { add(\"overlay-open\", t, \"nothing on the page opens it\"); continue; }\n trigger.scrollIntoView({ block: \"center\" });\n trigger.focus();\n trigger.click();\n await frame();\n if (!isOpen(t)) { add(\"overlay-open\", trigger, \"did not open its overlay\"); continue; }\n const modal = t.tagName === \"DIALOG\";\n if (modal && !t.contains(document.activeElement)) add(\"overlay-open\", t, \"focus stayed outside when it opened\");\n if (modal && t.getAttribute(\"closedby\") === \"none\" && !t.querySelector('[command=\"close\"]'))\n add(\"overlay-open\", t, \"it closes only by a button, and has none\");\n // Its text against its own paint (an overlay is opaque).\n const tw = document.createTreeWalker(t, NodeFilter.SHOW_TEXT);\n const doneT = new Set();\n while (tw.nextNode()) {\n const el = tw.currentNode.parentElement;\n if (!el || doneT.has(el) || !tw.currentNode.nodeValue.trim() || !visible(el)) continue;\n doneT.add(el);\n // The first opaque fill between the text and the overlay's edge.\n let bg = null;\n for (let e = el; e && !bg; e = e === t ? null : e.parentElement) {\n const c = rgba(getComputedStyle(e).backgroundColor);\n if (c[3] >= 1) bg = c.slice(0, 3);\n }\n if (!bg) continue;\n const st = getComputedStyle(el);\n const got = ratio(over(rgba(st.color), bg), bg);\n const big = parseFloat(st.fontSize) >= 24 || (parseFloat(st.fontSize) >= 18.66 && Number(st.fontWeight) >= 700);\n if (got < (big ? 3 : 4.5)) add(\"overlay-open\", el, got.toFixed(1) + \":1 inside the overlay, needs \" + (big ? 3 : 4.5) + \":1\");\n }\n if (modal) t.close(); else t.hidePopover();\n await frame();\n if (document.activeElement !== trigger) add(\"overlay-open\", trigger, \"focus did not come back when it closed\");\n }\n window.scrollTo(0, 0);\n";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: carousel-label / -lcp / -focus / -autoplay, image-fit, overlay-open; ends at the top of the page.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const COMPONENT_CHECKS = ` // A static preview runs no scripts (its CSP), so checks of what a module does wait for the site.
6
+ const staticPreview = !!document.querySelector('meta[name="generator"][content="Cloudhal preview"]');
7
+ // carousel-label and carousel-lcp: named carousels and slides; a hero's first slide not lazy.
8
+ for (const c of document.querySelectorAll('[aria-roledescription="carousel"], [aria-roledescription="kaydırmalı alan"]')) {
9
+ const name = (c.getAttribute("aria-label") || "").trim();
10
+ if (!name) add("carousel-label", c, "a carousel without a name (aria-label)");
11
+ else if (/carousel|karusel/i.test(name)) add("carousel-label", c, "its name says carousel; name what it shows");
12
+ const slides = [...c.querySelectorAll('[aria-roledescription="slide"], [aria-roledescription="slayt"]')];
13
+ for (const s of slides) if (!(s.getAttribute("aria-label") || "").trim()) add("carousel-label", s, "a slide without a name");
14
+ if (!staticPreview && c.querySelector('[data-ch-carousel="prev"][data-ch-pending],[data-ch-carousel="next"][data-ch-pending]'))
15
+ add("carousel-focus", c, "its arrows never appeared: the carousel module did not run");
16
+ if (!staticPreview && c.getAttribute("data-variant") === "hero")
17
+ for (const s of slides.slice(1)) {
18
+ if (s.closest("[inert]")) continue;
19
+ const f = s.querySelector('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])');
20
+ if (f) add("carousel-focus", f, "Tab can land in a slide out of view (make off-screen slides inert)");
21
+ }
22
+ if (c.hasAttribute("data-autoplay")) {
23
+ const first = c.querySelector('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])');
24
+ if (!first || first.getAttribute("data-ch-carousel") !== "rotate")
25
+ add("carousel-autoplay", c, "it plays on its own without a rotation control first in Tab order");
26
+ else if (!(first.getAttribute("aria-label") || "").trim())
27
+ add("carousel-autoplay", first, "its rotation control has no name");
28
+ }
29
+ if (c.getAttribute("data-variant") === "hero")
30
+ slides.forEach((s, i) => {
31
+ for (const img of s.querySelectorAll("img")) {
32
+ const lazy = wasLazy.has(img);
33
+ if (i === 0 && lazy) add("carousel-lcp", img, "the first hero slide's image is lazy; it is what the reader sees first");
34
+ if (i > 0 && !lazy) add("carousel-lcp", img, "slide " + (i + 1) + "'s image loads before anyone sees it; make it lazy");
35
+ }
36
+ });
37
+ }
38
+
39
+ // image-fit: cover crops that cut away much of a picture without a focal point (object-position).
40
+ for (const img of document.querySelectorAll("[data-section-id] img")) {
41
+ const box = img.getBoundingClientRect();
42
+ if (!img.naturalWidth || !img.naturalHeight || box.width < 80 || box.height < 80) continue;
43
+ const cs = getComputedStyle(img);
44
+ if (cs.objectFit !== "cover" || /object-position/.test(img.getAttribute("style") || "")) continue;
45
+ const own = img.naturalWidth / img.naturalHeight;
46
+ const frame = box.width / box.height;
47
+ const kept = Math.min(own, frame) / Math.max(own, frame);
48
+ if (kept < 0.6)
49
+ add("image-fit", img, "the frame keeps " + Math.round(kept * 100) + "% of the picture with no focal point; set one (update_asset focal) or use fit blur-fill");
50
+ }
51
+
52
+ const playing = document.querySelectorAll("[data-autoplay]");
53
+ if (playing.length > 1) add("carousel-autoplay", playing[1], "a second carousel plays on its own; one per page at most");
54
+
55
+ // overlay-open: every overlay of the page's sections, opened the way a reader would.
56
+ const isOpen = (t) => !!t.open || t.matches(":popover-open");
57
+ const overlaysOf = [...document.querySelectorAll("[data-section-id] dialog.ch-dialog, [data-section-id] [popover].ch-popover")];
58
+ for (const t of overlaysOf) {
59
+ const sel = '[commandfor="' + t.id + '"]:not([command="close"]),[popovertarget="' + t.id + '"]:not([popovertargetaction="hide"]),[data-ch-open="' + t.id + '"],[data-ch-lightbox="' + t.id + '"]';
60
+ const trigger = [...document.querySelectorAll(sel)].find(shown);
61
+ if (!trigger) { add("overlay-open", t, "nothing on the page opens it"); continue; }
62
+ trigger.scrollIntoView({ block: "center" });
63
+ trigger.focus();
64
+ trigger.click();
65
+ await frame();
66
+ if (!isOpen(t)) { add("overlay-open", trigger, "did not open its overlay"); continue; }
67
+ const modal = t.tagName === "DIALOG";
68
+ if (modal && !t.contains(document.activeElement)) add("overlay-open", t, "focus stayed outside when it opened");
69
+ if (modal && t.getAttribute("closedby") === "none" && !t.querySelector('[command="close"]'))
70
+ add("overlay-open", t, "it closes only by a button, and has none");
71
+ // Its text against its own paint (an overlay is opaque).
72
+ const tw = document.createTreeWalker(t, NodeFilter.SHOW_TEXT);
73
+ const doneT = new Set();
74
+ while (tw.nextNode()) {
75
+ const el = tw.currentNode.parentElement;
76
+ if (!el || doneT.has(el) || !tw.currentNode.nodeValue.trim() || !visible(el)) continue;
77
+ doneT.add(el);
78
+ // The first opaque fill between the text and the overlay's edge.
79
+ let bg = null;
80
+ for (let e = el; e && !bg; e = e === t ? null : e.parentElement) {
81
+ const c = rgba(getComputedStyle(e).backgroundColor);
82
+ if (c[3] >= 1) bg = c.slice(0, 3);
83
+ }
84
+ if (!bg) continue;
85
+ const st = getComputedStyle(el);
86
+ const got = ratio(over(rgba(st.color), bg), bg);
87
+ const big = parseFloat(st.fontSize) >= 24 || (parseFloat(st.fontSize) >= 18.66 && Number(st.fontWeight) >= 700);
88
+ if (got < (big ? 3 : 4.5)) add("overlay-open", el, got.toFixed(1) + ":1 inside the overlay, needs " + (big ? 3 : 4.5) + ":1");
89
+ }
90
+ if (modal) t.close(); else t.hidePopover();
91
+ await frame();
92
+ if (document.activeElement !== trigger) add("overlay-open", trigger, "focus did not come back when it closed");
93
+ }
94
+ window.scrollTo(0, 0);
95
+ `;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: empty-section, tap-target, input-label, chart-empty, long-line.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export declare const CONTENT_CHECKS = " // empty-section\n for (const s of document.querySelectorAll(\"[data-section-id]\")) {\n if (getComputedStyle(s).display === \"none\") continue;\n const text = (s.innerText || \"\").trim();\n const media = s.querySelector(\"img,video,iframe,svg,canvas,input,select,textarea,picture\");\n const r = s.getBoundingClientRect();\n if (!text && !media) add(\"empty-section\", s, \"no text or media\");\n else if (r.height < 8) add(\"empty-section\", s, Math.round(r.height) + \" px tall\");\n }\n\n // tap-target: on small screens, controls under 24 \u00D7 24 px that sit too close to another one\n // (WCAG 2.5.8: a 24 px circle on each must not touch another target; links inside sentences are exempt).\n if (W < 768) {\n const targets = [...document.querySelectorAll(\"a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button]\")]\n .filter(visible)\n .map((el) => ({ el, r: el.getBoundingClientRect() }))\n // screen-reader-only controls (1 \u00D7 1 px) are not tapped\n .filter((t) => t.r.width > 2 || t.r.height > 2);\n const small = (t) => t.r.width < 24 || t.r.height < 24;\n const centre = (r) => [r.left + r.width / 2, r.top + r.height / 2];\n const toRect = (p, r) => Math.hypot(Math.max(r.left - p[0], 0, p[0] - r.right), Math.max(r.top - p[1], 0, p[1] - r.bottom));\n for (const t of targets) {\n if (!small(t)) continue;\n if (t.el.tagName === \"A\" && getComputedStyle(t.el).display === \"inline\") {\n const block = t.el.closest(\"p,li,td,dd,blockquote,figcaption\");\n if (block && (block.innerText || \"\").trim().length > (t.el.innerText || \"\").trim().length + 20) continue;\n }\n const c = centre(t.r);\n const near = targets.find((o) => o !== t && !o.el.contains(t.el) && !t.el.contains(o.el) &&\n (toRect(c, o.r) < 12 || (small(o) && Math.hypot(c[0] - centre(o.r)[0], c[1] - centre(o.r)[1]) < 24)));\n if (near) add(\"tap-target\", t.el, Math.round(t.r.width) + \" \u00D7 \" + Math.round(t.r.height) + \" px, too close to \" + JSON.stringify(clip((near.el.innerText || near.el.getAttribute(\"aria-label\") || near.el.tagName.toLowerCase()).trim(), 24)));\n }\n }\n\n // input-label: form controls without an accessible name (a label, aria-label or aria-labelledby).\n for (const el of document.querySelectorAll(\"input:not([type=hidden]):not([type=submit]):not([type=button]),select,textarea\")) {\n if (!visible(el) && !(el.type === \"radio\" || el.type === \"checkbox\")) continue;\n const named = [...(el.labels || [])].some((l) => (l.innerText || l.textContent || \"\").trim()) ||\n (el.getAttribute(\"aria-label\") || \"\").trim() ||\n (el.getAttribute(\"aria-labelledby\") || \"\").split(/s+/).some((id) => id && (document.getElementById(id)?.textContent || \"\").trim());\n if (!named) add(\"input-label\", el, \"no label a screen reader can announce\");\n }\n\n // chart-empty: a chart whose data has no rows.\n for (const el of document.querySelectorAll(\"[data-ch-chart-empty]\")) add(\"chart-empty\", el, \"no rows in its data\");\n\n // long-line: paragraphs averaging more than 90 characters a line.\n for (const p of document.querySelectorAll(\"p,li,dd,blockquote\")) {\n if (!visible(p) || p.querySelector(\"p,li\")) continue;\n const text = (p.innerText || \"\").trim();\n if (text.length < 180) continue;\n const s = getComputedStyle(p);\n const lh = s.lineHeight === \"normal\" ? parseFloat(s.fontSize) * 1.2 : parseFloat(s.lineHeight);\n const lines = Math.round(p.getBoundingClientRect().height / lh);\n if (lines < 3) continue;\n const per = Math.round(text.length / lines);\n if (per > 90) add(\"long-line\", p, \"about \" + per + \" characters a line\");\n }\n";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * In-page checks, part of `RENDER_CHECK_SCRIPT`: empty-section, tap-target, input-label, chart-empty, long-line.
3
+ * Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
4
+ */
5
+ export const CONTENT_CHECKS = ` // empty-section
6
+ for (const s of document.querySelectorAll("[data-section-id]")) {
7
+ if (getComputedStyle(s).display === "none") continue;
8
+ const text = (s.innerText || "").trim();
9
+ const media = s.querySelector("img,video,iframe,svg,canvas,input,select,textarea,picture");
10
+ const r = s.getBoundingClientRect();
11
+ if (!text && !media) add("empty-section", s, "no text or media");
12
+ else if (r.height < 8) add("empty-section", s, Math.round(r.height) + " px tall");
13
+ }
14
+
15
+ // tap-target: on small screens, controls under 24 × 24 px that sit too close to another one
16
+ // (WCAG 2.5.8: a 24 px circle on each must not touch another target; links inside sentences are exempt).
17
+ if (W < 768) {
18
+ const targets = [...document.querySelectorAll("a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button]")]
19
+ .filter(visible)
20
+ .map((el) => ({ el, r: el.getBoundingClientRect() }))
21
+ // screen-reader-only controls (1 × 1 px) are not tapped
22
+ .filter((t) => t.r.width > 2 || t.r.height > 2);
23
+ const small = (t) => t.r.width < 24 || t.r.height < 24;
24
+ const centre = (r) => [r.left + r.width / 2, r.top + r.height / 2];
25
+ const toRect = (p, r) => Math.hypot(Math.max(r.left - p[0], 0, p[0] - r.right), Math.max(r.top - p[1], 0, p[1] - r.bottom));
26
+ for (const t of targets) {
27
+ if (!small(t)) continue;
28
+ if (t.el.tagName === "A" && getComputedStyle(t.el).display === "inline") {
29
+ const block = t.el.closest("p,li,td,dd,blockquote,figcaption");
30
+ if (block && (block.innerText || "").trim().length > (t.el.innerText || "").trim().length + 20) continue;
31
+ }
32
+ const c = centre(t.r);
33
+ const near = targets.find((o) => o !== t && !o.el.contains(t.el) && !t.el.contains(o.el) &&
34
+ (toRect(c, o.r) < 12 || (small(o) && Math.hypot(c[0] - centre(o.r)[0], c[1] - centre(o.r)[1]) < 24)));
35
+ if (near) add("tap-target", t.el, Math.round(t.r.width) + " × " + Math.round(t.r.height) + " px, too close to " + JSON.stringify(clip((near.el.innerText || near.el.getAttribute("aria-label") || near.el.tagName.toLowerCase()).trim(), 24)));
36
+ }
37
+ }
38
+
39
+ // input-label: form controls without an accessible name (a label, aria-label or aria-labelledby).
40
+ for (const el of document.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]),select,textarea")) {
41
+ if (!visible(el) && !(el.type === "radio" || el.type === "checkbox")) continue;
42
+ const named = [...(el.labels || [])].some((l) => (l.innerText || l.textContent || "").trim()) ||
43
+ (el.getAttribute("aria-label") || "").trim() ||
44
+ (el.getAttribute("aria-labelledby") || "").split(/s+/).some((id) => id && (document.getElementById(id)?.textContent || "").trim());
45
+ if (!named) add("input-label", el, "no label a screen reader can announce");
46
+ }
47
+
48
+ // chart-empty: a chart whose data has no rows.
49
+ for (const el of document.querySelectorAll("[data-ch-chart-empty]")) add("chart-empty", el, "no rows in its data");
50
+
51
+ // long-line: paragraphs averaging more than 90 characters a line.
52
+ for (const p of document.querySelectorAll("p,li,dd,blockquote")) {
53
+ if (!visible(p) || p.querySelector("p,li")) continue;
54
+ const text = (p.innerText || "").trim();
55
+ if (text.length < 180) continue;
56
+ const s = getComputedStyle(p);
57
+ const lh = s.lineHeight === "normal" ? parseFloat(s.fontSize) * 1.2 : parseFloat(s.lineHeight);
58
+ const lines = Math.round(p.getBoundingClientRect().height / lh);
59
+ if (lines < 3) continue;
60
+ const per = Math.round(text.length / lines);
61
+ if (per > 90) add("long-line", p, "about " + per + " characters a line");
62
+ }
63
+ `;