@goodfellow-cms/mcp 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/index.d.ts +68 -0
- package/dist/index.js +813 -0
- package/dist/screenshots--uulXZYY.js +240 -0
- package/dist/screenshots.d.ts +29 -0
- package/dist/screenshots.js +2 -0
- package/dist/stdio.d.ts +1 -0
- package/dist/stdio.js +690 -0
- package/package.json +66 -3
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { COLLECTIONS_DIR, COLLECTION_SETTINGS_FILE, FOOTER_FILE, HEADER_FILE, MEDIA_DIR, MEDIA_FILE, MENUS_FILE, PAGES_DIR, SITE_FILE, collectionFileSchema, entryAddress, isAddressSegment, layoutFileSchema, mediaFileSchema, menusFileSchema, normalizePagePath, pageFileSchema, pageFileToPath, pagePathToFile, parseContentFile, parseEntryFile, readTextFiles, siteSettingsSchema } from "@goodfellow-cms/core";
|
|
2
|
+
//#region src/content.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reading a site's content a file at a time. Tools read only the files they
|
|
5
|
+
* need, never the whole site, so a call stays within what a free hosting plan
|
|
6
|
+
* allows however big the site is (the hosted connector's Worker gets 50
|
|
7
|
+
* requests and 10 ms of processing per call).
|
|
8
|
+
*/
|
|
9
|
+
/** Thrown with a message Claude can pass on to the editor as it is. */
|
|
10
|
+
var ToolError = class extends Error {
|
|
11
|
+
name = "ToolError";
|
|
12
|
+
};
|
|
13
|
+
function parsed(result) {
|
|
14
|
+
if (!result.ok) throw new ToolError(`${result.problem.file} ${result.problem.message} Fix it in the admin panel, or ask the site's developer.`);
|
|
15
|
+
return result.value;
|
|
16
|
+
}
|
|
17
|
+
async function readSettings(source) {
|
|
18
|
+
const text = await source.read(SITE_FILE);
|
|
19
|
+
return text === void 0 ? siteSettingsSchema.parse({ version: 1 }) : parsed(parseContentFile("site", siteSettingsSchema, SITE_FILE, text));
|
|
20
|
+
}
|
|
21
|
+
async function readMenus(source) {
|
|
22
|
+
const text = await source.read(MENUS_FILE);
|
|
23
|
+
return text === void 0 ? {} : parsed(parseContentFile("menus", menusFileSchema, MENUS_FILE, text)).menus;
|
|
24
|
+
}
|
|
25
|
+
async function readLayout(source, part) {
|
|
26
|
+
const file = part === "header" ? HEADER_FILE : FOOTER_FILE;
|
|
27
|
+
const text = await source.read(file);
|
|
28
|
+
return text === void 0 ? {
|
|
29
|
+
version: 1,
|
|
30
|
+
data: {
|
|
31
|
+
root: {},
|
|
32
|
+
content: []
|
|
33
|
+
}
|
|
34
|
+
} : parsed(parseContentFile("layout", layoutFileSchema, file, text));
|
|
35
|
+
}
|
|
36
|
+
/** A page's address written the site's one way, or a `ToolError` saying why it can't be one. */
|
|
37
|
+
function pageAddress(input) {
|
|
38
|
+
try {
|
|
39
|
+
return normalizePagePath(input.trim().startsWith("/") ? input.trim() : `/${input.trim()}`);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new ToolError(`"${input}" can't be a page's address. Addresses are like /about or /news/open-day: lowercase letters, numbers and hyphens.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The site's pages' addresses and files, from the list of files alone. */
|
|
45
|
+
async function listPages(source) {
|
|
46
|
+
return (await source.list(PAGES_DIR)).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
47
|
+
try {
|
|
48
|
+
return [{
|
|
49
|
+
address: pageFileToPath(file),
|
|
50
|
+
file
|
|
51
|
+
}];
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}).sort((a, b) => a.address.localeCompare(b.address));
|
|
56
|
+
}
|
|
57
|
+
async function readPage(source, address) {
|
|
58
|
+
const file = pagePathToFile(address);
|
|
59
|
+
const text = await source.read(file);
|
|
60
|
+
return text === void 0 ? void 0 : {
|
|
61
|
+
file,
|
|
62
|
+
content: parsed(parseContentFile("page", pageFileSchema, file, text))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** The site's collections and their entries' files, from the list of files alone. */
|
|
66
|
+
async function listCollections(source) {
|
|
67
|
+
const folders = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const file of await source.list(COLLECTIONS_DIR)) {
|
|
69
|
+
const [id, name, ...rest] = file.slice(COLLECTIONS_DIR.length + 1).split("/");
|
|
70
|
+
if (!id || !name || rest.length > 0 || !isAddressSegment(id)) continue;
|
|
71
|
+
const folder = folders.get(id) ?? {
|
|
72
|
+
id,
|
|
73
|
+
file: `${COLLECTIONS_DIR}/${id}/${COLLECTION_SETTINGS_FILE}`,
|
|
74
|
+
entries: []
|
|
75
|
+
};
|
|
76
|
+
folders.set(id, folder);
|
|
77
|
+
if (name === COLLECTION_SETTINGS_FILE) continue;
|
|
78
|
+
const match = /^(.+)\.(json|md)$/.exec(name);
|
|
79
|
+
if (match?.[1] && isAddressSegment(match[1])) folder.entries.push({
|
|
80
|
+
slug: match[1],
|
|
81
|
+
file
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return [...folders.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
85
|
+
}
|
|
86
|
+
/** A collection's settings and its entries' files. */
|
|
87
|
+
async function readCollection(source, id) {
|
|
88
|
+
const collection = (await listCollections(source)).find((candidate) => candidate.id === id);
|
|
89
|
+
const text = collection && await source.read(collection.file);
|
|
90
|
+
if (!collection || text === void 0) throw new ToolError(`The site has no collection "${id}". list_collections lists the ones it has.`);
|
|
91
|
+
return {
|
|
92
|
+
...collection,
|
|
93
|
+
settings: parsed(parseContentFile("collection", collectionFileSchema, collection.file, text))
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function readEntry(source, collection, slug) {
|
|
97
|
+
const found = collection.entries.find((entry) => entry.slug === slug);
|
|
98
|
+
const text = found && await source.read(found.file);
|
|
99
|
+
if (!found || text === void 0) return void 0;
|
|
100
|
+
return {
|
|
101
|
+
file: found.file,
|
|
102
|
+
content: parsed(parseEntryFile(collection.settings, found.file, text))
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every address the site's pages and entries have, so a new page or entry
|
|
107
|
+
* never takes one. Entries' addresses come from their collections' settings.
|
|
108
|
+
*/
|
|
109
|
+
async function takenAddresses(source) {
|
|
110
|
+
const addresses = new Set((await listPages(source)).map((page) => page.address));
|
|
111
|
+
for (const collection of await listCollections(source)) {
|
|
112
|
+
const text = await source.read(collection.file);
|
|
113
|
+
if (text === void 0) continue;
|
|
114
|
+
const settings = parseContentFile("collection", collectionFileSchema, collection.file, text);
|
|
115
|
+
if (!settings.ok || settings.value.path === void 0) continue;
|
|
116
|
+
for (const entry of collection.entries) addresses.add(entryAddress(settings.value.path, entry.slug));
|
|
117
|
+
}
|
|
118
|
+
return addresses;
|
|
119
|
+
}
|
|
120
|
+
/** The site's media files, with their sizes and credits. */
|
|
121
|
+
async function listMedia(source) {
|
|
122
|
+
const [files, sizes, creditsText] = await Promise.all([
|
|
123
|
+
source.list(MEDIA_DIR),
|
|
124
|
+
source.fileSizes?.(MEDIA_DIR),
|
|
125
|
+
source.read(MEDIA_FILE)
|
|
126
|
+
]);
|
|
127
|
+
const credits = creditsText === void 0 ? {} : parsed(parseContentFile("media", mediaFileSchema, MEDIA_FILE, creditsText)).credits;
|
|
128
|
+
return files.sort().map((file) => {
|
|
129
|
+
const address = `/${file.slice(7)}`;
|
|
130
|
+
const bytes = sizes?.get(file);
|
|
131
|
+
const credit = credits[address];
|
|
132
|
+
return {
|
|
133
|
+
address,
|
|
134
|
+
...bytes !== void 0 && { bytes },
|
|
135
|
+
...credit && { credit }
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Image types a browser shows on their own, which `screenshot` can take a picture of. */
|
|
140
|
+
const IMAGE_ADDRESS = /^\/media\/[^?#]+\.(?:jpe?g|png|gif|webp|avif|svg|ico)$/i;
|
|
141
|
+
/** Whether an address is a media file's, rather than a page's. */
|
|
142
|
+
function isMediaAddress(address) {
|
|
143
|
+
return address.trim().startsWith("/media/");
|
|
144
|
+
}
|
|
145
|
+
/** An image's address in the media library, or a `ToolError` saying why it can't be shown. */
|
|
146
|
+
async function mediaImage(source, input) {
|
|
147
|
+
const address = input.trim();
|
|
148
|
+
if (!IMAGE_ADDRESS.test(address) || address.split("/").some((part) => part === ".." || part === ".")) throw new ToolError(`${address} isn't an image Claude can look at. Screenshots show pages, and images in the media library such as /media/photo.jpg; other files, such as PDFs and videos, are opened in the admin panel.`);
|
|
149
|
+
if (!(await source.list(MEDIA_DIR)).includes(`public${address}`)) throw new ToolError(`There's no file at ${address}. list_media lists the media library.`);
|
|
150
|
+
return address;
|
|
151
|
+
}
|
|
152
|
+
/** Every content file, as text: what the preview page needs to show a draft as the site would. */
|
|
153
|
+
async function contentFiles(source) {
|
|
154
|
+
return Object.fromEntries(await readTextFiles(source, await source.list("content")));
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/image-page.ts
|
|
158
|
+
/**
|
|
159
|
+
* A page showing one image at most as wide as the screenshot, which both
|
|
160
|
+
* screenshot setups open in place of the image's own address: browsers show
|
|
161
|
+
* an image opened on its own in a document of their own, which can't always
|
|
162
|
+
* be screenshotted (an SVG's never finishes).
|
|
163
|
+
*/
|
|
164
|
+
function attribute(text) {
|
|
165
|
+
return text.replace(/[&"<>]/g, (character) => ({
|
|
166
|
+
"&": "&",
|
|
167
|
+
"\"": """,
|
|
168
|
+
"<": "<",
|
|
169
|
+
">": ">"
|
|
170
|
+
})[character] ?? "");
|
|
171
|
+
}
|
|
172
|
+
function imagePage(url) {
|
|
173
|
+
return `<!doctype html>
|
|
174
|
+
<html><head><meta charset="utf-8"><style>
|
|
175
|
+
html, body { margin: 0; background: #fff; }
|
|
176
|
+
img { display: block; max-width: 100%; height: auto; }
|
|
177
|
+
img[src$=".svg" i] { width: 100%; }
|
|
178
|
+
</style></head>
|
|
179
|
+
<body><img src="${attribute(url)}" alt=""></body></html>`;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/screenshots.ts
|
|
183
|
+
/**
|
|
184
|
+
* Screenshots on the editor's computer, with the Chrome or Microsoft Edge
|
|
185
|
+
* it already has, through Playwright. Nothing is downloaded: without either
|
|
186
|
+
* browser, the screenshot tool says so.
|
|
187
|
+
*/
|
|
188
|
+
/** How long a page may take to load and settle. */
|
|
189
|
+
const TIMEOUT = 3e4;
|
|
190
|
+
function localScreenshots(options = {}) {
|
|
191
|
+
return { async take({ url, width, preview, image, dark }) {
|
|
192
|
+
let chromium;
|
|
193
|
+
try {
|
|
194
|
+
({chromium} = await import("playwright-core"));
|
|
195
|
+
} catch {
|
|
196
|
+
throw new ToolError("Screenshots aren't available: this copy of the connector was installed without them.");
|
|
197
|
+
}
|
|
198
|
+
const attempts = options.executablePath ? [{ executablePath: options.executablePath }] : [{ channel: "chrome" }, { channel: "msedge" }];
|
|
199
|
+
let browser;
|
|
200
|
+
for (const attempt of attempts) {
|
|
201
|
+
browser = await chromium.launch({
|
|
202
|
+
...attempt,
|
|
203
|
+
headless: true
|
|
204
|
+
}).catch(() => void 0);
|
|
205
|
+
if (browser) break;
|
|
206
|
+
}
|
|
207
|
+
if (!browser) throw new ToolError("Screenshots need Google Chrome or Microsoft Edge on this computer, or GOODFELLOW_BROWSER set to another Chromium-based browser.");
|
|
208
|
+
try {
|
|
209
|
+
const page = await browser.newPage({
|
|
210
|
+
viewport: {
|
|
211
|
+
width,
|
|
212
|
+
height: 900
|
|
213
|
+
},
|
|
214
|
+
colorScheme: dark ? "dark" : "light"
|
|
215
|
+
});
|
|
216
|
+
if (image) await page.setContent(imagePage(url), {
|
|
217
|
+
waitUntil: "networkidle",
|
|
218
|
+
timeout: TIMEOUT
|
|
219
|
+
});
|
|
220
|
+
else await page.goto(url, {
|
|
221
|
+
waitUntil: "networkidle",
|
|
222
|
+
timeout: TIMEOUT
|
|
223
|
+
});
|
|
224
|
+
if (preview) {
|
|
225
|
+
await page.waitForFunction(() => typeof window.goodfellowPreview === "function", null, { timeout: TIMEOUT });
|
|
226
|
+
const result = await page.evaluate((request) => window.goodfellowPreview?.(request), preview);
|
|
227
|
+
if (!result?.ok) throw new ToolError(`The draft couldn't be shown: ${result?.problem ?? "the site's preview page didn't answer"}`);
|
|
228
|
+
await page.waitForLoadState("networkidle", { timeout: TIMEOUT });
|
|
229
|
+
}
|
|
230
|
+
return new Uint8Array(await page.screenshot({
|
|
231
|
+
fullPage: true,
|
|
232
|
+
type: "png"
|
|
233
|
+
}));
|
|
234
|
+
} finally {
|
|
235
|
+
await browser.close();
|
|
236
|
+
}
|
|
237
|
+
} };
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
export { listCollections as a, mediaImage as c, readEntry as d, readLayout as f, takenAddresses as g, readSettings as h, isMediaAddress as i, pageAddress as l, readPage as m, ToolError as n, listMedia as o, readMenus as p, contentFiles as r, listPages as s, localScreenshots as t, readCollection as u };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import "@goodfellow-cms/core";
|
|
2
|
+
import "@puckeditor/core";
|
|
3
|
+
//#region src/site.d.ts
|
|
4
|
+
interface Screenshotter {
|
|
5
|
+
/**
|
|
6
|
+
* A PNG of a page at `url`, `width` pixels wide and as tall as the page. With
|
|
7
|
+
* `preview`, `url` is the site's preview page (`/admin/#/preview`), which is
|
|
8
|
+
* handed a draft's content files and shows the page at `preview.path`.
|
|
9
|
+
*/
|
|
10
|
+
take(options: {
|
|
11
|
+
url: string;
|
|
12
|
+
width: number;
|
|
13
|
+
preview?: {
|
|
14
|
+
files: Record<string, string>;
|
|
15
|
+
path: string;
|
|
16
|
+
dark?: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** `url` is an image's, shown on a page of its own (`imagePage()`), no wider than `width`. */
|
|
19
|
+
image?: boolean;
|
|
20
|
+
/** Shows the page as a computer set to dark sees it, for sites with dark mode. */
|
|
21
|
+
dark?: boolean;
|
|
22
|
+
}): Promise<Uint8Array>;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/screenshots.d.ts
|
|
26
|
+
export declare function localScreenshots(options?: {
|
|
27
|
+
executablePath?: string;
|
|
28
|
+
}): Screenshotter;
|
|
29
|
+
//#endregion
|
package/dist/stdio.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|