@damusix/ghost-mcp 0.3.0 → 0.4.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/README.md +61 -1
- package/bin/ghost-keys.sh +58 -0
- package/dist/index.mjs +1071 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -6,6 +7,8 @@ import { attempt } from "@logosdx/utils";
|
|
|
6
7
|
import { FetchEngine, config, get } from "@logosdx/fetch";
|
|
7
8
|
import mimeDb from "mime-db";
|
|
8
9
|
import jwt from "jsonwebtoken";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
9
12
|
//#region src/ghost-client.ts
|
|
10
13
|
const GHOST_URL = process.env.GHOST_URL || "";
|
|
11
14
|
const GHOST_ADMIN_API_KEY = process.env.GHOST_ADMIN_API_KEY || "";
|
|
@@ -141,7 +144,11 @@ const postWriteFields = {
|
|
|
141
144
|
"draft",
|
|
142
145
|
"scheduled"
|
|
143
146
|
]).optional().describe("Post status"),
|
|
144
|
-
tags: z.array(z.union([
|
|
147
|
+
tags: z.array(z.union([
|
|
148
|
+
z.string(),
|
|
149
|
+
z.object({ id: z.string() }),
|
|
150
|
+
z.object({ name: z.string() })
|
|
151
|
+
])).optional().describe("Tags to assign — a tag name string, or an object { id } or { name }"),
|
|
145
152
|
authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
|
|
146
153
|
featured: z.boolean().optional().describe("Whether the post is featured"),
|
|
147
154
|
visibility: z.string().optional().describe("Post visibility (public, members, paid, tiers)"),
|
|
@@ -293,7 +300,11 @@ const pageWriteFields = {
|
|
|
293
300
|
"draft",
|
|
294
301
|
"scheduled"
|
|
295
302
|
]).optional().describe("Page status"),
|
|
296
|
-
tags: z.array(z.union([
|
|
303
|
+
tags: z.array(z.union([
|
|
304
|
+
z.string(),
|
|
305
|
+
z.object({ id: z.string() }),
|
|
306
|
+
z.object({ name: z.string() })
|
|
307
|
+
])).optional().describe("Tags to assign — a tag name string, or an object { id } or { name }"),
|
|
297
308
|
authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
|
|
298
309
|
featured: z.boolean().optional().describe("Whether the page is featured"),
|
|
299
310
|
visibility: z.string().optional().describe("Page visibility"),
|
|
@@ -678,7 +689,7 @@ const adminNewsletterActions = [
|
|
|
678
689
|
];
|
|
679
690
|
//#endregion
|
|
680
691
|
//#region src/actions/admin/offers.ts
|
|
681
|
-
const browseParams$7 = z.object({
|
|
692
|
+
const browseParams$7 = z.object({ filter: z.string().optional().describe("NQL filter expression (e.g. \"status:active\" or \"status:archived\")") });
|
|
682
693
|
const readParams$8 = z.object({ id: z.string().describe("Offer ID") });
|
|
683
694
|
const addSchema$2 = z.object({
|
|
684
695
|
name: z.string().describe("Internal name for the offer (required)"),
|
|
@@ -710,7 +721,8 @@ const adminOfferActions = [
|
|
|
710
721
|
method: "GET",
|
|
711
722
|
path: "/offers/",
|
|
712
723
|
inputSchema: browseParams$7,
|
|
713
|
-
description: "Browse all offers"
|
|
724
|
+
description: "Browse all offers (Ghost returns the full list; filter by status)",
|
|
725
|
+
example: { filter: "status:active" }
|
|
714
726
|
},
|
|
715
727
|
{
|
|
716
728
|
name: "offers.read",
|
|
@@ -1293,7 +1305,32 @@ function initRegistry() {
|
|
|
1293
1305
|
}
|
|
1294
1306
|
initRegistry();
|
|
1295
1307
|
//#endregion
|
|
1308
|
+
//#region src/koenig/util.ts
|
|
1309
|
+
function isRecord(value) {
|
|
1310
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1311
|
+
}
|
|
1312
|
+
//#endregion
|
|
1296
1313
|
//#region src/tools/use-ghost-api.ts
|
|
1314
|
+
/**
|
|
1315
|
+
* Surface the Ghost API error body instead of just the HTTP status text.
|
|
1316
|
+
* `@logosdx/fetch` puts the parsed response on `err.data`; Ghost returns
|
|
1317
|
+
* `{ errors: [{ message, context, type, property, ... }] }`. Without this the
|
|
1318
|
+
* caller only sees "Unprocessable Entity" and cannot tell what actually failed.
|
|
1319
|
+
*/
|
|
1320
|
+
function formatApiError(err) {
|
|
1321
|
+
if (isRecord(err)) {
|
|
1322
|
+
const status = typeof err.status === "number" ? err.status : void 0;
|
|
1323
|
+
if (isRecord(err.data) && Array.isArray(err.data.errors) && err.data.errors.length > 0) return JSON.stringify({
|
|
1324
|
+
status,
|
|
1325
|
+
errors: err.data.errors
|
|
1326
|
+
});
|
|
1327
|
+
if (typeof err.message === "string") return JSON.stringify({
|
|
1328
|
+
status,
|
|
1329
|
+
error: err.message
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
return JSON.stringify({ error: String(err) });
|
|
1333
|
+
}
|
|
1297
1334
|
const extToMime = {};
|
|
1298
1335
|
for (const [mime, meta] of Object.entries(mimeDb)) for (const ext of meta.extensions ?? []) extToMime[`.${ext}`] = mime;
|
|
1299
1336
|
const useGhostApiSchema = z.object({
|
|
@@ -1356,30 +1393,30 @@ async function handleUseGhostApi(input, mode) {
|
|
|
1356
1393
|
if (actionDef.method === "GET") {
|
|
1357
1394
|
const queryParams = extractQueryParams(validPayload, usedPathParams);
|
|
1358
1395
|
const [response, err] = await attempt(async () => engine.get(path, { params: queryParams }));
|
|
1359
|
-
if (err) return
|
|
1360
|
-
return JSON.stringify(response);
|
|
1396
|
+
if (err) return formatApiError(err);
|
|
1397
|
+
return JSON.stringify(response.data);
|
|
1361
1398
|
}
|
|
1362
1399
|
if (actionDef.method === "DELETE") {
|
|
1363
1400
|
const [response, err] = await attempt(async () => engine.delete(path));
|
|
1364
|
-
if (err) return
|
|
1401
|
+
if (err) return formatApiError(err);
|
|
1365
1402
|
const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
|
|
1366
1403
|
await engine.invalidatePath(resourcePrefix);
|
|
1367
|
-
return JSON.stringify(response ?? { success: true });
|
|
1404
|
+
return JSON.stringify(response?.data ?? { success: true });
|
|
1368
1405
|
}
|
|
1369
1406
|
const resourceKey = actionDef.name.split(".")[0];
|
|
1370
1407
|
const body = extractBodyPayload(validPayload, usedPathParams);
|
|
1371
1408
|
const wrappedBody = { [resourceKey]: [body] };
|
|
1372
1409
|
if (actionDef.method === "POST") {
|
|
1373
1410
|
const [response, err] = await attempt(async () => engine.post(path, wrappedBody));
|
|
1374
|
-
if (err) return
|
|
1375
|
-
return JSON.stringify(response);
|
|
1411
|
+
if (err) return formatApiError(err);
|
|
1412
|
+
return JSON.stringify(response.data);
|
|
1376
1413
|
}
|
|
1377
1414
|
if (actionDef.method === "PUT") {
|
|
1378
1415
|
const [response, err] = await attempt(async () => engine.put(path, wrappedBody));
|
|
1379
|
-
if (err) return
|
|
1416
|
+
if (err) return formatApiError(err);
|
|
1380
1417
|
const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
|
|
1381
1418
|
await engine.invalidatePath(resourcePrefix);
|
|
1382
|
-
return JSON.stringify(response);
|
|
1419
|
+
return JSON.stringify(response.data);
|
|
1383
1420
|
}
|
|
1384
1421
|
return JSON.stringify({ error: `Unsupported method: ${actionDef.method}` });
|
|
1385
1422
|
}
|
|
@@ -1405,8 +1442,8 @@ async function handleFileUpload(actionName, payload, path) {
|
|
|
1405
1442
|
const [response, err] = await attempt(async () => adminApi.post(path, formData, { onBeforeReq: (opts) => {
|
|
1406
1443
|
delete opts.headers["Content-Type"];
|
|
1407
1444
|
} }));
|
|
1408
|
-
if (err) return
|
|
1409
|
-
return JSON.stringify(response);
|
|
1445
|
+
if (err) return formatApiError(err);
|
|
1446
|
+
return JSON.stringify(response.data);
|
|
1410
1447
|
}
|
|
1411
1448
|
//#endregion
|
|
1412
1449
|
//#region src/tools/ghost-api-help.ts
|
|
@@ -1448,20 +1485,11 @@ const ghostDocsSchema = z.object({
|
|
|
1448
1485
|
search: z.string().optional().describe("Case-insensitive substring search across the documentation"),
|
|
1449
1486
|
regex: z.string().optional().describe("Regex pattern string to match (e.g. \"/pattern/i\")")
|
|
1450
1487
|
});
|
|
1451
|
-
async function fetchDocs() {
|
|
1452
|
-
const [response, err] = await attempt(async () => docsApi.get("/llms.txt"));
|
|
1453
|
-
if (err) throw new Error(`Failed to fetch Ghost docs: ${err.message}`);
|
|
1454
|
-
return response.data;
|
|
1455
|
-
}
|
|
1456
1488
|
async function handleGhostDocs(input) {
|
|
1457
1489
|
const { all, search, regex } = input;
|
|
1458
1490
|
if (!all && !search && !regex) return "Provide one of: `all: true` to get full docs, `search` for text search, or `regex` for pattern matching.";
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
content = await fetchDocs();
|
|
1462
|
-
} catch (error) {
|
|
1463
|
-
return `Error: ${error.message}`;
|
|
1464
|
-
}
|
|
1491
|
+
const response = await docsApi.get("/llms.txt");
|
|
1492
|
+
const content = String(response.data);
|
|
1465
1493
|
if (all) return content;
|
|
1466
1494
|
const lines = content.split("\n");
|
|
1467
1495
|
const matchedLines = [];
|
|
@@ -1494,11 +1522,998 @@ async function handleGhostDocs(input) {
|
|
|
1494
1522
|
return matchedLines.join("\n");
|
|
1495
1523
|
}
|
|
1496
1524
|
//#endregion
|
|
1525
|
+
//#region src/koenig/inline.ts
|
|
1526
|
+
const FORMAT = {
|
|
1527
|
+
bold: 1,
|
|
1528
|
+
italic: 2,
|
|
1529
|
+
strikethrough: 4,
|
|
1530
|
+
underline: 8,
|
|
1531
|
+
code: 16
|
|
1532
|
+
};
|
|
1533
|
+
function textNode(text, format = 0) {
|
|
1534
|
+
return {
|
|
1535
|
+
type: "extended-text",
|
|
1536
|
+
version: 1,
|
|
1537
|
+
text,
|
|
1538
|
+
format,
|
|
1539
|
+
mode: "normal",
|
|
1540
|
+
style: "",
|
|
1541
|
+
detail: 0
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
function linkNode(url, label) {
|
|
1545
|
+
return {
|
|
1546
|
+
type: "link",
|
|
1547
|
+
version: 1,
|
|
1548
|
+
direction: "ltr",
|
|
1549
|
+
format: "",
|
|
1550
|
+
indent: 0,
|
|
1551
|
+
rel: null,
|
|
1552
|
+
target: null,
|
|
1553
|
+
title: null,
|
|
1554
|
+
url,
|
|
1555
|
+
children: [textNode(label)]
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
const TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*|(?<![\w])_([^_]+)_(?![\w])/g;
|
|
1559
|
+
function parseInline(input) {
|
|
1560
|
+
const nodes = [];
|
|
1561
|
+
let last = 0;
|
|
1562
|
+
let m;
|
|
1563
|
+
TOKEN.lastIndex = 0;
|
|
1564
|
+
while ((m = TOKEN.exec(input)) !== null) {
|
|
1565
|
+
if (m.index > last) nodes.push(textNode(input.slice(last, m.index)));
|
|
1566
|
+
if (m[1] !== void 0) nodes.push(linkNode(m[2], m[1]));
|
|
1567
|
+
else if (m[3] !== void 0) nodes.push(textNode(m[3], FORMAT.code));
|
|
1568
|
+
else if (m[4] !== void 0) nodes.push(textNode(m[4], FORMAT.bold));
|
|
1569
|
+
else if (m[5] !== void 0) nodes.push(textNode(m[5], FORMAT.italic));
|
|
1570
|
+
else if (m[6] !== void 0) nodes.push(textNode(m[6], FORMAT.italic));
|
|
1571
|
+
last = m.index + m[0].length;
|
|
1572
|
+
}
|
|
1573
|
+
if (last < input.length) nodes.push(textNode(input.slice(last)));
|
|
1574
|
+
if (nodes.length === 0) nodes.push(textNode(input));
|
|
1575
|
+
return nodes;
|
|
1576
|
+
}
|
|
1577
|
+
//#endregion
|
|
1578
|
+
//#region src/koenig/node-specs.ts
|
|
1579
|
+
const NODE_SPECS = {
|
|
1580
|
+
audio: {
|
|
1581
|
+
nodeType: "audio",
|
|
1582
|
+
hasVisibility: false,
|
|
1583
|
+
fields: {
|
|
1584
|
+
duration: 0,
|
|
1585
|
+
mimeType: "",
|
|
1586
|
+
src: "",
|
|
1587
|
+
title: "",
|
|
1588
|
+
thumbnailSrc: ""
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
bookmark: {
|
|
1592
|
+
nodeType: "bookmark",
|
|
1593
|
+
hasVisibility: false,
|
|
1594
|
+
fields: {
|
|
1595
|
+
title: "",
|
|
1596
|
+
description: "",
|
|
1597
|
+
url: "",
|
|
1598
|
+
caption: "",
|
|
1599
|
+
author: "",
|
|
1600
|
+
publisher: ""
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
button: {
|
|
1604
|
+
nodeType: "button",
|
|
1605
|
+
hasVisibility: false,
|
|
1606
|
+
fields: {
|
|
1607
|
+
buttonText: "",
|
|
1608
|
+
alignment: "center",
|
|
1609
|
+
buttonUrl: ""
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
"call-to-action": {
|
|
1613
|
+
nodeType: "call-to-action",
|
|
1614
|
+
hasVisibility: true,
|
|
1615
|
+
fields: {
|
|
1616
|
+
layout: "minimal",
|
|
1617
|
+
alignment: "left",
|
|
1618
|
+
textValue: "",
|
|
1619
|
+
showButton: true,
|
|
1620
|
+
showDividers: true,
|
|
1621
|
+
buttonText: "Learn more",
|
|
1622
|
+
buttonUrl: "",
|
|
1623
|
+
buttonColor: "#000000",
|
|
1624
|
+
buttonTextColor: "#ffffff",
|
|
1625
|
+
hasSponsorLabel: true,
|
|
1626
|
+
sponsorLabel: "<p><span style=\"white-space: pre-wrap;\">SPONSORED</span></p>",
|
|
1627
|
+
backgroundColor: "grey",
|
|
1628
|
+
linkColor: "text",
|
|
1629
|
+
imageUrl: "",
|
|
1630
|
+
imageWidth: null,
|
|
1631
|
+
imageHeight: null
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
callout: {
|
|
1635
|
+
nodeType: "callout",
|
|
1636
|
+
hasVisibility: false,
|
|
1637
|
+
fields: {
|
|
1638
|
+
calloutText: "",
|
|
1639
|
+
calloutEmoji: "💡",
|
|
1640
|
+
backgroundColor: "blue"
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
codeblock: {
|
|
1644
|
+
nodeType: "codeblock",
|
|
1645
|
+
hasVisibility: false,
|
|
1646
|
+
fields: {
|
|
1647
|
+
code: "",
|
|
1648
|
+
language: "",
|
|
1649
|
+
caption: ""
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
email: {
|
|
1653
|
+
nodeType: "email",
|
|
1654
|
+
hasVisibility: false,
|
|
1655
|
+
fields: { html: "" }
|
|
1656
|
+
},
|
|
1657
|
+
"email-cta": {
|
|
1658
|
+
nodeType: "email-cta",
|
|
1659
|
+
hasVisibility: false,
|
|
1660
|
+
fields: {
|
|
1661
|
+
alignment: "left",
|
|
1662
|
+
buttonText: "",
|
|
1663
|
+
buttonUrl: "",
|
|
1664
|
+
html: "",
|
|
1665
|
+
segment: "status:free",
|
|
1666
|
+
showButton: false,
|
|
1667
|
+
showDividers: true
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
embed: {
|
|
1671
|
+
nodeType: "embed",
|
|
1672
|
+
hasVisibility: false,
|
|
1673
|
+
fields: {
|
|
1674
|
+
url: "",
|
|
1675
|
+
embedType: "",
|
|
1676
|
+
html: "",
|
|
1677
|
+
caption: ""
|
|
1678
|
+
}
|
|
1679
|
+
},
|
|
1680
|
+
file: {
|
|
1681
|
+
nodeType: "file",
|
|
1682
|
+
hasVisibility: false,
|
|
1683
|
+
fields: {
|
|
1684
|
+
src: "",
|
|
1685
|
+
fileTitle: "",
|
|
1686
|
+
fileCaption: "",
|
|
1687
|
+
fileName: "",
|
|
1688
|
+
fileSize: 0
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
gallery: {
|
|
1692
|
+
nodeType: "gallery",
|
|
1693
|
+
hasVisibility: false,
|
|
1694
|
+
fields: {
|
|
1695
|
+
images: "[]",
|
|
1696
|
+
caption: ""
|
|
1697
|
+
}
|
|
1698
|
+
},
|
|
1699
|
+
header: {
|
|
1700
|
+
nodeType: "header",
|
|
1701
|
+
hasVisibility: false,
|
|
1702
|
+
fields: {
|
|
1703
|
+
size: "small",
|
|
1704
|
+
style: "dark",
|
|
1705
|
+
buttonEnabled: false,
|
|
1706
|
+
buttonUrl: "",
|
|
1707
|
+
buttonText: "",
|
|
1708
|
+
header: "",
|
|
1709
|
+
subheader: "",
|
|
1710
|
+
backgroundImageSrc: "",
|
|
1711
|
+
version: 1,
|
|
1712
|
+
accentColor: "#FF1A75",
|
|
1713
|
+
alignment: "center",
|
|
1714
|
+
backgroundColor: "#000000",
|
|
1715
|
+
backgroundImageWidth: null,
|
|
1716
|
+
backgroundImageHeight: null,
|
|
1717
|
+
backgroundSize: "cover",
|
|
1718
|
+
textColor: "#FFFFFF",
|
|
1719
|
+
buttonColor: "#ffffff",
|
|
1720
|
+
buttonTextColor: "#000000",
|
|
1721
|
+
layout: "full",
|
|
1722
|
+
swapped: false
|
|
1723
|
+
}
|
|
1724
|
+
},
|
|
1725
|
+
html: {
|
|
1726
|
+
nodeType: "html",
|
|
1727
|
+
hasVisibility: true,
|
|
1728
|
+
fields: { html: "" }
|
|
1729
|
+
},
|
|
1730
|
+
image: {
|
|
1731
|
+
nodeType: "image",
|
|
1732
|
+
hasVisibility: false,
|
|
1733
|
+
fields: {
|
|
1734
|
+
src: "",
|
|
1735
|
+
caption: "",
|
|
1736
|
+
title: "",
|
|
1737
|
+
alt: "",
|
|
1738
|
+
cardWidth: "regular",
|
|
1739
|
+
width: null,
|
|
1740
|
+
height: null,
|
|
1741
|
+
href: ""
|
|
1742
|
+
}
|
|
1743
|
+
},
|
|
1744
|
+
markdown: {
|
|
1745
|
+
nodeType: "markdown",
|
|
1746
|
+
hasVisibility: false,
|
|
1747
|
+
fields: { markdown: "" }
|
|
1748
|
+
},
|
|
1749
|
+
product: {
|
|
1750
|
+
nodeType: "product",
|
|
1751
|
+
hasVisibility: false,
|
|
1752
|
+
fields: {
|
|
1753
|
+
productImageSrc: "",
|
|
1754
|
+
productImageWidth: null,
|
|
1755
|
+
productImageHeight: null,
|
|
1756
|
+
productTitle: "",
|
|
1757
|
+
productDescription: "",
|
|
1758
|
+
productRatingEnabled: false,
|
|
1759
|
+
productStarRating: 5,
|
|
1760
|
+
productButtonEnabled: false,
|
|
1761
|
+
productButton: "",
|
|
1762
|
+
productUrl: ""
|
|
1763
|
+
}
|
|
1764
|
+
},
|
|
1765
|
+
signup: {
|
|
1766
|
+
nodeType: "signup",
|
|
1767
|
+
hasVisibility: false,
|
|
1768
|
+
fields: {
|
|
1769
|
+
alignment: "left",
|
|
1770
|
+
backgroundColor: "#F0F0F0",
|
|
1771
|
+
backgroundImageSrc: "",
|
|
1772
|
+
backgroundSize: "cover",
|
|
1773
|
+
textColor: "",
|
|
1774
|
+
buttonColor: "accent",
|
|
1775
|
+
buttonTextColor: "#FFFFFF",
|
|
1776
|
+
buttonText: "Subscribe",
|
|
1777
|
+
disclaimer: "",
|
|
1778
|
+
header: "",
|
|
1779
|
+
layout: "wide",
|
|
1780
|
+
subheader: "",
|
|
1781
|
+
successMessage: "Email sent! Check your inbox to complete your signup.",
|
|
1782
|
+
swapped: false
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
toggle: {
|
|
1786
|
+
nodeType: "toggle",
|
|
1787
|
+
hasVisibility: false,
|
|
1788
|
+
fields: {
|
|
1789
|
+
heading: "",
|
|
1790
|
+
content: ""
|
|
1791
|
+
}
|
|
1792
|
+
},
|
|
1793
|
+
transistor: {
|
|
1794
|
+
nodeType: "transistor",
|
|
1795
|
+
hasVisibility: true,
|
|
1796
|
+
fields: {
|
|
1797
|
+
accentColor: "",
|
|
1798
|
+
backgroundColor: ""
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
video: {
|
|
1802
|
+
nodeType: "video",
|
|
1803
|
+
hasVisibility: false,
|
|
1804
|
+
fields: {
|
|
1805
|
+
src: "",
|
|
1806
|
+
caption: "",
|
|
1807
|
+
fileName: "",
|
|
1808
|
+
mimeType: "",
|
|
1809
|
+
width: null,
|
|
1810
|
+
height: null,
|
|
1811
|
+
duration: 0,
|
|
1812
|
+
thumbnailSrc: "",
|
|
1813
|
+
customThumbnailSrc: "",
|
|
1814
|
+
thumbnailWidth: null,
|
|
1815
|
+
thumbnailHeight: null,
|
|
1816
|
+
cardWidth: "regular",
|
|
1817
|
+
loop: false
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/koenig/cards.ts
|
|
1823
|
+
function passthrough(def, fields) {
|
|
1824
|
+
const spec = NODE_SPECS[def.nodeType];
|
|
1825
|
+
const validFields = spec ? new Set(Object.keys(spec.fields)) : null;
|
|
1826
|
+
const node = {};
|
|
1827
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
1828
|
+
const target = def.aliases[key] ?? key;
|
|
1829
|
+
if (validFields && !validFields.has(target)) {
|
|
1830
|
+
const allowed = [...Object.keys(def.aliases), ...validFields ?? []].join(", ");
|
|
1831
|
+
throw new Error(`unknown field "${key}" for card "${def.nodeType}". allowed: ${allowed}`);
|
|
1832
|
+
}
|
|
1833
|
+
node[target] = value;
|
|
1834
|
+
}
|
|
1835
|
+
return node;
|
|
1836
|
+
}
|
|
1837
|
+
/** keyed by the friendly block `type` the LLM writes */
|
|
1838
|
+
const CARDS = {
|
|
1839
|
+
image: {
|
|
1840
|
+
nodeType: "image",
|
|
1841
|
+
version: 1,
|
|
1842
|
+
group: "media",
|
|
1843
|
+
description: "An image with optional caption, alt text, and link.",
|
|
1844
|
+
required: ["src"],
|
|
1845
|
+
aliases: {},
|
|
1846
|
+
example: {
|
|
1847
|
+
src: "https://example.com/photo.jpg",
|
|
1848
|
+
alt: "A photo",
|
|
1849
|
+
caption: "My caption"
|
|
1850
|
+
}
|
|
1851
|
+
},
|
|
1852
|
+
gallery: {
|
|
1853
|
+
nodeType: "gallery",
|
|
1854
|
+
version: 1,
|
|
1855
|
+
group: "media",
|
|
1856
|
+
description: "A grid of images. Provide `images` as an array of { src, alt?, width?, height? }.",
|
|
1857
|
+
required: ["images"],
|
|
1858
|
+
aliases: {},
|
|
1859
|
+
example: {
|
|
1860
|
+
images: [{ src: "https://example.com/1.jpg" }, { src: "https://example.com/2.jpg" }],
|
|
1861
|
+
caption: "Trip photos"
|
|
1862
|
+
},
|
|
1863
|
+
build(fields) {
|
|
1864
|
+
const node = { images: (Array.isArray(fields.images) ? fields.images : []).map((img, i) => {
|
|
1865
|
+
const o = isRecord(img) ? img : {};
|
|
1866
|
+
return {
|
|
1867
|
+
fileName: o.fileName ?? `image-${i}.jpg`,
|
|
1868
|
+
row: o.row ?? 0,
|
|
1869
|
+
src: o.src ?? "",
|
|
1870
|
+
width: o.width ?? 0,
|
|
1871
|
+
height: o.height ?? 0,
|
|
1872
|
+
title: o.title ?? "",
|
|
1873
|
+
alt: o.alt ?? ""
|
|
1874
|
+
};
|
|
1875
|
+
}) };
|
|
1876
|
+
if (typeof fields.caption === "string") node.caption = fields.caption;
|
|
1877
|
+
return node;
|
|
1878
|
+
}
|
|
1879
|
+
},
|
|
1880
|
+
video: {
|
|
1881
|
+
nodeType: "video",
|
|
1882
|
+
version: 1,
|
|
1883
|
+
group: "media",
|
|
1884
|
+
description: "A video file. Needs `src`; `thumbnailSrc` sets the poster.",
|
|
1885
|
+
required: ["src"],
|
|
1886
|
+
aliases: { thumbnail: "thumbnailSrc" },
|
|
1887
|
+
example: {
|
|
1888
|
+
src: "https://example.com/clip.mp4",
|
|
1889
|
+
caption: "A clip",
|
|
1890
|
+
thumbnail: "https://example.com/poster.jpg"
|
|
1891
|
+
}
|
|
1892
|
+
},
|
|
1893
|
+
audio: {
|
|
1894
|
+
nodeType: "audio",
|
|
1895
|
+
version: 1,
|
|
1896
|
+
group: "media",
|
|
1897
|
+
description: "An audio file with a title.",
|
|
1898
|
+
required: ["src"],
|
|
1899
|
+
aliases: { thumbnail: "thumbnailSrc" },
|
|
1900
|
+
example: {
|
|
1901
|
+
src: "https://example.com/track.mp3",
|
|
1902
|
+
title: "Episode 1",
|
|
1903
|
+
duration: 320
|
|
1904
|
+
}
|
|
1905
|
+
},
|
|
1906
|
+
file: {
|
|
1907
|
+
nodeType: "file",
|
|
1908
|
+
version: 1,
|
|
1909
|
+
group: "media",
|
|
1910
|
+
description: "A downloadable file card.",
|
|
1911
|
+
required: ["src"],
|
|
1912
|
+
aliases: {
|
|
1913
|
+
title: "fileTitle",
|
|
1914
|
+
name: "fileName",
|
|
1915
|
+
caption: "fileCaption",
|
|
1916
|
+
size: "fileSize"
|
|
1917
|
+
},
|
|
1918
|
+
example: {
|
|
1919
|
+
src: "https://example.com/guide.pdf",
|
|
1920
|
+
title: "Whitepaper",
|
|
1921
|
+
caption: "Download our guide"
|
|
1922
|
+
}
|
|
1923
|
+
},
|
|
1924
|
+
bookmark: {
|
|
1925
|
+
nodeType: "bookmark",
|
|
1926
|
+
version: 1,
|
|
1927
|
+
group: "embed",
|
|
1928
|
+
description: "A rich link preview. Provide `url`; optionally title/description/author/publisher/icon/thumbnail.",
|
|
1929
|
+
required: ["url"],
|
|
1930
|
+
aliases: {},
|
|
1931
|
+
example: {
|
|
1932
|
+
url: "https://ghost.org",
|
|
1933
|
+
title: "Ghost",
|
|
1934
|
+
description: "Publishing platform"
|
|
1935
|
+
},
|
|
1936
|
+
build(fields) {
|
|
1937
|
+
const metaKeys = [
|
|
1938
|
+
"title",
|
|
1939
|
+
"description",
|
|
1940
|
+
"author",
|
|
1941
|
+
"publisher",
|
|
1942
|
+
"icon",
|
|
1943
|
+
"thumbnail"
|
|
1944
|
+
];
|
|
1945
|
+
const metadata = { url: fields.url };
|
|
1946
|
+
for (const k of metaKeys) metadata[k] = fields[k] ?? (k === "author" ? null : "");
|
|
1947
|
+
return {
|
|
1948
|
+
url: fields.url,
|
|
1949
|
+
caption: fields.caption ?? "",
|
|
1950
|
+
metadata
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
embed: {
|
|
1955
|
+
nodeType: "embed",
|
|
1956
|
+
version: 1,
|
|
1957
|
+
group: "embed",
|
|
1958
|
+
description: "An external embed (YouTube, Twitter, etc.). Provide `url` and the embed `html`.",
|
|
1959
|
+
required: ["url"],
|
|
1960
|
+
aliases: {},
|
|
1961
|
+
example: {
|
|
1962
|
+
url: "https://youtube.com/watch?v=abc",
|
|
1963
|
+
embedType: "video",
|
|
1964
|
+
html: "<iframe src=\"...\"></iframe>"
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
html: {
|
|
1968
|
+
nodeType: "html",
|
|
1969
|
+
version: 1,
|
|
1970
|
+
group: "embed",
|
|
1971
|
+
description: "Raw HTML passthrough. Use only when no native card fits.",
|
|
1972
|
+
required: ["html"],
|
|
1973
|
+
aliases: {},
|
|
1974
|
+
example: { html: "<div class=\"custom\">Raw HTML</div>" }
|
|
1975
|
+
},
|
|
1976
|
+
markdown: {
|
|
1977
|
+
nodeType: "markdown",
|
|
1978
|
+
version: 1,
|
|
1979
|
+
group: "embed",
|
|
1980
|
+
description: "A markdown block (rendered as one unit). Prefer paragraph/heading/list blocks for editable prose.",
|
|
1981
|
+
required: ["markdown"],
|
|
1982
|
+
aliases: { text: "markdown" },
|
|
1983
|
+
example: { markdown: "## Heading\n\nSome **markdown**." }
|
|
1984
|
+
},
|
|
1985
|
+
codeblock: {
|
|
1986
|
+
nodeType: "codeblock",
|
|
1987
|
+
version: 1,
|
|
1988
|
+
group: "embed",
|
|
1989
|
+
description: "A syntax-highlighted code block.",
|
|
1990
|
+
required: ["code"],
|
|
1991
|
+
aliases: { lang: "language" },
|
|
1992
|
+
example: {
|
|
1993
|
+
code: "const x = 1;",
|
|
1994
|
+
language: "javascript",
|
|
1995
|
+
caption: "snippet"
|
|
1996
|
+
}
|
|
1997
|
+
},
|
|
1998
|
+
callout: {
|
|
1999
|
+
nodeType: "callout",
|
|
2000
|
+
version: 1,
|
|
2001
|
+
group: "layout",
|
|
2002
|
+
description: "A highlighted callout box with an emoji and background color.",
|
|
2003
|
+
required: ["text"],
|
|
2004
|
+
aliases: {
|
|
2005
|
+
text: "calloutText",
|
|
2006
|
+
emoji: "calloutEmoji",
|
|
2007
|
+
color: "backgroundColor"
|
|
2008
|
+
},
|
|
2009
|
+
example: {
|
|
2010
|
+
text: "Heads up!",
|
|
2011
|
+
emoji: "💡",
|
|
2012
|
+
color: "blue"
|
|
2013
|
+
}
|
|
2014
|
+
},
|
|
2015
|
+
toggle: {
|
|
2016
|
+
nodeType: "toggle",
|
|
2017
|
+
version: 1,
|
|
2018
|
+
group: "layout",
|
|
2019
|
+
description: "A collapsible accordion. `content` is HTML. (No-op in email.)",
|
|
2020
|
+
required: ["heading"],
|
|
2021
|
+
aliases: {},
|
|
2022
|
+
example: {
|
|
2023
|
+
heading: "Click to expand",
|
|
2024
|
+
content: "<p>Hidden content.</p>"
|
|
2025
|
+
}
|
|
2026
|
+
},
|
|
2027
|
+
button: {
|
|
2028
|
+
nodeType: "button",
|
|
2029
|
+
version: 1,
|
|
2030
|
+
group: "layout",
|
|
2031
|
+
description: "A call-to-action button.",
|
|
2032
|
+
required: ["text", "url"],
|
|
2033
|
+
aliases: {
|
|
2034
|
+
text: "buttonText",
|
|
2035
|
+
url: "buttonUrl"
|
|
2036
|
+
},
|
|
2037
|
+
example: {
|
|
2038
|
+
text: "Subscribe",
|
|
2039
|
+
url: "https://example.com",
|
|
2040
|
+
alignment: "center"
|
|
2041
|
+
}
|
|
2042
|
+
},
|
|
2043
|
+
header: {
|
|
2044
|
+
nodeType: "header",
|
|
2045
|
+
version: 2,
|
|
2046
|
+
group: "layout",
|
|
2047
|
+
description: "A large hero header with optional background image and button.",
|
|
2048
|
+
required: [],
|
|
2049
|
+
aliases: { title: "header" },
|
|
2050
|
+
example: {
|
|
2051
|
+
header: "Big Header",
|
|
2052
|
+
subheader: "A subheader",
|
|
2053
|
+
buttonEnabled: true,
|
|
2054
|
+
buttonText: "Start",
|
|
2055
|
+
buttonUrl: "https://example.com"
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
cta: {
|
|
2059
|
+
nodeType: "call-to-action",
|
|
2060
|
+
version: 1,
|
|
2061
|
+
group: "layout",
|
|
2062
|
+
description: "A call-to-action card with text, optional image and button. `text` accepts HTML or plain text.",
|
|
2063
|
+
required: [],
|
|
2064
|
+
aliases: { buttonColor: "buttonColor" },
|
|
2065
|
+
example: {
|
|
2066
|
+
text: "Subscribe for more.",
|
|
2067
|
+
buttonText: "Join",
|
|
2068
|
+
buttonUrl: "https://example.com",
|
|
2069
|
+
showButton: true
|
|
2070
|
+
},
|
|
2071
|
+
build(fields) {
|
|
2072
|
+
const node = {};
|
|
2073
|
+
for (const k of [
|
|
2074
|
+
"layout",
|
|
2075
|
+
"alignment",
|
|
2076
|
+
"showButton",
|
|
2077
|
+
"showDividers",
|
|
2078
|
+
"buttonText",
|
|
2079
|
+
"buttonUrl",
|
|
2080
|
+
"buttonColor",
|
|
2081
|
+
"buttonTextColor",
|
|
2082
|
+
"hasSponsorLabel",
|
|
2083
|
+
"sponsorLabel",
|
|
2084
|
+
"backgroundColor",
|
|
2085
|
+
"linkColor",
|
|
2086
|
+
"imageUrl",
|
|
2087
|
+
"imageWidth",
|
|
2088
|
+
"imageHeight",
|
|
2089
|
+
"visibility"
|
|
2090
|
+
]) if (k in fields) node[k] = fields[k];
|
|
2091
|
+
if (typeof fields.text === "string") node.textValue = /^\s*</.test(fields.text) ? fields.text : `<p>${fields.text}</p>`;
|
|
2092
|
+
return node;
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
signup: {
|
|
2096
|
+
nodeType: "signup",
|
|
2097
|
+
version: 1,
|
|
2098
|
+
group: "membership",
|
|
2099
|
+
description: "A member signup form. (No-op in email.)",
|
|
2100
|
+
required: [],
|
|
2101
|
+
aliases: {},
|
|
2102
|
+
example: {
|
|
2103
|
+
header: "Subscribe",
|
|
2104
|
+
subheader: "Join the newsletter",
|
|
2105
|
+
disclaimer: "No spam."
|
|
2106
|
+
}
|
|
2107
|
+
},
|
|
2108
|
+
product: {
|
|
2109
|
+
nodeType: "product",
|
|
2110
|
+
version: 1,
|
|
2111
|
+
group: "layout",
|
|
2112
|
+
description: "A product card with image, rating, and button.",
|
|
2113
|
+
required: ["productTitle"],
|
|
2114
|
+
aliases: {
|
|
2115
|
+
title: "productTitle",
|
|
2116
|
+
description: "productDescription",
|
|
2117
|
+
image: "productImageSrc",
|
|
2118
|
+
button: "productButton",
|
|
2119
|
+
url: "productUrl",
|
|
2120
|
+
rating: "productStarRating"
|
|
2121
|
+
},
|
|
2122
|
+
example: {
|
|
2123
|
+
title: "The Product",
|
|
2124
|
+
description: "A great product.",
|
|
2125
|
+
rating: 5,
|
|
2126
|
+
button: "Buy",
|
|
2127
|
+
url: "https://example.com",
|
|
2128
|
+
productButtonEnabled: true,
|
|
2129
|
+
productRatingEnabled: true
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
divider: {
|
|
2133
|
+
nodeType: "horizontalrule",
|
|
2134
|
+
version: 1,
|
|
2135
|
+
group: "divider",
|
|
2136
|
+
description: "A horizontal rule / divider.",
|
|
2137
|
+
required: [],
|
|
2138
|
+
aliases: {},
|
|
2139
|
+
example: {},
|
|
2140
|
+
build() {
|
|
2141
|
+
return {};
|
|
2142
|
+
}
|
|
2143
|
+
},
|
|
2144
|
+
paywall: {
|
|
2145
|
+
nodeType: "paywall",
|
|
2146
|
+
version: 1,
|
|
2147
|
+
group: "membership",
|
|
2148
|
+
description: "Splits free vs members-only content. Everything after it is members-only.",
|
|
2149
|
+
required: [],
|
|
2150
|
+
aliases: {},
|
|
2151
|
+
example: {},
|
|
2152
|
+
build() {
|
|
2153
|
+
return {};
|
|
2154
|
+
}
|
|
2155
|
+
},
|
|
2156
|
+
email: {
|
|
2157
|
+
nodeType: "email",
|
|
2158
|
+
version: 1,
|
|
2159
|
+
group: "email-only",
|
|
2160
|
+
description: "Content shown ONLY in the email newsletter (empty on web). `html` supports {first_name, \"fallback\"}.",
|
|
2161
|
+
required: ["html"],
|
|
2162
|
+
aliases: {},
|
|
2163
|
+
example: { html: "<p>Hello {first_name, \"there\"}!</p>" }
|
|
2164
|
+
},
|
|
2165
|
+
"email-cta": {
|
|
2166
|
+
nodeType: "email-cta",
|
|
2167
|
+
version: 1,
|
|
2168
|
+
group: "email-only",
|
|
2169
|
+
description: "A newsletter-only call to action targeting a member segment.",
|
|
2170
|
+
required: [],
|
|
2171
|
+
aliases: {},
|
|
2172
|
+
example: {
|
|
2173
|
+
html: "<p>Read more.</p>",
|
|
2174
|
+
buttonText: "Read",
|
|
2175
|
+
buttonUrl: "https://example.com",
|
|
2176
|
+
segment: "status:free"
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
};
|
|
2180
|
+
function buildCardNode(blockType, fields) {
|
|
2181
|
+
const def = CARDS[blockType];
|
|
2182
|
+
if (!def) throw new Error(`unknown card type "${blockType}"`);
|
|
2183
|
+
for (const r of def.required) if (fields[r] === void 0 || fields[r] === null || fields[r] === "") throw new Error(`card "${blockType}" requires field "${r}"`);
|
|
2184
|
+
const data = def.build ? def.build(fields) : passthrough(def, fields);
|
|
2185
|
+
return {
|
|
2186
|
+
type: def.nodeType,
|
|
2187
|
+
version: def.version,
|
|
2188
|
+
...data
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
function isCardType(blockType) {
|
|
2192
|
+
return blockType in CARDS;
|
|
2193
|
+
}
|
|
2194
|
+
//#endregion
|
|
2195
|
+
//#region src/koenig/blocks.ts
|
|
2196
|
+
const ELEMENT = {
|
|
2197
|
+
direction: "ltr",
|
|
2198
|
+
format: "",
|
|
2199
|
+
indent: 0
|
|
2200
|
+
};
|
|
2201
|
+
function element(type, extra, children) {
|
|
2202
|
+
return {
|
|
2203
|
+
type,
|
|
2204
|
+
version: 1,
|
|
2205
|
+
...ELEMENT,
|
|
2206
|
+
...extra,
|
|
2207
|
+
children
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
function asString(value, field, blockType) {
|
|
2211
|
+
if (typeof value !== "string") throw new Error(`block "${blockType}" field "${field}" must be a string`);
|
|
2212
|
+
return value;
|
|
2213
|
+
}
|
|
2214
|
+
function buildProse(block) {
|
|
2215
|
+
switch (block.type) {
|
|
2216
|
+
case "paragraph": return element("paragraph", {}, parseInline(asString(block.text, "text", "paragraph")));
|
|
2217
|
+
case "heading": return element("extended-heading", { tag: `h${typeof block.level === "number" ? Math.min(6, Math.max(1, block.level)) : 2}` }, parseInline(asString(block.text, "text", "heading")));
|
|
2218
|
+
case "quote": return element("extended-quote", {}, parseInline(asString(block.text, "text", "quote")));
|
|
2219
|
+
case "aside": return element("aside", {}, parseInline(asString(block.text, "text", "aside")));
|
|
2220
|
+
case "list": {
|
|
2221
|
+
const items = Array.isArray(block.items) ? block.items : [];
|
|
2222
|
+
if (items.length === 0) throw new Error("block \"list\" requires a non-empty \"items\" array");
|
|
2223
|
+
const ordered = block.style === "number" || block.style === "ordered";
|
|
2224
|
+
const children = items.map((item, i) => element("listitem", { value: i + 1 }, parseInline(String(item))));
|
|
2225
|
+
return element("list", {
|
|
2226
|
+
listType: ordered ? "number" : "bullet",
|
|
2227
|
+
tag: ordered ? "ol" : "ul",
|
|
2228
|
+
start: 1
|
|
2229
|
+
}, children);
|
|
2230
|
+
}
|
|
2231
|
+
default: return null;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
function isBlock(value) {
|
|
2235
|
+
return isRecord(value) && typeof value.type === "string";
|
|
2236
|
+
}
|
|
2237
|
+
function buildBlock(block) {
|
|
2238
|
+
if (!isBlock(block)) throw new Error("each block must be an object with a string \"type\"");
|
|
2239
|
+
const prose = buildProse(block);
|
|
2240
|
+
if (prose) return prose;
|
|
2241
|
+
if (isCardType(block.type)) {
|
|
2242
|
+
const { type, ...fields } = block;
|
|
2243
|
+
return buildCardNode(type, fields);
|
|
2244
|
+
}
|
|
2245
|
+
throw new Error(`unknown block type "${block.type}". valid types: ${[...PROSE_TYPES, ...Object.keys(CARDS)].join(", ")}`);
|
|
2246
|
+
}
|
|
2247
|
+
const PROSE_TYPES = [
|
|
2248
|
+
"paragraph",
|
|
2249
|
+
"heading",
|
|
2250
|
+
"list",
|
|
2251
|
+
"quote",
|
|
2252
|
+
"aside"
|
|
2253
|
+
];
|
|
2254
|
+
//#endregion
|
|
2255
|
+
//#region src/koenig/compose.ts
|
|
2256
|
+
var ComposeError = class extends Error {
|
|
2257
|
+
constructor(issues) {
|
|
2258
|
+
const detail = issues.map((i) => `[#${i.index} ${i.type}] ${i.message}`).join("; ");
|
|
2259
|
+
super(`composition failed (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${detail}`);
|
|
2260
|
+
this.issues = issues;
|
|
2261
|
+
this.name = "ComposeError";
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
function composeRoot(blocks) {
|
|
2265
|
+
if (!Array.isArray(blocks) || blocks.length === 0) throw new ComposeError([{
|
|
2266
|
+
index: -1,
|
|
2267
|
+
type: "(none)",
|
|
2268
|
+
message: "blocks must be a non-empty array"
|
|
2269
|
+
}]);
|
|
2270
|
+
const children = [];
|
|
2271
|
+
const issues = [];
|
|
2272
|
+
blocks.forEach((block, index) => {
|
|
2273
|
+
try {
|
|
2274
|
+
children.push(buildBlock(block));
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
issues.push({
|
|
2277
|
+
index,
|
|
2278
|
+
type: isRecord(block) && typeof block.type === "string" ? block.type : "(invalid)",
|
|
2279
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
if (issues.length > 0) throw new ComposeError(issues);
|
|
2284
|
+
return { root: {
|
|
2285
|
+
type: "root",
|
|
2286
|
+
version: 1,
|
|
2287
|
+
direction: "ltr",
|
|
2288
|
+
format: "",
|
|
2289
|
+
indent: 0,
|
|
2290
|
+
children
|
|
2291
|
+
} };
|
|
2292
|
+
}
|
|
2293
|
+
function compose(blocks) {
|
|
2294
|
+
return JSON.stringify(composeRoot(blocks));
|
|
2295
|
+
}
|
|
2296
|
+
//#endregion
|
|
2297
|
+
//#region src/koenig/help.ts
|
|
2298
|
+
const PROSE = [
|
|
2299
|
+
{
|
|
2300
|
+
type: "paragraph",
|
|
2301
|
+
description: "A text paragraph. `text` supports inline **bold**, _italic_, `code`, [links](url).",
|
|
2302
|
+
example: {
|
|
2303
|
+
type: "paragraph",
|
|
2304
|
+
text: "Some **bold** and a [link](https://x.com)."
|
|
2305
|
+
}
|
|
2306
|
+
},
|
|
2307
|
+
{
|
|
2308
|
+
type: "heading",
|
|
2309
|
+
description: "A heading. `level` 1–6 (default 2). `text` supports inline markdown.",
|
|
2310
|
+
example: {
|
|
2311
|
+
type: "heading",
|
|
2312
|
+
level: 2,
|
|
2313
|
+
text: "Section title"
|
|
2314
|
+
}
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
type: "list",
|
|
2318
|
+
description: "A bullet or numbered list. `style`: \"bullet\" (default) or \"number\". `items` is a string array (inline markdown supported).",
|
|
2319
|
+
example: {
|
|
2320
|
+
type: "list",
|
|
2321
|
+
style: "bullet",
|
|
2322
|
+
items: ["First", "Second"]
|
|
2323
|
+
}
|
|
2324
|
+
},
|
|
2325
|
+
{
|
|
2326
|
+
type: "quote",
|
|
2327
|
+
description: "A blockquote. `text` supports inline markdown.",
|
|
2328
|
+
example: {
|
|
2329
|
+
type: "quote",
|
|
2330
|
+
text: "A memorable quote."
|
|
2331
|
+
}
|
|
2332
|
+
},
|
|
2333
|
+
{
|
|
2334
|
+
type: "aside",
|
|
2335
|
+
description: "A pull-quote / aside.",
|
|
2336
|
+
example: {
|
|
2337
|
+
type: "aside",
|
|
2338
|
+
text: "An aside."
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
];
|
|
2342
|
+
function blockHelp(blockType) {
|
|
2343
|
+
if (blockType) {
|
|
2344
|
+
const prose = PROSE.find((p) => p.type === blockType);
|
|
2345
|
+
if (prose) return [
|
|
2346
|
+
`# block: ${prose.type}`,
|
|
2347
|
+
"",
|
|
2348
|
+
prose.description,
|
|
2349
|
+
"",
|
|
2350
|
+
"```json",
|
|
2351
|
+
JSON.stringify(prose.example, null, 2),
|
|
2352
|
+
"```"
|
|
2353
|
+
].join("\n");
|
|
2354
|
+
const card = CARDS[blockType];
|
|
2355
|
+
if (!card) return `Unknown block type "${blockType}". Run koenig_help with no argument to list all block types.`;
|
|
2356
|
+
const example = {
|
|
2357
|
+
type: blockType,
|
|
2358
|
+
...card.example
|
|
2359
|
+
};
|
|
2360
|
+
const lines = [
|
|
2361
|
+
`# block: ${blockType}`,
|
|
2362
|
+
"",
|
|
2363
|
+
`${card.description}`,
|
|
2364
|
+
"",
|
|
2365
|
+
`- Lexical node: \`${card.nodeType}\` (version ${card.version})`,
|
|
2366
|
+
card.required.length ? `- Required fields: ${card.required.map((r) => `\`${r}\``).join(", ")}` : "- Required fields: none"
|
|
2367
|
+
];
|
|
2368
|
+
if (Object.keys(card.aliases).length) lines.push(`- Aliases: ${Object.entries(card.aliases).map(([f, t]) => `\`${f}\`→\`${t}\``).join(", ")}`);
|
|
2369
|
+
lines.push("", "```json", JSON.stringify(example, null, 2), "```");
|
|
2370
|
+
return lines.join("\n");
|
|
2371
|
+
}
|
|
2372
|
+
const lines = [
|
|
2373
|
+
"# Koenig block types",
|
|
2374
|
+
"",
|
|
2375
|
+
"Compose posts from these blocks instead of raw HTML — they produce clean, natively-editable Ghost content.",
|
|
2376
|
+
"",
|
|
2377
|
+
"## Prose (native, inline markdown in `text`)"
|
|
2378
|
+
];
|
|
2379
|
+
for (const p of PROSE) lines.push(`- **${p.type}** — ${p.description}`);
|
|
2380
|
+
const byGroup = {};
|
|
2381
|
+
for (const [type, def] of Object.entries(CARDS)) (byGroup[def.group] ??= []).push(`- **${type}** — ${def.description}`);
|
|
2382
|
+
for (const [group, entries] of Object.entries(byGroup)) {
|
|
2383
|
+
lines.push("", `## ${group}`);
|
|
2384
|
+
lines.push(...entries);
|
|
2385
|
+
}
|
|
2386
|
+
lines.push("", "Use `koenig_help` with a `block` name for fields + a JSON example. Then call `compose_post` (creates the post) or `compose_lexical` (returns the lexical string).");
|
|
2387
|
+
return lines.join("\n");
|
|
2388
|
+
}
|
|
2389
|
+
//#endregion
|
|
2390
|
+
//#region src/tools/blocks-source.ts
|
|
2391
|
+
function extractBlocks(parsed) {
|
|
2392
|
+
if (Array.isArray(parsed)) return parsed;
|
|
2393
|
+
if (isRecord(parsed) && Array.isArray(parsed.blocks)) return parsed.blocks;
|
|
2394
|
+
return null;
|
|
2395
|
+
}
|
|
2396
|
+
function resolveBlocks(src) {
|
|
2397
|
+
const hasFile = typeof src.blockFile === "string" && src.blockFile.trim() !== "";
|
|
2398
|
+
if (Array.isArray(src.blocks)) {
|
|
2399
|
+
if (hasFile) throw new Error("provide either \"blocks\" or \"blockFile\", not both");
|
|
2400
|
+
return src.blocks;
|
|
2401
|
+
}
|
|
2402
|
+
if (!hasFile) throw new Error("provide \"blocks\" (inline array) or \"blockFile\" (absolute path to a JSON file of blocks)");
|
|
2403
|
+
const file = src.blockFile;
|
|
2404
|
+
if (typeof file !== "string") throw new Error("blockFile must be a string path");
|
|
2405
|
+
if (!isAbsolute(file)) throw new Error(`blockFile must be an absolute path (got "${file}"). Write the JSON to an absolute path, e.g. under your tmp/ directory.`);
|
|
2406
|
+
let raw;
|
|
2407
|
+
try {
|
|
2408
|
+
raw = readFileSync(file, "utf8");
|
|
2409
|
+
} catch {
|
|
2410
|
+
throw new Error(`could not read blockFile: ${file}`);
|
|
2411
|
+
}
|
|
2412
|
+
let parsed;
|
|
2413
|
+
try {
|
|
2414
|
+
parsed = JSON.parse(raw);
|
|
2415
|
+
} catch {
|
|
2416
|
+
throw new Error(`blockFile is not valid JSON: ${file}`);
|
|
2417
|
+
}
|
|
2418
|
+
const blocks = extractBlocks(parsed);
|
|
2419
|
+
if (!blocks) throw new Error(`blockFile must contain a JSON array of blocks, or { "blocks": [...] }: ${file}`);
|
|
2420
|
+
return blocks;
|
|
2421
|
+
}
|
|
2422
|
+
//#endregion
|
|
2423
|
+
//#region src/tools/compose-post.ts
|
|
2424
|
+
const blockSchema = z.object({ type: z.string().describe("Block type: paragraph, heading, list, quote, aside, image, gallery, video, audio, file, bookmark, embed, html, markdown, codeblock, callout, toggle, button, header, cta, signup, product, divider, paywall, email, email-cta. Run koenig_help for fields.") }).passthrough();
|
|
2425
|
+
const composeFields = {
|
|
2426
|
+
blocks: z.array(blockSchema).optional().describe("Ordered content blocks (inline). Prefer native blocks (paragraph/heading/list/quote) and cards over raw html. Prose `text` supports inline **bold**, _italic_, `code`, [links](url). Use koenig_help to discover block fields. For long posts, write the blocks to a JSON file and pass `blockFile` instead."),
|
|
2427
|
+
blockFile: z.string().optional().describe("Absolute path to a local JSON file containing the blocks — either a bare array `[...]` or `{ \"blocks\": [...] }`. Use this for long posts: compose/edit the file (validating with compose_lexical), then pass the path instead of re-sending the whole array. Provide exactly one of `blocks` or `blockFile`. Writing to an absolute path under tmp/ is recommended."),
|
|
2428
|
+
title: z.string().optional().describe("Post title (required when creating a new post)"),
|
|
2429
|
+
id: z.string().optional().describe("Post ID to update. Omit to create a new post."),
|
|
2430
|
+
updated_at: z.string().optional().describe("Required when updating (id set): the post's current updated_at, for collision detection. Get it via posts.read."),
|
|
2431
|
+
status: z.enum([
|
|
2432
|
+
"published",
|
|
2433
|
+
"draft",
|
|
2434
|
+
"scheduled"
|
|
2435
|
+
]).optional().describe("Post status (default draft)"),
|
|
2436
|
+
tags: z.array(z.union([z.object({ id: z.string() }), z.object({ name: z.string() })])).optional().describe("Tags to assign (by id or name)"),
|
|
2437
|
+
feature_image: z.string().optional().describe("Feature image URL"),
|
|
2438
|
+
excerpt: z.string().optional().describe("Custom excerpt (maps to custom_excerpt)"),
|
|
2439
|
+
slug: z.string().optional().describe("Custom URL slug"),
|
|
2440
|
+
visibility: z.string().optional().describe("public, members, paid, or tiers")
|
|
2441
|
+
};
|
|
2442
|
+
const composePostSchema = z.object(composeFields);
|
|
2443
|
+
async function handleComposePost(input, mode) {
|
|
2444
|
+
let lexical;
|
|
2445
|
+
try {
|
|
2446
|
+
lexical = compose(resolveBlocks({
|
|
2447
|
+
blocks: input.blocks,
|
|
2448
|
+
blockFile: input.blockFile
|
|
2449
|
+
}));
|
|
2450
|
+
} catch (error) {
|
|
2451
|
+
if (error instanceof ComposeError) return JSON.stringify({
|
|
2452
|
+
error: "composition failed",
|
|
2453
|
+
issues: error.issues
|
|
2454
|
+
});
|
|
2455
|
+
return JSON.stringify({
|
|
2456
|
+
error: "invalid blocks input",
|
|
2457
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
const { blocks: _blocks, blockFile: _blockFile, id, excerpt, ...rest } = input;
|
|
2461
|
+
const payload = {
|
|
2462
|
+
...rest,
|
|
2463
|
+
lexical
|
|
2464
|
+
};
|
|
2465
|
+
if (excerpt !== void 0) payload.custom_excerpt = excerpt;
|
|
2466
|
+
if (id) return handleUseGhostApi({
|
|
2467
|
+
api: "admin",
|
|
2468
|
+
action: "posts.edit",
|
|
2469
|
+
payload: {
|
|
2470
|
+
id,
|
|
2471
|
+
...payload
|
|
2472
|
+
}
|
|
2473
|
+
}, mode);
|
|
2474
|
+
return handleUseGhostApi({
|
|
2475
|
+
api: "admin",
|
|
2476
|
+
action: "posts.add",
|
|
2477
|
+
payload
|
|
2478
|
+
}, mode);
|
|
2479
|
+
}
|
|
2480
|
+
//#endregion
|
|
2481
|
+
//#region src/tools/compose-lexical.ts
|
|
2482
|
+
const composeLexicalSchema = z.object({
|
|
2483
|
+
blocks: z.array(z.object({ type: z.string() }).passthrough()).optional().describe("Ordered content blocks (same shape as compose_post), inline."),
|
|
2484
|
+
blockFile: z.string().optional().describe("Absolute path to a JSON file of blocks. Use to validate a file you are building before calling compose_post. Provide exactly one of `blocks` or `blockFile`.")
|
|
2485
|
+
});
|
|
2486
|
+
function handleComposeLexical(input) {
|
|
2487
|
+
try {
|
|
2488
|
+
const lexical = compose(resolveBlocks({
|
|
2489
|
+
blocks: input.blocks,
|
|
2490
|
+
blockFile: input.blockFile
|
|
2491
|
+
}));
|
|
2492
|
+
return JSON.stringify({ lexical });
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
if (error instanceof ComposeError) return JSON.stringify({
|
|
2495
|
+
error: "composition failed",
|
|
2496
|
+
issues: error.issues
|
|
2497
|
+
});
|
|
2498
|
+
return JSON.stringify({
|
|
2499
|
+
error: "invalid blocks input",
|
|
2500
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
//#endregion
|
|
2505
|
+
//#region src/tools/koenig-help.ts
|
|
2506
|
+
const koenigHelpSchema = z.object({ block: z.string().optional().describe("A block type (e.g. \"callout\", \"image\") for its fields + a JSON example. Omit to list all block types.") });
|
|
2507
|
+
function handleKoenigHelp(input) {
|
|
2508
|
+
return blockHelp(input.block);
|
|
2509
|
+
}
|
|
2510
|
+
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
2511
|
+
//#endregion
|
|
1497
2512
|
//#region src/index.ts
|
|
1498
2513
|
const GHOST_API_MODE = process.env.GHOST_API_MODE || "admin";
|
|
1499
2514
|
const server = new McpServer({
|
|
1500
2515
|
name: "ghost-mcp",
|
|
1501
|
-
version:
|
|
2516
|
+
version: VERSION
|
|
1502
2517
|
});
|
|
1503
2518
|
server.tool("use_ghost_api", "Execute a Ghost API action (browse, read, add, edit, delete posts, pages, tags, members, newsletters, and more)", useGhostApiSchema.shape, async ({ api, action, payload }) => {
|
|
1504
2519
|
return { content: [{
|
|
@@ -1529,6 +2544,36 @@ server.tool("ghost_docs", "Search Ghost CMS documentation — fetch full docs, s
|
|
|
1529
2544
|
})
|
|
1530
2545
|
}] };
|
|
1531
2546
|
});
|
|
2547
|
+
server.tool("compose_post", "Create or update a Ghost post from structured Koenig content blocks (paragraphs, headings, lists, callouts, images, buttons, etc.). PREFER THIS over use_ghost_api with raw html/lexical — it produces clean, natively-editable posts. Pass blocks inline for short posts, or write them to a JSON file and pass `blockFile` (absolute path) for long posts. Omit `id` to create, set `id`+`updated_at` to update. Call koenig_help to discover block types and fields.", composePostSchema.shape, async (input) => {
|
|
2548
|
+
return { content: [{
|
|
2549
|
+
type: "text",
|
|
2550
|
+
text: await handleComposePost(input, GHOST_API_MODE)
|
|
2551
|
+
}] };
|
|
2552
|
+
});
|
|
2553
|
+
server.tool("compose_lexical", "Compile Koenig content blocks into a Lexical JSON string without creating a post (for preview/inspection). Same block shape as compose_post.", composeLexicalSchema.shape, async (input) => {
|
|
2554
|
+
return { content: [{
|
|
2555
|
+
type: "text",
|
|
2556
|
+
text: handleComposeLexical(input)
|
|
2557
|
+
}] };
|
|
2558
|
+
});
|
|
2559
|
+
server.tool("koenig_help", "List Koenig content block types, or get the fields and a JSON example for one block. Use before compose_post to build clean, editable posts instead of raw HTML.", koenigHelpSchema.shape, async ({ block }) => {
|
|
2560
|
+
return { content: [{
|
|
2561
|
+
type: "text",
|
|
2562
|
+
text: handleKoenigHelp({ block })
|
|
2563
|
+
}] };
|
|
2564
|
+
});
|
|
2565
|
+
server.registerPrompt("compose_ghost_post", { description: "Guidance for composing clean, editable Ghost posts from Koenig blocks instead of raw HTML." }, () => ({ messages: [{
|
|
2566
|
+
role: "assistant",
|
|
2567
|
+
content: {
|
|
2568
|
+
type: "text",
|
|
2569
|
+
text: [
|
|
2570
|
+
"When writing Ghost post content, do NOT push raw HTML into the API. Compose the post from structured Koenig blocks via the `compose_post` tool — this yields clean, natively-editable posts.",
|
|
2571
|
+
"Workflow: (1) call `koenig_help` to see block types; (2) build a `blocks` array — prose as paragraph/heading/list/quote blocks (their `text` supports inline **bold**, _italic_, `code`, [links](url)), and rich features as cards (callout, image, button, bookmark, embed, codeblock, toggle, gallery, etc.); (3) call `compose_post`. Use the `html` block ONLY when no native block fits.",
|
|
2572
|
+
"For a short post, pass `blocks` inline. For a long post, write the blocks JSON to an absolute path (e.g. under tmp/), optionally validate it with `compose_lexical` (`blockFile`), then call `compose_post` with `blockFile` — this avoids re-sending the whole array on each edit.",
|
|
2573
|
+
blockHelp()
|
|
2574
|
+
].join("\n\n")
|
|
2575
|
+
}
|
|
2576
|
+
}] }));
|
|
1532
2577
|
async function main() {
|
|
1533
2578
|
const transport = new StdioServerTransport();
|
|
1534
2579
|
await server.connect(transport);
|