@damusix/ghost-mcp 0.2.1 → 0.4.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/README.md +61 -1
- package/bin/ghost-keys.sh +58 -0
- package/dist/index.mjs +1104 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { attempt } from "@logosdx/utils";
|
|
|
6
6
|
import { FetchEngine, config, get } from "@logosdx/fetch";
|
|
7
7
|
import mimeDb from "mime-db";
|
|
8
8
|
import jwt from "jsonwebtoken";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { isAbsolute } from "node:path";
|
|
9
11
|
//#region src/ghost-client.ts
|
|
10
12
|
const GHOST_URL = process.env.GHOST_URL || "";
|
|
11
13
|
const GHOST_ADMIN_API_KEY = process.env.GHOST_ADMIN_API_KEY || "";
|
|
@@ -163,7 +165,10 @@ const postWriteFields = {
|
|
|
163
165
|
feature_image_caption: z.string().optional().describe("Feature image caption (HTML)"),
|
|
164
166
|
custom_template: z.string().optional().describe("Custom template for the post"),
|
|
165
167
|
newsletter: z.object({ id: z.string() }).optional().describe("Newsletter to send the post to"),
|
|
166
|
-
email_subject: z.string().optional().describe("Custom email subject when sending as newsletter")
|
|
168
|
+
email_subject: z.string().optional().describe("Custom email subject when sending as newsletter"),
|
|
169
|
+
slug: z.string().optional().describe("Custom URL slug for the post"),
|
|
170
|
+
email_only: z.boolean().optional().describe("Whether the post is email-only (not published to web)"),
|
|
171
|
+
email_segment: z.string().optional().describe("NQL filter for email recipient segment (e.g. 'status:free', 'status:-free')")
|
|
167
172
|
};
|
|
168
173
|
const addSchema$7 = z.object({
|
|
169
174
|
...postWriteFields,
|
|
@@ -310,7 +315,8 @@ const pageWriteFields = {
|
|
|
310
315
|
feature_image: z.string().optional().describe("Feature image URL"),
|
|
311
316
|
feature_image_alt: z.string().optional().describe("Feature image alt text"),
|
|
312
317
|
feature_image_caption: z.string().optional().describe("Feature image caption (HTML)"),
|
|
313
|
-
custom_template: z.string().optional().describe("Custom template")
|
|
318
|
+
custom_template: z.string().optional().describe("Custom template"),
|
|
319
|
+
slug: z.string().optional().describe("Custom URL slug for the page")
|
|
314
320
|
};
|
|
315
321
|
const addSchema$6 = z.object({
|
|
316
322
|
...pageWriteFields,
|
|
@@ -504,7 +510,8 @@ const tierWriteFields = {
|
|
|
504
510
|
monthly_price: z.number().optional().describe("Monthly price in smallest currency unit (e.g. cents)"),
|
|
505
511
|
yearly_price: z.number().optional().describe("Yearly price in smallest currency unit"),
|
|
506
512
|
currency: z.string().optional().describe("Three-letter ISO currency code (e.g. \"usd\")"),
|
|
507
|
-
benefits: z.array(z.string()).optional().describe("List of benefits for this tier")
|
|
513
|
+
benefits: z.array(z.string()).optional().describe("List of benefits for this tier"),
|
|
514
|
+
trial_days: z.number().optional().describe("Number of free trial days for new subscribers")
|
|
508
515
|
};
|
|
509
516
|
const addSchema$4 = z.object({
|
|
510
517
|
...tierWriteFields,
|
|
@@ -564,7 +571,9 @@ const adminTierActions = [
|
|
|
564
571
|
const browseParams$8 = z.object({
|
|
565
572
|
limit: z.union([z.number(), z.literal("all")]).optional().describe("Number of results per page"),
|
|
566
573
|
page: z.number().optional().describe("Page number"),
|
|
567
|
-
order: z.string().optional().describe("Sort order")
|
|
574
|
+
order: z.string().optional().describe("Sort order"),
|
|
575
|
+
filter: z.string().optional().describe("NQL filter expression"),
|
|
576
|
+
include: z.string().optional().describe("Related data to include (e.g. 'count.posts,count.members,count.active_members')")
|
|
568
577
|
});
|
|
569
578
|
const readParams$9 = z.object({ id: z.string().describe("Newsletter ID") });
|
|
570
579
|
const newsletterWriteFields = {
|
|
@@ -587,7 +596,39 @@ const newsletterWriteFields = {
|
|
|
587
596
|
show_feature_image: z.boolean().optional().describe("Show feature image in emails"),
|
|
588
597
|
body_font_category: z.enum(["serif", "sans_serif"]).optional().describe("Body font category"),
|
|
589
598
|
footer_content: z.string().optional().describe("Footer content (HTML)"),
|
|
590
|
-
show_badge: z.boolean().optional().describe("Show Ghost badge in footer")
|
|
599
|
+
show_badge: z.boolean().optional().describe("Show Ghost badge in footer"),
|
|
600
|
+
feedback_enabled: z.boolean().optional().describe("Enable email feedback/reactions"),
|
|
601
|
+
show_excerpt: z.boolean().optional().describe("Show post excerpt in emails"),
|
|
602
|
+
show_post_title_section: z.boolean().optional().describe("Show post title section"),
|
|
603
|
+
show_comment_cta: z.boolean().optional().describe("Show comment call-to-action"),
|
|
604
|
+
show_subscription_details: z.boolean().optional().describe("Show subscription details"),
|
|
605
|
+
show_latest_posts: z.boolean().optional().describe("Show latest posts section"),
|
|
606
|
+
show_share_button: z.boolean().optional().describe("Show share button"),
|
|
607
|
+
background_color: z.string().optional().describe("Background color (e.g. 'light', 'dark', or hex)"),
|
|
608
|
+
post_title_color: z.string().nullable().optional().describe("Post title color (hex)"),
|
|
609
|
+
button_corners: z.enum([
|
|
610
|
+
"square",
|
|
611
|
+
"rounded",
|
|
612
|
+
"pill"
|
|
613
|
+
]).optional().describe("Button corner style"),
|
|
614
|
+
button_style: z.enum(["fill", "outline"]).optional().describe("Button style"),
|
|
615
|
+
title_font_weight: z.enum([
|
|
616
|
+
"normal",
|
|
617
|
+
"medium",
|
|
618
|
+
"semibold",
|
|
619
|
+
"bold"
|
|
620
|
+
]).optional().describe("Title font weight"),
|
|
621
|
+
link_style: z.enum([
|
|
622
|
+
"underline",
|
|
623
|
+
"regular",
|
|
624
|
+
"bold"
|
|
625
|
+
]).optional().describe("Link style in emails"),
|
|
626
|
+
image_corners: z.enum(["square", "rounded"]).optional().describe("Image corner style"),
|
|
627
|
+
header_background_color: z.string().optional().describe("Header background color (e.g. 'transparent', hex, 'accent')"),
|
|
628
|
+
section_title_color: z.string().nullable().optional().describe("Section title color (hex)"),
|
|
629
|
+
divider_color: z.string().nullable().optional().describe("Divider color (hex)"),
|
|
630
|
+
button_color: z.string().optional().describe("Button color (e.g. 'accent', hex)"),
|
|
631
|
+
link_color: z.string().optional().describe("Link color (e.g. 'accent', hex)")
|
|
591
632
|
};
|
|
592
633
|
const addSchema$3 = z.object({
|
|
593
634
|
...newsletterWriteFields,
|
|
@@ -657,14 +698,12 @@ const addSchema$2 = z.object({
|
|
|
657
698
|
duration_in_months: z.number().optional().describe("Number of months for repeating duration"),
|
|
658
699
|
currency_restriction: z.boolean().optional().describe("Whether the offer is restricted to a specific currency"),
|
|
659
700
|
currency: z.string().optional().describe("Three-letter ISO currency code (required for fixed type)"),
|
|
660
|
-
tier: z.object({ id: z.string().describe("Tier ID") }).describe("Tier this offer applies to (required)")
|
|
701
|
+
tier: z.object({ id: z.string().describe("Tier ID") }).describe("Tier this offer applies to (required)"),
|
|
702
|
+
redemption_type: z.enum(["signup", "retention"]).optional().describe("Whether the offer is for new signups or existing member retention")
|
|
661
703
|
});
|
|
662
704
|
const editSchema$2 = z.object({
|
|
663
705
|
id: z.string().describe("Offer ID (required)"),
|
|
664
|
-
name: z.string().optional().describe("Internal name")
|
|
665
|
-
code: z.string().optional().describe("Unique code for the offer URL"),
|
|
666
|
-
display_title: z.string().optional().describe("Title shown to users"),
|
|
667
|
-
display_description: z.string().optional().describe("Description shown to users")
|
|
706
|
+
name: z.string().optional().describe("Internal name")
|
|
668
707
|
});
|
|
669
708
|
const adminOfferActions = [
|
|
670
709
|
{
|
|
@@ -1174,6 +1213,35 @@ function listActions(api) {
|
|
|
1174
1213
|
if (api) return actions.filter((a) => a.api === api);
|
|
1175
1214
|
return actions;
|
|
1176
1215
|
}
|
|
1216
|
+
function unwrapZodType(field) {
|
|
1217
|
+
if (field instanceof z.ZodOptional || field instanceof z.ZodNullable) return unwrapZodType(field._def.innerType);
|
|
1218
|
+
if (field instanceof z.ZodDefault) return unwrapZodType(field._def.innerType);
|
|
1219
|
+
return field;
|
|
1220
|
+
}
|
|
1221
|
+
function describeZodType(field) {
|
|
1222
|
+
const inner = unwrapZodType(field);
|
|
1223
|
+
if (inner instanceof z.ZodEnum) return `values: ${inner._def.values.map((v) => `\`${v}\``).join(", ")}`;
|
|
1224
|
+
if (inner instanceof z.ZodObject) {
|
|
1225
|
+
const shape = inner.shape;
|
|
1226
|
+
return `object: { ${Object.entries(shape).map(([k, v]) => {
|
|
1227
|
+
const t = describeZodType(v);
|
|
1228
|
+
return t ? `${k}${t.startsWith("values:") ? ` (${t})` : `: ${t}`}` : k;
|
|
1229
|
+
}).join(", ")} }`;
|
|
1230
|
+
}
|
|
1231
|
+
if (inner instanceof z.ZodArray) {
|
|
1232
|
+
const itemType = describeZodType(inner._def.type);
|
|
1233
|
+
return itemType ? `array of [${itemType}]` : "array";
|
|
1234
|
+
}
|
|
1235
|
+
if (inner instanceof z.ZodUnion) {
|
|
1236
|
+
const parts = inner._def.options.map((o) => describeZodType(o)).filter(Boolean);
|
|
1237
|
+
return parts.length > 0 ? parts.join(" | ") : "";
|
|
1238
|
+
}
|
|
1239
|
+
if (inner instanceof z.ZodLiteral) return `\`${String(inner._def.value)}\``;
|
|
1240
|
+
if (inner instanceof z.ZodString) return "string";
|
|
1241
|
+
if (inner instanceof z.ZodNumber) return "number";
|
|
1242
|
+
if (inner instanceof z.ZodBoolean) return "boolean";
|
|
1243
|
+
return "";
|
|
1244
|
+
}
|
|
1177
1245
|
function getActionHelp(name, api) {
|
|
1178
1246
|
const action = getAction(name, api);
|
|
1179
1247
|
if (!action) return;
|
|
@@ -1194,7 +1262,9 @@ function getActionHelp(name, api) {
|
|
|
1194
1262
|
for (const [key, field] of Object.entries(shape)) {
|
|
1195
1263
|
const isOptional = field.isOptional();
|
|
1196
1264
|
const desc = field.description || "";
|
|
1197
|
-
|
|
1265
|
+
const typeInfo = describeZodType(field);
|
|
1266
|
+
const suffix = typeInfo ? ` — ${typeInfo}` : "";
|
|
1267
|
+
lines.push(`- **${key}**${isOptional ? " (optional)" : " (required)"}: ${desc}${suffix}`);
|
|
1198
1268
|
}
|
|
1199
1269
|
lines.push("");
|
|
1200
1270
|
}
|
|
@@ -1380,20 +1450,11 @@ const ghostDocsSchema = z.object({
|
|
|
1380
1450
|
search: z.string().optional().describe("Case-insensitive substring search across the documentation"),
|
|
1381
1451
|
regex: z.string().optional().describe("Regex pattern string to match (e.g. \"/pattern/i\")")
|
|
1382
1452
|
});
|
|
1383
|
-
async function fetchDocs() {
|
|
1384
|
-
const [response, err] = await attempt(async () => docsApi.get("/llms.txt"));
|
|
1385
|
-
if (err) throw new Error(`Failed to fetch Ghost docs: ${err.message}`);
|
|
1386
|
-
return response.data;
|
|
1387
|
-
}
|
|
1388
1453
|
async function handleGhostDocs(input) {
|
|
1389
1454
|
const { all, search, regex } = input;
|
|
1390
1455
|
if (!all && !search && !regex) return "Provide one of: `all: true` to get full docs, `search` for text search, or `regex` for pattern matching.";
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
content = await fetchDocs();
|
|
1394
|
-
} catch (error) {
|
|
1395
|
-
return `Error: ${error.message}`;
|
|
1396
|
-
}
|
|
1456
|
+
const response = await docsApi.get("/llms.txt");
|
|
1457
|
+
const content = String(response.data);
|
|
1397
1458
|
if (all) return content;
|
|
1398
1459
|
const lines = content.split("\n");
|
|
1399
1460
|
const matchedLines = [];
|
|
@@ -1426,6 +1487,997 @@ async function handleGhostDocs(input) {
|
|
|
1426
1487
|
return matchedLines.join("\n");
|
|
1427
1488
|
}
|
|
1428
1489
|
//#endregion
|
|
1490
|
+
//#region src/koenig/inline.ts
|
|
1491
|
+
const FORMAT = {
|
|
1492
|
+
bold: 1,
|
|
1493
|
+
italic: 2,
|
|
1494
|
+
strikethrough: 4,
|
|
1495
|
+
underline: 8,
|
|
1496
|
+
code: 16
|
|
1497
|
+
};
|
|
1498
|
+
function textNode(text, format = 0) {
|
|
1499
|
+
return {
|
|
1500
|
+
type: "extended-text",
|
|
1501
|
+
version: 1,
|
|
1502
|
+
text,
|
|
1503
|
+
format,
|
|
1504
|
+
mode: "normal",
|
|
1505
|
+
style: "",
|
|
1506
|
+
detail: 0
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
function linkNode(url, label) {
|
|
1510
|
+
return {
|
|
1511
|
+
type: "link",
|
|
1512
|
+
version: 1,
|
|
1513
|
+
direction: "ltr",
|
|
1514
|
+
format: "",
|
|
1515
|
+
indent: 0,
|
|
1516
|
+
rel: null,
|
|
1517
|
+
target: null,
|
|
1518
|
+
title: null,
|
|
1519
|
+
url,
|
|
1520
|
+
children: [textNode(label)]
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
const TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*|(?<![\w])_([^_]+)_(?![\w])/g;
|
|
1524
|
+
function parseInline(input) {
|
|
1525
|
+
const nodes = [];
|
|
1526
|
+
let last = 0;
|
|
1527
|
+
let m;
|
|
1528
|
+
TOKEN.lastIndex = 0;
|
|
1529
|
+
while ((m = TOKEN.exec(input)) !== null) {
|
|
1530
|
+
if (m.index > last) nodes.push(textNode(input.slice(last, m.index)));
|
|
1531
|
+
if (m[1] !== void 0) nodes.push(linkNode(m[2], m[1]));
|
|
1532
|
+
else if (m[3] !== void 0) nodes.push(textNode(m[3], FORMAT.code));
|
|
1533
|
+
else if (m[4] !== void 0) nodes.push(textNode(m[4], FORMAT.bold));
|
|
1534
|
+
else if (m[5] !== void 0) nodes.push(textNode(m[5], FORMAT.italic));
|
|
1535
|
+
else if (m[6] !== void 0) nodes.push(textNode(m[6], FORMAT.italic));
|
|
1536
|
+
last = m.index + m[0].length;
|
|
1537
|
+
}
|
|
1538
|
+
if (last < input.length) nodes.push(textNode(input.slice(last)));
|
|
1539
|
+
if (nodes.length === 0) nodes.push(textNode(input));
|
|
1540
|
+
return nodes;
|
|
1541
|
+
}
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/koenig/node-specs.ts
|
|
1544
|
+
const NODE_SPECS = {
|
|
1545
|
+
audio: {
|
|
1546
|
+
nodeType: "audio",
|
|
1547
|
+
hasVisibility: false,
|
|
1548
|
+
fields: {
|
|
1549
|
+
duration: 0,
|
|
1550
|
+
mimeType: "",
|
|
1551
|
+
src: "",
|
|
1552
|
+
title: "",
|
|
1553
|
+
thumbnailSrc: ""
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
bookmark: {
|
|
1557
|
+
nodeType: "bookmark",
|
|
1558
|
+
hasVisibility: false,
|
|
1559
|
+
fields: {
|
|
1560
|
+
title: "",
|
|
1561
|
+
description: "",
|
|
1562
|
+
url: "",
|
|
1563
|
+
caption: "",
|
|
1564
|
+
author: "",
|
|
1565
|
+
publisher: ""
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
button: {
|
|
1569
|
+
nodeType: "button",
|
|
1570
|
+
hasVisibility: false,
|
|
1571
|
+
fields: {
|
|
1572
|
+
buttonText: "",
|
|
1573
|
+
alignment: "center",
|
|
1574
|
+
buttonUrl: ""
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
"call-to-action": {
|
|
1578
|
+
nodeType: "call-to-action",
|
|
1579
|
+
hasVisibility: true,
|
|
1580
|
+
fields: {
|
|
1581
|
+
layout: "minimal",
|
|
1582
|
+
alignment: "left",
|
|
1583
|
+
textValue: "",
|
|
1584
|
+
showButton: true,
|
|
1585
|
+
showDividers: true,
|
|
1586
|
+
buttonText: "Learn more",
|
|
1587
|
+
buttonUrl: "",
|
|
1588
|
+
buttonColor: "#000000",
|
|
1589
|
+
buttonTextColor: "#ffffff",
|
|
1590
|
+
hasSponsorLabel: true,
|
|
1591
|
+
sponsorLabel: "<p><span style=\"white-space: pre-wrap;\">SPONSORED</span></p>",
|
|
1592
|
+
backgroundColor: "grey",
|
|
1593
|
+
linkColor: "text",
|
|
1594
|
+
imageUrl: "",
|
|
1595
|
+
imageWidth: null,
|
|
1596
|
+
imageHeight: null
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
callout: {
|
|
1600
|
+
nodeType: "callout",
|
|
1601
|
+
hasVisibility: false,
|
|
1602
|
+
fields: {
|
|
1603
|
+
calloutText: "",
|
|
1604
|
+
calloutEmoji: "💡",
|
|
1605
|
+
backgroundColor: "blue"
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
codeblock: {
|
|
1609
|
+
nodeType: "codeblock",
|
|
1610
|
+
hasVisibility: false,
|
|
1611
|
+
fields: {
|
|
1612
|
+
code: "",
|
|
1613
|
+
language: "",
|
|
1614
|
+
caption: ""
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
email: {
|
|
1618
|
+
nodeType: "email",
|
|
1619
|
+
hasVisibility: false,
|
|
1620
|
+
fields: { html: "" }
|
|
1621
|
+
},
|
|
1622
|
+
"email-cta": {
|
|
1623
|
+
nodeType: "email-cta",
|
|
1624
|
+
hasVisibility: false,
|
|
1625
|
+
fields: {
|
|
1626
|
+
alignment: "left",
|
|
1627
|
+
buttonText: "",
|
|
1628
|
+
buttonUrl: "",
|
|
1629
|
+
html: "",
|
|
1630
|
+
segment: "status:free",
|
|
1631
|
+
showButton: false,
|
|
1632
|
+
showDividers: true
|
|
1633
|
+
}
|
|
1634
|
+
},
|
|
1635
|
+
embed: {
|
|
1636
|
+
nodeType: "embed",
|
|
1637
|
+
hasVisibility: false,
|
|
1638
|
+
fields: {
|
|
1639
|
+
url: "",
|
|
1640
|
+
embedType: "",
|
|
1641
|
+
html: "",
|
|
1642
|
+
caption: ""
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
file: {
|
|
1646
|
+
nodeType: "file",
|
|
1647
|
+
hasVisibility: false,
|
|
1648
|
+
fields: {
|
|
1649
|
+
src: "",
|
|
1650
|
+
fileTitle: "",
|
|
1651
|
+
fileCaption: "",
|
|
1652
|
+
fileName: "",
|
|
1653
|
+
fileSize: 0
|
|
1654
|
+
}
|
|
1655
|
+
},
|
|
1656
|
+
gallery: {
|
|
1657
|
+
nodeType: "gallery",
|
|
1658
|
+
hasVisibility: false,
|
|
1659
|
+
fields: {
|
|
1660
|
+
images: "[]",
|
|
1661
|
+
caption: ""
|
|
1662
|
+
}
|
|
1663
|
+
},
|
|
1664
|
+
header: {
|
|
1665
|
+
nodeType: "header",
|
|
1666
|
+
hasVisibility: false,
|
|
1667
|
+
fields: {
|
|
1668
|
+
size: "small",
|
|
1669
|
+
style: "dark",
|
|
1670
|
+
buttonEnabled: false,
|
|
1671
|
+
buttonUrl: "",
|
|
1672
|
+
buttonText: "",
|
|
1673
|
+
header: "",
|
|
1674
|
+
subheader: "",
|
|
1675
|
+
backgroundImageSrc: "",
|
|
1676
|
+
version: 1,
|
|
1677
|
+
accentColor: "#FF1A75",
|
|
1678
|
+
alignment: "center",
|
|
1679
|
+
backgroundColor: "#000000",
|
|
1680
|
+
backgroundImageWidth: null,
|
|
1681
|
+
backgroundImageHeight: null,
|
|
1682
|
+
backgroundSize: "cover",
|
|
1683
|
+
textColor: "#FFFFFF",
|
|
1684
|
+
buttonColor: "#ffffff",
|
|
1685
|
+
buttonTextColor: "#000000",
|
|
1686
|
+
layout: "full",
|
|
1687
|
+
swapped: false
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
html: {
|
|
1691
|
+
nodeType: "html",
|
|
1692
|
+
hasVisibility: true,
|
|
1693
|
+
fields: { html: "" }
|
|
1694
|
+
},
|
|
1695
|
+
image: {
|
|
1696
|
+
nodeType: "image",
|
|
1697
|
+
hasVisibility: false,
|
|
1698
|
+
fields: {
|
|
1699
|
+
src: "",
|
|
1700
|
+
caption: "",
|
|
1701
|
+
title: "",
|
|
1702
|
+
alt: "",
|
|
1703
|
+
cardWidth: "regular",
|
|
1704
|
+
width: null,
|
|
1705
|
+
height: null,
|
|
1706
|
+
href: ""
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
markdown: {
|
|
1710
|
+
nodeType: "markdown",
|
|
1711
|
+
hasVisibility: false,
|
|
1712
|
+
fields: { markdown: "" }
|
|
1713
|
+
},
|
|
1714
|
+
product: {
|
|
1715
|
+
nodeType: "product",
|
|
1716
|
+
hasVisibility: false,
|
|
1717
|
+
fields: {
|
|
1718
|
+
productImageSrc: "",
|
|
1719
|
+
productImageWidth: null,
|
|
1720
|
+
productImageHeight: null,
|
|
1721
|
+
productTitle: "",
|
|
1722
|
+
productDescription: "",
|
|
1723
|
+
productRatingEnabled: false,
|
|
1724
|
+
productStarRating: 5,
|
|
1725
|
+
productButtonEnabled: false,
|
|
1726
|
+
productButton: "",
|
|
1727
|
+
productUrl: ""
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
signup: {
|
|
1731
|
+
nodeType: "signup",
|
|
1732
|
+
hasVisibility: false,
|
|
1733
|
+
fields: {
|
|
1734
|
+
alignment: "left",
|
|
1735
|
+
backgroundColor: "#F0F0F0",
|
|
1736
|
+
backgroundImageSrc: "",
|
|
1737
|
+
backgroundSize: "cover",
|
|
1738
|
+
textColor: "",
|
|
1739
|
+
buttonColor: "accent",
|
|
1740
|
+
buttonTextColor: "#FFFFFF",
|
|
1741
|
+
buttonText: "Subscribe",
|
|
1742
|
+
disclaimer: "",
|
|
1743
|
+
header: "",
|
|
1744
|
+
layout: "wide",
|
|
1745
|
+
subheader: "",
|
|
1746
|
+
successMessage: "Email sent! Check your inbox to complete your signup.",
|
|
1747
|
+
swapped: false
|
|
1748
|
+
}
|
|
1749
|
+
},
|
|
1750
|
+
toggle: {
|
|
1751
|
+
nodeType: "toggle",
|
|
1752
|
+
hasVisibility: false,
|
|
1753
|
+
fields: {
|
|
1754
|
+
heading: "",
|
|
1755
|
+
content: ""
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
transistor: {
|
|
1759
|
+
nodeType: "transistor",
|
|
1760
|
+
hasVisibility: true,
|
|
1761
|
+
fields: {
|
|
1762
|
+
accentColor: "",
|
|
1763
|
+
backgroundColor: ""
|
|
1764
|
+
}
|
|
1765
|
+
},
|
|
1766
|
+
video: {
|
|
1767
|
+
nodeType: "video",
|
|
1768
|
+
hasVisibility: false,
|
|
1769
|
+
fields: {
|
|
1770
|
+
src: "",
|
|
1771
|
+
caption: "",
|
|
1772
|
+
fileName: "",
|
|
1773
|
+
mimeType: "",
|
|
1774
|
+
width: null,
|
|
1775
|
+
height: null,
|
|
1776
|
+
duration: 0,
|
|
1777
|
+
thumbnailSrc: "",
|
|
1778
|
+
customThumbnailSrc: "",
|
|
1779
|
+
thumbnailWidth: null,
|
|
1780
|
+
thumbnailHeight: null,
|
|
1781
|
+
cardWidth: "regular",
|
|
1782
|
+
loop: false
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
//#endregion
|
|
1787
|
+
//#region src/koenig/util.ts
|
|
1788
|
+
function isRecord(value) {
|
|
1789
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1790
|
+
}
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/koenig/cards.ts
|
|
1793
|
+
function passthrough(def, fields) {
|
|
1794
|
+
const spec = NODE_SPECS[def.nodeType];
|
|
1795
|
+
const validFields = spec ? new Set(Object.keys(spec.fields)) : null;
|
|
1796
|
+
const node = {};
|
|
1797
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
1798
|
+
const target = def.aliases[key] ?? key;
|
|
1799
|
+
if (validFields && !validFields.has(target)) {
|
|
1800
|
+
const allowed = [...Object.keys(def.aliases), ...validFields ?? []].join(", ");
|
|
1801
|
+
throw new Error(`unknown field "${key}" for card "${def.nodeType}". allowed: ${allowed}`);
|
|
1802
|
+
}
|
|
1803
|
+
node[target] = value;
|
|
1804
|
+
}
|
|
1805
|
+
return node;
|
|
1806
|
+
}
|
|
1807
|
+
/** keyed by the friendly block `type` the LLM writes */
|
|
1808
|
+
const CARDS = {
|
|
1809
|
+
image: {
|
|
1810
|
+
nodeType: "image",
|
|
1811
|
+
version: 1,
|
|
1812
|
+
group: "media",
|
|
1813
|
+
description: "An image with optional caption, alt text, and link.",
|
|
1814
|
+
required: ["src"],
|
|
1815
|
+
aliases: {},
|
|
1816
|
+
example: {
|
|
1817
|
+
src: "https://example.com/photo.jpg",
|
|
1818
|
+
alt: "A photo",
|
|
1819
|
+
caption: "My caption"
|
|
1820
|
+
}
|
|
1821
|
+
},
|
|
1822
|
+
gallery: {
|
|
1823
|
+
nodeType: "gallery",
|
|
1824
|
+
version: 1,
|
|
1825
|
+
group: "media",
|
|
1826
|
+
description: "A grid of images. Provide `images` as an array of { src, alt?, width?, height? }.",
|
|
1827
|
+
required: ["images"],
|
|
1828
|
+
aliases: {},
|
|
1829
|
+
example: {
|
|
1830
|
+
images: [{ src: "https://example.com/1.jpg" }, { src: "https://example.com/2.jpg" }],
|
|
1831
|
+
caption: "Trip photos"
|
|
1832
|
+
},
|
|
1833
|
+
build(fields) {
|
|
1834
|
+
const node = { images: (Array.isArray(fields.images) ? fields.images : []).map((img, i) => {
|
|
1835
|
+
const o = isRecord(img) ? img : {};
|
|
1836
|
+
return {
|
|
1837
|
+
fileName: o.fileName ?? `image-${i}.jpg`,
|
|
1838
|
+
row: o.row ?? 0,
|
|
1839
|
+
src: o.src ?? "",
|
|
1840
|
+
width: o.width ?? 0,
|
|
1841
|
+
height: o.height ?? 0,
|
|
1842
|
+
title: o.title ?? "",
|
|
1843
|
+
alt: o.alt ?? ""
|
|
1844
|
+
};
|
|
1845
|
+
}) };
|
|
1846
|
+
if (typeof fields.caption === "string") node.caption = fields.caption;
|
|
1847
|
+
return node;
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
video: {
|
|
1851
|
+
nodeType: "video",
|
|
1852
|
+
version: 1,
|
|
1853
|
+
group: "media",
|
|
1854
|
+
description: "A video file. Needs `src`; `thumbnailSrc` sets the poster.",
|
|
1855
|
+
required: ["src"],
|
|
1856
|
+
aliases: { thumbnail: "thumbnailSrc" },
|
|
1857
|
+
example: {
|
|
1858
|
+
src: "https://example.com/clip.mp4",
|
|
1859
|
+
caption: "A clip",
|
|
1860
|
+
thumbnail: "https://example.com/poster.jpg"
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
audio: {
|
|
1864
|
+
nodeType: "audio",
|
|
1865
|
+
version: 1,
|
|
1866
|
+
group: "media",
|
|
1867
|
+
description: "An audio file with a title.",
|
|
1868
|
+
required: ["src"],
|
|
1869
|
+
aliases: { thumbnail: "thumbnailSrc" },
|
|
1870
|
+
example: {
|
|
1871
|
+
src: "https://example.com/track.mp3",
|
|
1872
|
+
title: "Episode 1",
|
|
1873
|
+
duration: 320
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
file: {
|
|
1877
|
+
nodeType: "file",
|
|
1878
|
+
version: 1,
|
|
1879
|
+
group: "media",
|
|
1880
|
+
description: "A downloadable file card.",
|
|
1881
|
+
required: ["src"],
|
|
1882
|
+
aliases: {
|
|
1883
|
+
title: "fileTitle",
|
|
1884
|
+
name: "fileName",
|
|
1885
|
+
caption: "fileCaption",
|
|
1886
|
+
size: "fileSize"
|
|
1887
|
+
},
|
|
1888
|
+
example: {
|
|
1889
|
+
src: "https://example.com/guide.pdf",
|
|
1890
|
+
title: "Whitepaper",
|
|
1891
|
+
caption: "Download our guide"
|
|
1892
|
+
}
|
|
1893
|
+
},
|
|
1894
|
+
bookmark: {
|
|
1895
|
+
nodeType: "bookmark",
|
|
1896
|
+
version: 1,
|
|
1897
|
+
group: "embed",
|
|
1898
|
+
description: "A rich link preview. Provide `url`; optionally title/description/author/publisher/icon/thumbnail.",
|
|
1899
|
+
required: ["url"],
|
|
1900
|
+
aliases: {},
|
|
1901
|
+
example: {
|
|
1902
|
+
url: "https://ghost.org",
|
|
1903
|
+
title: "Ghost",
|
|
1904
|
+
description: "Publishing platform"
|
|
1905
|
+
},
|
|
1906
|
+
build(fields) {
|
|
1907
|
+
const metaKeys = [
|
|
1908
|
+
"title",
|
|
1909
|
+
"description",
|
|
1910
|
+
"author",
|
|
1911
|
+
"publisher",
|
|
1912
|
+
"icon",
|
|
1913
|
+
"thumbnail"
|
|
1914
|
+
];
|
|
1915
|
+
const metadata = { url: fields.url };
|
|
1916
|
+
for (const k of metaKeys) metadata[k] = fields[k] ?? (k === "author" ? null : "");
|
|
1917
|
+
return {
|
|
1918
|
+
url: fields.url,
|
|
1919
|
+
caption: fields.caption ?? "",
|
|
1920
|
+
metadata
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
},
|
|
1924
|
+
embed: {
|
|
1925
|
+
nodeType: "embed",
|
|
1926
|
+
version: 1,
|
|
1927
|
+
group: "embed",
|
|
1928
|
+
description: "An external embed (YouTube, Twitter, etc.). Provide `url` and the embed `html`.",
|
|
1929
|
+
required: ["url"],
|
|
1930
|
+
aliases: {},
|
|
1931
|
+
example: {
|
|
1932
|
+
url: "https://youtube.com/watch?v=abc",
|
|
1933
|
+
embedType: "video",
|
|
1934
|
+
html: "<iframe src=\"...\"></iframe>"
|
|
1935
|
+
}
|
|
1936
|
+
},
|
|
1937
|
+
html: {
|
|
1938
|
+
nodeType: "html",
|
|
1939
|
+
version: 1,
|
|
1940
|
+
group: "embed",
|
|
1941
|
+
description: "Raw HTML passthrough. Use only when no native card fits.",
|
|
1942
|
+
required: ["html"],
|
|
1943
|
+
aliases: {},
|
|
1944
|
+
example: { html: "<div class=\"custom\">Raw HTML</div>" }
|
|
1945
|
+
},
|
|
1946
|
+
markdown: {
|
|
1947
|
+
nodeType: "markdown",
|
|
1948
|
+
version: 1,
|
|
1949
|
+
group: "embed",
|
|
1950
|
+
description: "A markdown block (rendered as one unit). Prefer paragraph/heading/list blocks for editable prose.",
|
|
1951
|
+
required: ["markdown"],
|
|
1952
|
+
aliases: { text: "markdown" },
|
|
1953
|
+
example: { markdown: "## Heading\n\nSome **markdown**." }
|
|
1954
|
+
},
|
|
1955
|
+
codeblock: {
|
|
1956
|
+
nodeType: "codeblock",
|
|
1957
|
+
version: 1,
|
|
1958
|
+
group: "embed",
|
|
1959
|
+
description: "A syntax-highlighted code block.",
|
|
1960
|
+
required: ["code"],
|
|
1961
|
+
aliases: { lang: "language" },
|
|
1962
|
+
example: {
|
|
1963
|
+
code: "const x = 1;",
|
|
1964
|
+
language: "javascript",
|
|
1965
|
+
caption: "snippet"
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
callout: {
|
|
1969
|
+
nodeType: "callout",
|
|
1970
|
+
version: 1,
|
|
1971
|
+
group: "layout",
|
|
1972
|
+
description: "A highlighted callout box with an emoji and background color.",
|
|
1973
|
+
required: ["text"],
|
|
1974
|
+
aliases: {
|
|
1975
|
+
text: "calloutText",
|
|
1976
|
+
emoji: "calloutEmoji",
|
|
1977
|
+
color: "backgroundColor"
|
|
1978
|
+
},
|
|
1979
|
+
example: {
|
|
1980
|
+
text: "Heads up!",
|
|
1981
|
+
emoji: "💡",
|
|
1982
|
+
color: "blue"
|
|
1983
|
+
}
|
|
1984
|
+
},
|
|
1985
|
+
toggle: {
|
|
1986
|
+
nodeType: "toggle",
|
|
1987
|
+
version: 1,
|
|
1988
|
+
group: "layout",
|
|
1989
|
+
description: "A collapsible accordion. `content` is HTML. (No-op in email.)",
|
|
1990
|
+
required: ["heading"],
|
|
1991
|
+
aliases: {},
|
|
1992
|
+
example: {
|
|
1993
|
+
heading: "Click to expand",
|
|
1994
|
+
content: "<p>Hidden content.</p>"
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
button: {
|
|
1998
|
+
nodeType: "button",
|
|
1999
|
+
version: 1,
|
|
2000
|
+
group: "layout",
|
|
2001
|
+
description: "A call-to-action button.",
|
|
2002
|
+
required: ["text", "url"],
|
|
2003
|
+
aliases: {
|
|
2004
|
+
text: "buttonText",
|
|
2005
|
+
url: "buttonUrl"
|
|
2006
|
+
},
|
|
2007
|
+
example: {
|
|
2008
|
+
text: "Subscribe",
|
|
2009
|
+
url: "https://example.com",
|
|
2010
|
+
alignment: "center"
|
|
2011
|
+
}
|
|
2012
|
+
},
|
|
2013
|
+
header: {
|
|
2014
|
+
nodeType: "header",
|
|
2015
|
+
version: 2,
|
|
2016
|
+
group: "layout",
|
|
2017
|
+
description: "A large hero header with optional background image and button.",
|
|
2018
|
+
required: [],
|
|
2019
|
+
aliases: { title: "header" },
|
|
2020
|
+
example: {
|
|
2021
|
+
header: "Big Header",
|
|
2022
|
+
subheader: "A subheader",
|
|
2023
|
+
buttonEnabled: true,
|
|
2024
|
+
buttonText: "Start",
|
|
2025
|
+
buttonUrl: "https://example.com"
|
|
2026
|
+
}
|
|
2027
|
+
},
|
|
2028
|
+
cta: {
|
|
2029
|
+
nodeType: "call-to-action",
|
|
2030
|
+
version: 1,
|
|
2031
|
+
group: "layout",
|
|
2032
|
+
description: "A call-to-action card with text, optional image and button. `text` accepts HTML or plain text.",
|
|
2033
|
+
required: [],
|
|
2034
|
+
aliases: { buttonColor: "buttonColor" },
|
|
2035
|
+
example: {
|
|
2036
|
+
text: "Subscribe for more.",
|
|
2037
|
+
buttonText: "Join",
|
|
2038
|
+
buttonUrl: "https://example.com",
|
|
2039
|
+
showButton: true
|
|
2040
|
+
},
|
|
2041
|
+
build(fields) {
|
|
2042
|
+
const node = {};
|
|
2043
|
+
for (const k of [
|
|
2044
|
+
"layout",
|
|
2045
|
+
"alignment",
|
|
2046
|
+
"showButton",
|
|
2047
|
+
"showDividers",
|
|
2048
|
+
"buttonText",
|
|
2049
|
+
"buttonUrl",
|
|
2050
|
+
"buttonColor",
|
|
2051
|
+
"buttonTextColor",
|
|
2052
|
+
"hasSponsorLabel",
|
|
2053
|
+
"sponsorLabel",
|
|
2054
|
+
"backgroundColor",
|
|
2055
|
+
"linkColor",
|
|
2056
|
+
"imageUrl",
|
|
2057
|
+
"imageWidth",
|
|
2058
|
+
"imageHeight",
|
|
2059
|
+
"visibility"
|
|
2060
|
+
]) if (k in fields) node[k] = fields[k];
|
|
2061
|
+
if (typeof fields.text === "string") node.textValue = /^\s*</.test(fields.text) ? fields.text : `<p>${fields.text}</p>`;
|
|
2062
|
+
return node;
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
signup: {
|
|
2066
|
+
nodeType: "signup",
|
|
2067
|
+
version: 1,
|
|
2068
|
+
group: "membership",
|
|
2069
|
+
description: "A member signup form. (No-op in email.)",
|
|
2070
|
+
required: [],
|
|
2071
|
+
aliases: {},
|
|
2072
|
+
example: {
|
|
2073
|
+
header: "Subscribe",
|
|
2074
|
+
subheader: "Join the newsletter",
|
|
2075
|
+
disclaimer: "No spam."
|
|
2076
|
+
}
|
|
2077
|
+
},
|
|
2078
|
+
product: {
|
|
2079
|
+
nodeType: "product",
|
|
2080
|
+
version: 1,
|
|
2081
|
+
group: "layout",
|
|
2082
|
+
description: "A product card with image, rating, and button.",
|
|
2083
|
+
required: ["productTitle"],
|
|
2084
|
+
aliases: {
|
|
2085
|
+
title: "productTitle",
|
|
2086
|
+
description: "productDescription",
|
|
2087
|
+
image: "productImageSrc",
|
|
2088
|
+
button: "productButton",
|
|
2089
|
+
url: "productUrl",
|
|
2090
|
+
rating: "productStarRating"
|
|
2091
|
+
},
|
|
2092
|
+
example: {
|
|
2093
|
+
title: "The Product",
|
|
2094
|
+
description: "A great product.",
|
|
2095
|
+
rating: 5,
|
|
2096
|
+
button: "Buy",
|
|
2097
|
+
url: "https://example.com",
|
|
2098
|
+
productButtonEnabled: true,
|
|
2099
|
+
productRatingEnabled: true
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
divider: {
|
|
2103
|
+
nodeType: "horizontalrule",
|
|
2104
|
+
version: 1,
|
|
2105
|
+
group: "divider",
|
|
2106
|
+
description: "A horizontal rule / divider.",
|
|
2107
|
+
required: [],
|
|
2108
|
+
aliases: {},
|
|
2109
|
+
example: {},
|
|
2110
|
+
build() {
|
|
2111
|
+
return {};
|
|
2112
|
+
}
|
|
2113
|
+
},
|
|
2114
|
+
paywall: {
|
|
2115
|
+
nodeType: "paywall",
|
|
2116
|
+
version: 1,
|
|
2117
|
+
group: "membership",
|
|
2118
|
+
description: "Splits free vs members-only content. Everything after it is members-only.",
|
|
2119
|
+
required: [],
|
|
2120
|
+
aliases: {},
|
|
2121
|
+
example: {},
|
|
2122
|
+
build() {
|
|
2123
|
+
return {};
|
|
2124
|
+
}
|
|
2125
|
+
},
|
|
2126
|
+
email: {
|
|
2127
|
+
nodeType: "email",
|
|
2128
|
+
version: 1,
|
|
2129
|
+
group: "email-only",
|
|
2130
|
+
description: "Content shown ONLY in the email newsletter (empty on web). `html` supports {first_name, \"fallback\"}.",
|
|
2131
|
+
required: ["html"],
|
|
2132
|
+
aliases: {},
|
|
2133
|
+
example: { html: "<p>Hello {first_name, \"there\"}!</p>" }
|
|
2134
|
+
},
|
|
2135
|
+
"email-cta": {
|
|
2136
|
+
nodeType: "email-cta",
|
|
2137
|
+
version: 1,
|
|
2138
|
+
group: "email-only",
|
|
2139
|
+
description: "A newsletter-only call to action targeting a member segment.",
|
|
2140
|
+
required: [],
|
|
2141
|
+
aliases: {},
|
|
2142
|
+
example: {
|
|
2143
|
+
html: "<p>Read more.</p>",
|
|
2144
|
+
buttonText: "Read",
|
|
2145
|
+
buttonUrl: "https://example.com",
|
|
2146
|
+
segment: "status:free"
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
function buildCardNode(blockType, fields) {
|
|
2151
|
+
const def = CARDS[blockType];
|
|
2152
|
+
if (!def) throw new Error(`unknown card type "${blockType}"`);
|
|
2153
|
+
for (const r of def.required) if (fields[r] === void 0 || fields[r] === null || fields[r] === "") throw new Error(`card "${blockType}" requires field "${r}"`);
|
|
2154
|
+
const data = def.build ? def.build(fields) : passthrough(def, fields);
|
|
2155
|
+
return {
|
|
2156
|
+
type: def.nodeType,
|
|
2157
|
+
version: def.version,
|
|
2158
|
+
...data
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
function isCardType(blockType) {
|
|
2162
|
+
return blockType in CARDS;
|
|
2163
|
+
}
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region src/koenig/blocks.ts
|
|
2166
|
+
const ELEMENT = {
|
|
2167
|
+
direction: "ltr",
|
|
2168
|
+
format: "",
|
|
2169
|
+
indent: 0
|
|
2170
|
+
};
|
|
2171
|
+
function element(type, extra, children) {
|
|
2172
|
+
return {
|
|
2173
|
+
type,
|
|
2174
|
+
version: 1,
|
|
2175
|
+
...ELEMENT,
|
|
2176
|
+
...extra,
|
|
2177
|
+
children
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
function asString(value, field, blockType) {
|
|
2181
|
+
if (typeof value !== "string") throw new Error(`block "${blockType}" field "${field}" must be a string`);
|
|
2182
|
+
return value;
|
|
2183
|
+
}
|
|
2184
|
+
function buildProse(block) {
|
|
2185
|
+
switch (block.type) {
|
|
2186
|
+
case "paragraph": return element("paragraph", {}, parseInline(asString(block.text, "text", "paragraph")));
|
|
2187
|
+
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")));
|
|
2188
|
+
case "quote": return element("extended-quote", {}, parseInline(asString(block.text, "text", "quote")));
|
|
2189
|
+
case "aside": return element("aside", {}, parseInline(asString(block.text, "text", "aside")));
|
|
2190
|
+
case "list": {
|
|
2191
|
+
const items = Array.isArray(block.items) ? block.items : [];
|
|
2192
|
+
if (items.length === 0) throw new Error("block \"list\" requires a non-empty \"items\" array");
|
|
2193
|
+
const ordered = block.style === "number" || block.style === "ordered";
|
|
2194
|
+
const children = items.map((item, i) => element("listitem", { value: i + 1 }, parseInline(String(item))));
|
|
2195
|
+
return element("list", {
|
|
2196
|
+
listType: ordered ? "number" : "bullet",
|
|
2197
|
+
tag: ordered ? "ol" : "ul",
|
|
2198
|
+
start: 1
|
|
2199
|
+
}, children);
|
|
2200
|
+
}
|
|
2201
|
+
default: return null;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
function isBlock(value) {
|
|
2205
|
+
return isRecord(value) && typeof value.type === "string";
|
|
2206
|
+
}
|
|
2207
|
+
function buildBlock(block) {
|
|
2208
|
+
if (!isBlock(block)) throw new Error("each block must be an object with a string \"type\"");
|
|
2209
|
+
const prose = buildProse(block);
|
|
2210
|
+
if (prose) return prose;
|
|
2211
|
+
if (isCardType(block.type)) {
|
|
2212
|
+
const { type, ...fields } = block;
|
|
2213
|
+
return buildCardNode(type, fields);
|
|
2214
|
+
}
|
|
2215
|
+
throw new Error(`unknown block type "${block.type}". valid types: ${[...PROSE_TYPES, ...Object.keys(CARDS)].join(", ")}`);
|
|
2216
|
+
}
|
|
2217
|
+
const PROSE_TYPES = [
|
|
2218
|
+
"paragraph",
|
|
2219
|
+
"heading",
|
|
2220
|
+
"list",
|
|
2221
|
+
"quote",
|
|
2222
|
+
"aside"
|
|
2223
|
+
];
|
|
2224
|
+
//#endregion
|
|
2225
|
+
//#region src/koenig/compose.ts
|
|
2226
|
+
var ComposeError = class extends Error {
|
|
2227
|
+
constructor(issues) {
|
|
2228
|
+
const detail = issues.map((i) => `[#${i.index} ${i.type}] ${i.message}`).join("; ");
|
|
2229
|
+
super(`composition failed (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${detail}`);
|
|
2230
|
+
this.issues = issues;
|
|
2231
|
+
this.name = "ComposeError";
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
function composeRoot(blocks) {
|
|
2235
|
+
if (!Array.isArray(blocks) || blocks.length === 0) throw new ComposeError([{
|
|
2236
|
+
index: -1,
|
|
2237
|
+
type: "(none)",
|
|
2238
|
+
message: "blocks must be a non-empty array"
|
|
2239
|
+
}]);
|
|
2240
|
+
const children = [];
|
|
2241
|
+
const issues = [];
|
|
2242
|
+
blocks.forEach((block, index) => {
|
|
2243
|
+
try {
|
|
2244
|
+
children.push(buildBlock(block));
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
issues.push({
|
|
2247
|
+
index,
|
|
2248
|
+
type: isRecord(block) && typeof block.type === "string" ? block.type : "(invalid)",
|
|
2249
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
if (issues.length > 0) throw new ComposeError(issues);
|
|
2254
|
+
return { root: {
|
|
2255
|
+
type: "root",
|
|
2256
|
+
version: 1,
|
|
2257
|
+
direction: "ltr",
|
|
2258
|
+
format: "",
|
|
2259
|
+
indent: 0,
|
|
2260
|
+
children
|
|
2261
|
+
} };
|
|
2262
|
+
}
|
|
2263
|
+
function compose(blocks) {
|
|
2264
|
+
return JSON.stringify(composeRoot(blocks));
|
|
2265
|
+
}
|
|
2266
|
+
//#endregion
|
|
2267
|
+
//#region src/koenig/help.ts
|
|
2268
|
+
const PROSE = [
|
|
2269
|
+
{
|
|
2270
|
+
type: "paragraph",
|
|
2271
|
+
description: "A text paragraph. `text` supports inline **bold**, _italic_, `code`, [links](url).",
|
|
2272
|
+
example: {
|
|
2273
|
+
type: "paragraph",
|
|
2274
|
+
text: "Some **bold** and a [link](https://x.com)."
|
|
2275
|
+
}
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
type: "heading",
|
|
2279
|
+
description: "A heading. `level` 1–6 (default 2). `text` supports inline markdown.",
|
|
2280
|
+
example: {
|
|
2281
|
+
type: "heading",
|
|
2282
|
+
level: 2,
|
|
2283
|
+
text: "Section title"
|
|
2284
|
+
}
|
|
2285
|
+
},
|
|
2286
|
+
{
|
|
2287
|
+
type: "list",
|
|
2288
|
+
description: "A bullet or numbered list. `style`: \"bullet\" (default) or \"number\". `items` is a string array (inline markdown supported).",
|
|
2289
|
+
example: {
|
|
2290
|
+
type: "list",
|
|
2291
|
+
style: "bullet",
|
|
2292
|
+
items: ["First", "Second"]
|
|
2293
|
+
}
|
|
2294
|
+
},
|
|
2295
|
+
{
|
|
2296
|
+
type: "quote",
|
|
2297
|
+
description: "A blockquote. `text` supports inline markdown.",
|
|
2298
|
+
example: {
|
|
2299
|
+
type: "quote",
|
|
2300
|
+
text: "A memorable quote."
|
|
2301
|
+
}
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
type: "aside",
|
|
2305
|
+
description: "A pull-quote / aside.",
|
|
2306
|
+
example: {
|
|
2307
|
+
type: "aside",
|
|
2308
|
+
text: "An aside."
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
];
|
|
2312
|
+
function blockHelp(blockType) {
|
|
2313
|
+
if (blockType) {
|
|
2314
|
+
const prose = PROSE.find((p) => p.type === blockType);
|
|
2315
|
+
if (prose) return [
|
|
2316
|
+
`# block: ${prose.type}`,
|
|
2317
|
+
"",
|
|
2318
|
+
prose.description,
|
|
2319
|
+
"",
|
|
2320
|
+
"```json",
|
|
2321
|
+
JSON.stringify(prose.example, null, 2),
|
|
2322
|
+
"```"
|
|
2323
|
+
].join("\n");
|
|
2324
|
+
const card = CARDS[blockType];
|
|
2325
|
+
if (!card) return `Unknown block type "${blockType}". Run koenig_help with no argument to list all block types.`;
|
|
2326
|
+
const example = {
|
|
2327
|
+
type: blockType,
|
|
2328
|
+
...card.example
|
|
2329
|
+
};
|
|
2330
|
+
const lines = [
|
|
2331
|
+
`# block: ${blockType}`,
|
|
2332
|
+
"",
|
|
2333
|
+
`${card.description}`,
|
|
2334
|
+
"",
|
|
2335
|
+
`- Lexical node: \`${card.nodeType}\` (version ${card.version})`,
|
|
2336
|
+
card.required.length ? `- Required fields: ${card.required.map((r) => `\`${r}\``).join(", ")}` : "- Required fields: none"
|
|
2337
|
+
];
|
|
2338
|
+
if (Object.keys(card.aliases).length) lines.push(`- Aliases: ${Object.entries(card.aliases).map(([f, t]) => `\`${f}\`→\`${t}\``).join(", ")}`);
|
|
2339
|
+
lines.push("", "```json", JSON.stringify(example, null, 2), "```");
|
|
2340
|
+
return lines.join("\n");
|
|
2341
|
+
}
|
|
2342
|
+
const lines = [
|
|
2343
|
+
"# Koenig block types",
|
|
2344
|
+
"",
|
|
2345
|
+
"Compose posts from these blocks instead of raw HTML — they produce clean, natively-editable Ghost content.",
|
|
2346
|
+
"",
|
|
2347
|
+
"## Prose (native, inline markdown in `text`)"
|
|
2348
|
+
];
|
|
2349
|
+
for (const p of PROSE) lines.push(`- **${p.type}** — ${p.description}`);
|
|
2350
|
+
const byGroup = {};
|
|
2351
|
+
for (const [type, def] of Object.entries(CARDS)) (byGroup[def.group] ??= []).push(`- **${type}** — ${def.description}`);
|
|
2352
|
+
for (const [group, entries] of Object.entries(byGroup)) {
|
|
2353
|
+
lines.push("", `## ${group}`);
|
|
2354
|
+
lines.push(...entries);
|
|
2355
|
+
}
|
|
2356
|
+
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).");
|
|
2357
|
+
return lines.join("\n");
|
|
2358
|
+
}
|
|
2359
|
+
//#endregion
|
|
2360
|
+
//#region src/tools/blocks-source.ts
|
|
2361
|
+
function extractBlocks(parsed) {
|
|
2362
|
+
if (Array.isArray(parsed)) return parsed;
|
|
2363
|
+
if (isRecord(parsed) && Array.isArray(parsed.blocks)) return parsed.blocks;
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
function resolveBlocks(src) {
|
|
2367
|
+
const hasFile = typeof src.blockFile === "string" && src.blockFile.trim() !== "";
|
|
2368
|
+
if (Array.isArray(src.blocks)) {
|
|
2369
|
+
if (hasFile) throw new Error("provide either \"blocks\" or \"blockFile\", not both");
|
|
2370
|
+
return src.blocks;
|
|
2371
|
+
}
|
|
2372
|
+
if (!hasFile) throw new Error("provide \"blocks\" (inline array) or \"blockFile\" (absolute path to a JSON file of blocks)");
|
|
2373
|
+
const file = src.blockFile;
|
|
2374
|
+
if (typeof file !== "string") throw new Error("blockFile must be a string path");
|
|
2375
|
+
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.`);
|
|
2376
|
+
let raw;
|
|
2377
|
+
try {
|
|
2378
|
+
raw = readFileSync(file, "utf8");
|
|
2379
|
+
} catch {
|
|
2380
|
+
throw new Error(`could not read blockFile: ${file}`);
|
|
2381
|
+
}
|
|
2382
|
+
let parsed;
|
|
2383
|
+
try {
|
|
2384
|
+
parsed = JSON.parse(raw);
|
|
2385
|
+
} catch {
|
|
2386
|
+
throw new Error(`blockFile is not valid JSON: ${file}`);
|
|
2387
|
+
}
|
|
2388
|
+
const blocks = extractBlocks(parsed);
|
|
2389
|
+
if (!blocks) throw new Error(`blockFile must contain a JSON array of blocks, or { "blocks": [...] }: ${file}`);
|
|
2390
|
+
return blocks;
|
|
2391
|
+
}
|
|
2392
|
+
//#endregion
|
|
2393
|
+
//#region src/tools/compose-post.ts
|
|
2394
|
+
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();
|
|
2395
|
+
const composeFields = {
|
|
2396
|
+
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."),
|
|
2397
|
+
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."),
|
|
2398
|
+
title: z.string().optional().describe("Post title (required when creating a new post)"),
|
|
2399
|
+
id: z.string().optional().describe("Post ID to update. Omit to create a new post."),
|
|
2400
|
+
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."),
|
|
2401
|
+
status: z.enum([
|
|
2402
|
+
"published",
|
|
2403
|
+
"draft",
|
|
2404
|
+
"scheduled"
|
|
2405
|
+
]).optional().describe("Post status (default draft)"),
|
|
2406
|
+
tags: z.array(z.union([z.object({ id: z.string() }), z.object({ name: z.string() })])).optional().describe("Tags to assign (by id or name)"),
|
|
2407
|
+
feature_image: z.string().optional().describe("Feature image URL"),
|
|
2408
|
+
excerpt: z.string().optional().describe("Custom excerpt (maps to custom_excerpt)"),
|
|
2409
|
+
slug: z.string().optional().describe("Custom URL slug"),
|
|
2410
|
+
visibility: z.string().optional().describe("public, members, paid, or tiers")
|
|
2411
|
+
};
|
|
2412
|
+
const composePostSchema = z.object(composeFields);
|
|
2413
|
+
async function handleComposePost(input, mode) {
|
|
2414
|
+
let lexical;
|
|
2415
|
+
try {
|
|
2416
|
+
lexical = compose(resolveBlocks({
|
|
2417
|
+
blocks: input.blocks,
|
|
2418
|
+
blockFile: input.blockFile
|
|
2419
|
+
}));
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
if (error instanceof ComposeError) return JSON.stringify({
|
|
2422
|
+
error: "composition failed",
|
|
2423
|
+
issues: error.issues
|
|
2424
|
+
});
|
|
2425
|
+
return JSON.stringify({
|
|
2426
|
+
error: "invalid blocks input",
|
|
2427
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
const { blocks: _blocks, blockFile: _blockFile, id, excerpt, ...rest } = input;
|
|
2431
|
+
const payload = {
|
|
2432
|
+
...rest,
|
|
2433
|
+
lexical
|
|
2434
|
+
};
|
|
2435
|
+
if (excerpt !== void 0) payload.custom_excerpt = excerpt;
|
|
2436
|
+
if (id) return handleUseGhostApi({
|
|
2437
|
+
api: "admin",
|
|
2438
|
+
action: "posts.edit",
|
|
2439
|
+
payload: {
|
|
2440
|
+
id,
|
|
2441
|
+
...payload
|
|
2442
|
+
}
|
|
2443
|
+
}, mode);
|
|
2444
|
+
return handleUseGhostApi({
|
|
2445
|
+
api: "admin",
|
|
2446
|
+
action: "posts.add",
|
|
2447
|
+
payload
|
|
2448
|
+
}, mode);
|
|
2449
|
+
}
|
|
2450
|
+
//#endregion
|
|
2451
|
+
//#region src/tools/compose-lexical.ts
|
|
2452
|
+
const composeLexicalSchema = z.object({
|
|
2453
|
+
blocks: z.array(z.object({ type: z.string() }).passthrough()).optional().describe("Ordered content blocks (same shape as compose_post), inline."),
|
|
2454
|
+
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`.")
|
|
2455
|
+
});
|
|
2456
|
+
function handleComposeLexical(input) {
|
|
2457
|
+
try {
|
|
2458
|
+
const lexical = compose(resolveBlocks({
|
|
2459
|
+
blocks: input.blocks,
|
|
2460
|
+
blockFile: input.blockFile
|
|
2461
|
+
}));
|
|
2462
|
+
return JSON.stringify({ lexical });
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
if (error instanceof ComposeError) return JSON.stringify({
|
|
2465
|
+
error: "composition failed",
|
|
2466
|
+
issues: error.issues
|
|
2467
|
+
});
|
|
2468
|
+
return JSON.stringify({
|
|
2469
|
+
error: "invalid blocks input",
|
|
2470
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
//#endregion
|
|
2475
|
+
//#region src/tools/koenig-help.ts
|
|
2476
|
+
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.") });
|
|
2477
|
+
function handleKoenigHelp(input) {
|
|
2478
|
+
return blockHelp(input.block);
|
|
2479
|
+
}
|
|
2480
|
+
//#endregion
|
|
1429
2481
|
//#region src/index.ts
|
|
1430
2482
|
const GHOST_API_MODE = process.env.GHOST_API_MODE || "admin";
|
|
1431
2483
|
const server = new McpServer({
|
|
@@ -1461,6 +2513,36 @@ server.tool("ghost_docs", "Search Ghost CMS documentation — fetch full docs, s
|
|
|
1461
2513
|
})
|
|
1462
2514
|
}] };
|
|
1463
2515
|
});
|
|
2516
|
+
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) => {
|
|
2517
|
+
return { content: [{
|
|
2518
|
+
type: "text",
|
|
2519
|
+
text: await handleComposePost(input, GHOST_API_MODE)
|
|
2520
|
+
}] };
|
|
2521
|
+
});
|
|
2522
|
+
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) => {
|
|
2523
|
+
return { content: [{
|
|
2524
|
+
type: "text",
|
|
2525
|
+
text: handleComposeLexical(input)
|
|
2526
|
+
}] };
|
|
2527
|
+
});
|
|
2528
|
+
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 }) => {
|
|
2529
|
+
return { content: [{
|
|
2530
|
+
type: "text",
|
|
2531
|
+
text: handleKoenigHelp({ block })
|
|
2532
|
+
}] };
|
|
2533
|
+
});
|
|
2534
|
+
server.registerPrompt("compose_ghost_post", { description: "Guidance for composing clean, editable Ghost posts from Koenig blocks instead of raw HTML." }, () => ({ messages: [{
|
|
2535
|
+
role: "assistant",
|
|
2536
|
+
content: {
|
|
2537
|
+
type: "text",
|
|
2538
|
+
text: [
|
|
2539
|
+
"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.",
|
|
2540
|
+
"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.",
|
|
2541
|
+
"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.",
|
|
2542
|
+
blockHelp()
|
|
2543
|
+
].join("\n\n")
|
|
2544
|
+
}
|
|
2545
|
+
}] }));
|
|
1464
2546
|
async function main() {
|
|
1465
2547
|
const transport = new StdioServerTransport();
|
|
1466
2548
|
await server.connect(transport);
|