import { JSDOM } from "jsdom";
import { v7 } from "uuid";
import {
buildBlocksFromPasteHTML,
type BuiltBlock,
} from "src/utils/paste/htmlToBlocks";
import type { FactInput } from "src/replicache/mutations";
import { resolveGhostUrl } from "./parseGhostExport";
export type ImportImage = {
entityID: string;
url: string;
// Ghost's rendered
carries the intrinsic size, which lets a preview
// reserve the right aspect ratio before the bytes are fetched.
width: number | null;
height: number | null;
};
export type ConvertedContent = {
blocks: BuiltBlock[];
extraEntities: string[];
images: ImportImage[];
};
// The paste pipeline (src/utils/paste) is written against browser globals.
// Node has none, so they're installed from a jsdom window for the duration of
// a synchronous conversion and removed again — leaving `document` defined on
// the server would trip libraries that use it to detect a browser.
let sharedDom: JSDOM | undefined;
function withDomGlobals(fn: () => T): T {
const g = globalThis as Record;
if (typeof g.document !== "undefined") return fn();
sharedDom ??= new JSDOM("");
const w = sharedDom.window as unknown as Record;
const names = ["DOMParser", "document", "Node", "HTMLElement", "Element"];
for (const n of names) g[n] = w[n];
try {
return fn();
} finally {
for (const n of names) delete g[n];
}
}
type Card = {
type: BuiltBlock["type"];
facts: Array<{ attribute: string; data: unknown }>;
image?: Omit;
};
type Segment = { kind: "html"; html: string } | ({ kind: "card" } & Card);
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
const COMMENT_NODE = 8;
const string = (attribute: string, value: string) => ({
attribute,
data: { type: "string", value },
});
const size = (v: string | null): number | null => {
const n = Number(v);
return n > 0 ? n : null;
};
function need(el: Element, selector: string): Element {
const found = el.querySelector(selector);
if (!found) throw new Error(`Ghost card has no <${selector}>`);
return found;
}
function escapeHtml(s: string) {
return s.replace(/&/g, "&").replace(//g, ">");
}
function imageCard(img: Element): Card {
const url = img.getAttribute("src");
if (!url) throw new Error("Image without a src");
const alt = img.getAttribute("alt");
return {
type: "image",
facts: alt ? [string("image/alt", alt)] : [],
image: {
url,
width: size(img.getAttribute("width")),
height: size(img.getAttribute("height")),
},
};
}
function embedCard(iframe: Element): Card {
const url = iframe.getAttribute("src");
if (!url) throw new Error("iframe without a src");
const height = size(iframe.getAttribute("height"));
return {
type: "embed",
facts: [
string("embed/url", url),
...(height
? [
{
attribute: "embed/height",
data: { type: "number", value: height },
},
]
: []),
],
};
}
// An html card holding just an iframe is an embed; anything else is kept as
// raw html.
function htmlCard(container: Element): Card {
const only =
container.children.length === 1 ? container.firstElementChild : null;
if (only?.tagName === "IFRAME") return embedCard(only);
return {
type: "html",
facts: [string("embed/html", container.innerHTML.trim())],
};
}
const CARD_CLASS = /^kg-(.+)-card$/;
// Ghost "koenig" cards that map onto Leaflet blocks. Anything else tagged as a
// card is refused rather than imported as something it isn't.
function convertCard(el: Element): Segment[] {
const kind = Array.from(el.classList)
.map((c) => CARD_CLASS.exec(c)?.[1])
.find(Boolean);
const cards: Segment[] = [];
const card = (c: Card) => cards.push({ kind: "card", ...c });
switch (kind) {
case "image":
card(imageCard(need(el, "img")));
break;
case "gallery":
Array.from(el.querySelectorAll("img")).map(imageCard).forEach(card);
break;
case "embed":
card(embedCard(need(el, "iframe")));
break;
case "bookmark": {
const url = need(el, "a[href]").getAttribute("href")!;
const title = el.querySelector(".kg-bookmark-title")?.textContent?.trim();
const description = el
.querySelector(".kg-bookmark-description")
?.textContent?.trim();
card({
type: "link",
facts: [
string("link/url", url),
...(title ? [string("link/title", title)] : []),
...(description ? [string("link/description", description)] : []),
],
});
break;
}
case "button": {
const a = need(el, "a[href]");
const url = a.getAttribute("href")!;
card({
type: "button",
facts: [
string("button/text", a.textContent?.trim() || url),
string("button/url", url),
],
});
break;
}
case "callout": {
const emoji = el.querySelector(".kg-callout-emoji")?.textContent?.trim();
const text = need(el, ".kg-callout-text").innerHTML;
cards.push({
kind: "html",
html: `${emoji ? emoji + " " : ""}${text}
`,
});
break;
}
case "signup":
card({ type: "signup", facts: [] });
break;
default:
throw new Error(`Unsupported Ghost card "${kind ?? el.className}"`);
}
const caption = el.querySelector("figcaption")?.innerHTML.trim();
if (caption)
cards.push({
kind: "html",
html: `${caption}
`,
});
return cards;
}
// Split a Ghost post body into runs of ordinary HTML that the paste pipeline
// understands, interleaved with cards that map to a specific Leaflet block.
function segmentGhostBody(body: HTMLElement, doc: Document): Segment[] {
const segments: Segment[] = [];
const push = (s: Segment) => {
const last = segments[segments.length - 1];
if (s.kind === "html" && last?.kind === "html") last.html += s.html;
else segments.push(s);
};
const nodes = Array.from(body.childNodes);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.nodeType === COMMENT_NODE) {
const text = node.textContent?.trim();
// Ghost's members-only paywall card renders as a bare comment.
if (text === "members-only")
push({ kind: "card", type: "members-only-delimiter", facts: [] });
else if (text === "kg-card-begin: html") {
const container = doc.createElement("div");
while (
++i < nodes.length &&
!(
nodes[i].nodeType === COMMENT_NODE &&
nodes[i].textContent?.trim() === "kg-card-end: html"
)
)
container.appendChild(nodes[i].cloneNode(true));
push({ kind: "card", ...htmlCard(container) });
}
} else if (node.nodeType === TEXT_NODE) {
const text = node.textContent ?? "";
if (text.trim())
push({ kind: "html", html: `${escapeHtml(text)}
` });
} else if (node.nodeType === ELEMENT_NODE) {
const el = node as Element;
if (el.tagName === "IFRAME") push({ kind: "card", ...embedCard(el) });
else if (el.tagName === "IMG") push({ kind: "card", ...imageCard(el) });
else if (el.classList.contains("kg-card")) convertCard(el).forEach(push);
else push({ kind: "html", html: el.outerHTML });
}
}
return segments;
}
export function ghostHtmlToBlocks(
html: string,
opts: { siteUrl: string; parent: string },
): ConvertedContent {
const dom = new JSDOM(`${resolveGhostUrl(html, opts.siteUrl)}`);
const doc = dom.window.document;
// Ghost leaves stray empty paragraphs behind, and keeps responsive variants
// in srcset that the paste parser would otherwise pick up.
for (const p of Array.from(doc.querySelectorAll("p")))
if (!p.textContent?.trim() && p.children.length === 0) p.remove();
for (const img of Array.from(doc.querySelectorAll("img"))) {
img.removeAttribute("srcset");
img.removeAttribute("sizes");
// The paste pipeline only keeps images that stand as blocks of their own;
// Ghost's markdown cards render each `![]()` inside a paragraph.
const p = img.parentElement;
if (p?.tagName !== "P") continue;
if (p.textContent?.trim() || p.children.length !== 1)
throw new Error(`Inline image ${img.getAttribute("src")} in a paragraph`);
p.replaceWith(img);
}
const content: ConvertedContent = {
blocks: [],
extraEntities: [],
images: [],
};
for (const seg of segmentGhostBody(doc.body, doc)) {
if (seg.kind === "html") {
const result = withDomGlobals(() =>
// Only the ids of extra entities are used; they join the leaflet's
// entity set on insert.
buildBlocksFromPasteHTML(seg.html, {
parent: opts.parent,
permission_set: "",
}),
);
content.blocks.push(...result.blocks);
content.extraEntities.push(
...result.extraEntities.map((e) => e.entityID),
);
content.images.push(
...result.imageTasks.map((t) => ({
entityID: t.entityID,
url: t.url,
width: null,
height: null,
})),
);
} else {
const entityID = v7();
content.blocks.push({
entityID,
parent: opts.parent,
type: seg.type,
facts: [
{
entity: entityID,
attribute: "block/type",
data: { type: "block-type-union", value: seg.type },
},
...seg.facts.map((f) => ({ ...f, entity: entityID })),
] as FactInput[],
});
if (seg.image) content.images.push({ entityID, ...seg.image });
}
}
return content;
}