Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions dist/public/syndication-export.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/public/syndication-export.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/public/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Path: @/src/public

### Core Implementation

- Article export parses custom-domain `/p/<slug>` URLs, cleans subscription UI, and emits divider and image markers.
- Article export parses custom-domain `/p/<slug>` URLs, cleans subscription UI, and emits divider and image markers. Galleries decode `data-attrs.gallery.images` in article order; the shared caption appears once and the composite fallback is excluded. Invalid gallery data fails the export instead of dropping images.
- Note export follows public profile cursors until a forced ID or lookback boundary is reached.
- Note filtering requires the requested author, a top-level feed entity, and either the time window or exact forced ID.
- Article artifacts use `kind: "article"`; Note candidates use a source-neutral `kind: "posts"` collection.
Expand Down
26 changes: 24 additions & 2 deletions src/public/syndication-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,35 @@ export async function exportPostArtifact(client: PublicClient, postUrl: string):
$("div > hr").each((_index, element) => { const parent = $(element).parent(); if (parent.is("div") && parent.children().length === 1) parent.replaceWith("<p>[[NORI_DIVIDER]]</p>"); });
$("a.footnote-anchor").each((_index, element) => { $(element).replaceWith(`[${$(element).text().trim()}]`); });
$("div.footnote").each((_index, element) => { const number = $(element).find(".footnote-number").text().trim(); const body = $(element).find(".footnote-content").text().trim().replace(/\s+/g, " "); $(element).replaceWith(`<p>[${number}] ${body}</p>`); });
$(".captioned-image-container").each((_index, element) => {
$(".captioned-image-container,.image-gallery-embed").each((_index, element) => {
if ($(element).is(".image-gallery-embed")) {
// Galleries are empty divs: the original photos live in entity-encoded
// data-attrs JSON, not img tags. Keep their source order and one shared
// caption; staticGalleryImage is a composite fallback, not another photo.
let gallery: { images?: Array<{ src?: unknown }>; caption?: unknown };
try {
gallery = JSON.parse($(element).attr("data-attrs") ?? "").gallery;
if (!gallery || !Array.isArray(gallery.images) || gallery.images.length === 0 ||
gallery.images.some((image) => !image || typeof image.src !== "string" || !/^https?:\/\//.test(image.src))) throw new Error();
if (gallery.caption !== undefined && typeof gallery.caption !== "string") throw new Error();
} catch {
throw new CliError("INVALID_RESPONSE", "Substack image gallery is missing valid image data; refusing to omit it from the export.", 8);
}
const markers = gallery.images!.map((image) => {
const marker = `[[NORI_IMAGE:${images.length}]]`;
images.push({ url: image.src as string, caption: "" });
return `<p>${marker}</p>`;
});
if (gallery.caption) markers.push(`<p><em>${escapeHtml(gallery.caption as string)}</em></p>`);
$(element).replaceWith(markers.join(""));
return;
}
const url = $(element).find("img").first().attr("src") ?? "";
if (!url || url.includes("missing-image")) { $(element).remove(); return; }
const caption = $(element).find("figcaption,.image-caption").first().text().trim();
const marker = `[[NORI_IMAGE:${images.length}]]`;
images.push({ url, caption });
$(element).replaceWith(`<p>${marker}</p>${caption ? `<p><em>${caption}</em></p>` : ""}`);
$(element).replaceWith(`<p>${marker}</p>${caption ? `<p><em>${escapeHtml(caption)}</em></p>` : ""}`);
});
// Video embeds. X Articles cannot embed external players (the composer's Insert
// menu has no video/embed option), so preserve each embed as a link rather than
Expand Down
30 changes: 30 additions & 0 deletions tests/syndication-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ test("exports a public Substack post as a portable article bundle", async () =>
expect(bundle.html).not.toContain("button-wrapper");
});

test("exports all gallery photos in article order, with the shared caption once", async () => {
const gallery = { images: Array.from({ length: 6 }, (_, i) => ({ type: "image/jpeg", src: `https://cdn.example/photo-${i}.jpeg` })), caption: "Photos & <friends>", staticGalleryImage: { src: "https://cdn.example/composite.png" } };
const attrs = JSON.stringify({ gallery, isEditorNode: true }).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const server = await withHttpServer((_request, response) => {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ title: "Gallery", body_html: `<div class="captioned-image-container"><img src="https://cdn.example/before.png"></div><p>Before gallery.</p><div class="image-gallery-embed" data-attrs="${attrs}"></div><p>After gallery.</p><div class="captioned-image-container"><img src="https://cdn.example/after.png"></div>` }));
});
closers.push(server.close);
const { output } = await tempOutput("gallery.json");
const result = await runCli(["post", "export", "--url", `${server.origin}/p/gallery`, "--output", output]);
expect(result.code).toBe(0);
const bundle = JSON.parse(await readFile(output, "utf8"));
expect(bundle.images.map((image: { url: string }) => image.url)).toEqual(["https://cdn.example/before.png", ...gallery.images.map(image => image.src), "https://cdn.example/after.png"]);
expect(bundle.html).toBe(`<p>[[NORI_IMAGE:0]]</p><p>Before gallery.</p>${gallery.images.map((_, i) => `<p>[[NORI_IMAGE:${i + 1}]]</p>`).join("")}<p><em>Photos &amp; &lt;friends&gt;</em></p><p>After gallery.</p><p>[[NORI_IMAGE:7]]</p>`);
expect(JSON.stringify(bundle)).not.toContain("composite.png");
});

test.each(["{", "{}", JSON.stringify({ gallery: { images: [{ src: "" }] } })])("rejects malformed gallery data instead of silently dropping photos: %s", async attrs => {
const server = await withHttpServer((_request, response) => {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ title: "Gallery", body_html: `<p>Body.</p><div class="image-gallery-embed" data-attrs='${attrs}'></div>` }));
});
closers.push(server.close);
const { output } = await tempOutput("invalid-gallery.json");
const result = await runCli(["post", "export", "--url", `${server.origin}/p/gallery`, "--output", output]);
expect(result.code).not.toBe(0);
expect(JSON.parse(result.stderr).error.code).toBe("INVALID_RESPONSE");
await expect(readFile(output)).rejects.toThrow();
});

test("exports only recent top-level Notes from the requested author", async () => {
const now = Date.now();
const note = (overrides: Record<string, unknown>) => ({
Expand Down