Skip to content

Commit a3ecc0b

Browse files
authored
feat(syndication): add syndicate-post workflow and dispatch runner (#708) (#709)
1 parent eb27345 commit a3ecc0b

2 files changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Syndicate Post
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
document_id:
7+
description: "Sanity Document ID to syndicate"
8+
required: true
9+
default: "05eda048-efff-4e0f-8b76-83a2f482fa13"
10+
type: string
11+
dataset:
12+
description: "Target Sanity Dataset"
13+
required: true
14+
default: "production"
15+
type: choice
16+
options:
17+
- production
18+
- dev
19+
20+
jobs:
21+
syndicate:
22+
runs-on: ubuntu-latest
23+
environment: production
24+
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: pnpm/action-setup@v4
29+
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version-file: ".nvmrc"
33+
cache: "pnpm"
34+
35+
- name: Install dependencies
36+
run: pnpm install --frozen-lockfile
37+
38+
- name: Syndicate Post
39+
run: |
40+
node scripts/run-syndicate-post.mjs
41+
env:
42+
TARGET_DOCUMENT_ID: ${{ inputs.document_id }}
43+
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
44+
SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }}
45+
SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }}
46+
SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }}
47+
PRIVATE_DEVTO: ${{ secrets.PRIVATE_DEVTO }}
48+
PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }}
49+
DEVTO_API_KEY: ${{ secrets.PRIVATE_DEVTO }}
50+
HASHNODE_ACCESS_TOKEN: ${{ secrets.PRIVATE_HASHNODE }}

‎scripts/run-syndicate-post.mjs‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { createClient } from "@sanity/client";
2+
import { createImageUrlBuilder } from "@sanity/image-url";
3+
import { portableTextToMarkdown } from "@portabletext/markdown";
4+
5+
const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w";
6+
const dataset = process.env.SANITY_STUDIO_DATASET || "production";
7+
const token = process.env.SANITY_API_WRITE_TOKEN || process.env.SANITY_AUTH_TOKEN;
8+
const devtoApiKey = process.env.DEVTO_API_KEY || process.env.PRIVATE_DEVTO;
9+
const hashnodeToken = process.env.HASHNODE_ACCESS_TOKEN || process.env.PRIVATE_HASHNODE;
10+
const docId = process.env.TARGET_DOCUMENT_ID || "05eda048-efff-4e0f-8b76-83a2f482fa13";
11+
12+
console.log("Syndication script starting with config:");
13+
console.log("- Document ID:", docId);
14+
console.log("- Project ID:", projectId);
15+
console.log("- Dataset:", dataset);
16+
console.log("- Has Write Token:", Boolean(token));
17+
console.log("- Has Dev.to Key:", Boolean(devtoApiKey));
18+
console.log("- Has Hashnode Key:", Boolean(hashnodeToken));
19+
20+
if (!token) {
21+
console.error("Missing SANITY_API_WRITE_TOKEN or SANITY_AUTH_TOKEN.");
22+
process.exit(1);
23+
}
24+
25+
const client = createClient({
26+
projectId,
27+
dataset,
28+
token,
29+
apiVersion: "2025-09-30",
30+
useCdn: false,
31+
});
32+
33+
async function run() {
34+
const doc = await client.getDocument(docId);
35+
if (!doc) {
36+
console.error(`Document not found: ${docId}`);
37+
process.exit(1);
38+
}
39+
40+
console.log(`Loaded document: "${doc.title}" (type: ${doc._type})`);
41+
const slug = typeof doc.slug === "object" ? doc.slug?.current : (doc.slug || docId);
42+
const canonicalUrl = `https://codingcat.dev/${doc._type}/${slug}`;
43+
44+
const imgBuilder = createImageUrlBuilder({ projectId, dataset });
45+
const urlForImage = (source) => {
46+
if (!source || !source.asset) return undefined;
47+
return imgBuilder.image(source).auto("format").fit("max");
48+
};
49+
50+
const serializeContent = (content) => {
51+
if (!content || !Array.isArray(content) || content.length === 0) {
52+
return "";
53+
}
54+
try {
55+
return portableTextToMarkdown(content, {
56+
types: {
57+
code: ({ value }) => "```" + (value?.language || "") + "\n" + (value?.code || "") + "\n```",
58+
image: ({ value }) => {
59+
const url = urlForImage(value)?.url();
60+
return url ? `![](${url})` : "";
61+
},
62+
codepen: ({ value }) => `{% codepen ${value?.url || ""} %}`,
63+
codesandbox: ({ value }) => {
64+
const id = value?.url?.split("https://codesandbox.io/p/sandbox/")?.at(-1);
65+
return `{% codesandbox ${id || value?.url || ""} %}`;
66+
},
67+
twitter: ({ value }) => `{% twitter ${value?.id || ""} %}`,
68+
quote: ({ value }) => `> ${serializeContent(value?.content)}`,
69+
},
70+
});
71+
} catch (err) {
72+
console.warn("Error serializing content:", err);
73+
return doc.excerpt || "";
74+
}
75+
};
76+
77+
const contentMarkdown = serializeContent(doc.content);
78+
console.log(`Content serialized to Markdown: ${contentMarkdown.length} characters`);
79+
80+
// --- DEV.TO SYNDICATION ---
81+
if (devtoApiKey) {
82+
console.log("\n--- Syndicating to Dev.to ---");
83+
const syndicationDocId = `syndication.${doc._id}.devto`;
84+
const devtoTags = ["webdev", "javascript", "beginners"];
85+
if (doc._type === "podcast") devtoTags.push("podcast");
86+
87+
const mainImageUrl = urlForImage(doc.coverImage)?.width(1000).height(420).url() || "";
88+
const youtubeEmbed = doc.youtube ? `{% youtube ${doc.youtube.replace("live", "embed")} %}\n\n` : "";
89+
90+
const devtoArticlePayload = {
91+
article: {
92+
title: doc.title || "Untitled",
93+
published: true,
94+
tags: devtoTags,
95+
main_image: mainImageUrl,
96+
canonical_url: canonicalUrl,
97+
description: doc.excerpt || "",
98+
organization_id: "1009",
99+
body_markdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`,
100+
},
101+
};
102+
103+
if (doc._type === "podcast") {
104+
devtoArticlePayload.article.series = `codingcatdev_podcast_${doc.season || 4}`;
105+
}
106+
107+
try {
108+
// First attempt with organization_id: 1009
109+
console.log("Sending POST to Dev.to API with organization_id: 1009...");
110+
let res = await fetch("https://dev.to/api/articles", {
111+
method: "POST",
112+
headers: {
113+
"api-key": devtoApiKey,
114+
"Content-Type": "application/json",
115+
},
116+
body: JSON.stringify(devtoArticlePayload),
117+
});
118+
119+
let resText = await res.text();
120+
console.log("Dev.to initial response status:", res.status);
121+
console.log("Dev.to initial response body:", resText);
122+
123+
// If org 1009 fails (e.g. 401 or 422 unauthorized org), retry without organization_id
124+
if (!res.ok && resText.toLowerCase().includes("organization")) {
125+
console.warn("Dev.to rejected organization_id 1009. Retrying under personal account without organization_id...");
126+
delete devtoArticlePayload.article.organization_id;
127+
res = await fetch("https://dev.to/api/articles", {
128+
method: "POST",
129+
headers: {
130+
"api-key": devtoApiKey,
131+
"Content-Type": "application/json",
132+
},
133+
body: JSON.stringify(devtoArticlePayload),
134+
});
135+
resText = await res.text();
136+
console.log("Dev.to retry response status:", res.status);
137+
console.log("Dev.to retry response body:", resText);
138+
}
139+
140+
if (res.ok) {
141+
const resJson = JSON.parse(resText);
142+
console.log(`✓ Dev.to published: ${resJson.url} (ID: ${resJson.id})`);
143+
144+
await client.createOrReplace({
145+
_id: syndicationDocId,
146+
_type: "syndication",
147+
target: { _type: "reference", _ref: doc._id },
148+
platform: "devto",
149+
status: "published",
150+
canonicalUrl,
151+
externalUrl: resJson.url,
152+
externalId: String(resJson.id ?? ""),
153+
syndicatedAt: new Date().toISOString(),
154+
});
155+
156+
await client.patch(doc._id).set({ devto: resJson.url }).commit();
157+
console.log(`✓ Updated Sanity doc ${doc._id} devto URL`);
158+
} else {
159+
console.error("✗ Dev.to publication failed:", resText);
160+
}
161+
} catch (err) {
162+
console.error("✗ Dev.to request error:", err);
163+
}
164+
} else {
165+
console.log("Dev.to key missing, skipping Dev.to");
166+
}
167+
168+
// --- HASHNODE SYNDICATION ---
169+
if (hashnodeToken) {
170+
console.log("\n--- Syndicating to Hashnode ---");
171+
const syndicationDocId = `syndication.${doc._id}.hashnode`;
172+
const publicationId = process.env.HASHNODE_PUBLICATION_ID || "60242f8180da6c44eadf775b";
173+
174+
const subtitle = doc.excerpt && doc.excerpt.length > 250
175+
? doc.excerpt.substring(0, 247) + "..."
176+
: (doc.excerpt || "");
177+
178+
const hashnodeTags = [
179+
{ id: "56744721958ef13879b94cad", name: "JavaScript", slug: "javascript" },
180+
{ id: "56744722958ef13879b94f1b", name: "Web Development", slug: "web-development" },
181+
{ id: "56744723958ef13879b955a9", name: "Beginner Developers", slug: "beginners" },
182+
];
183+
184+
const hashnodeCoverImageUrl = urlForImage(doc.coverImage)?.width(1600).height(840).url() || "";
185+
const youtubeEmbed = doc.youtube ? `%[${doc.youtube.replace("live", "embed")}]\n\n` : "";
186+
187+
const articleInput = {
188+
title: doc.title || "Untitled",
189+
subtitle,
190+
publicationId,
191+
slug: `${doc._type}-${slug}`,
192+
tags: hashnodeTags,
193+
coverImageOptions: {
194+
coverImageURL: hashnodeCoverImageUrl,
195+
},
196+
originalArticleURL: canonicalUrl,
197+
contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`,
198+
};
199+
200+
try {
201+
const publishRes = await fetch("https://gql-beta.hashnode.com", {
202+
method: "POST",
203+
headers: {
204+
"Content-Type": "application/json",
205+
Authorization: hashnodeToken,
206+
},
207+
body: JSON.stringify({
208+
operationName: "publishPost",
209+
query: `mutation publishPost($input: PublishPostInput!) {
210+
publishPost(input: $input) {
211+
post {
212+
id
213+
title
214+
slug
215+
url
216+
}
217+
}
218+
}`,
219+
variables: { input: articleInput },
220+
}),
221+
});
222+
223+
const publishData = await publishRes.json();
224+
console.log("Hashnode response:", JSON.stringify(publishData, null, 2));
225+
226+
const publishedPost = publishData?.data?.publishPost?.post;
227+
if (publishedPost) {
228+
const externalUrl = publishedPost.url || `https://hashnode.codingcat.dev/${publishedPost.slug}`;
229+
console.log(`✓ Hashnode published: ${externalUrl}`);
230+
231+
await client.createOrReplace({
232+
_id: syndicationDocId,
233+
_type: "syndication",
234+
target: { _type: "reference", _ref: doc._id },
235+
platform: "hashnode",
236+
status: "published",
237+
canonicalUrl,
238+
externalUrl,
239+
externalId: publishedPost.slug || publishedPost.id,
240+
syndicatedAt: new Date().toISOString(),
241+
});
242+
243+
const postSlug = publishedPost.slug || publishedPost.id;
244+
await client.patch(doc._id).set({ hashnode: postSlug }).commit();
245+
console.log(`✓ Updated Sanity doc ${doc._id} hashnode slug`);
246+
} else {
247+
console.error("✗ Hashnode publication failed:", publishData?.errors);
248+
}
249+
} catch (err) {
250+
console.error("✗ Hashnode request error:", err);
251+
}
252+
}
253+
254+
// --- LINK SYNDICATION REFERENCES ON PARENT ---
255+
try {
256+
const platforms = ["devto", "hashnode"];
257+
const expectedRefs = platforms.map((p) => `syndication.${doc._id}.${p}`);
258+
const latestDoc = await client.getDocument(doc._id);
259+
const existingRefs = (latestDoc?.syndications || []).map((s) => s._ref);
260+
261+
const newRefs = expectedRefs.filter((ref) => !existingRefs.includes(ref));
262+
if (newRefs.length > 0) {
263+
const referencesToAppend = newRefs.map((ref) => ({
264+
_key: `syn_${ref.replace(/[^a-zA-Z0-9]/g, "_")}`,
265+
_type: "reference",
266+
_ref: ref,
267+
}));
268+
269+
await client
270+
.patch(doc._id)
271+
.setIfMissing({ syndications: [] })
272+
.append("syndications", referencesToAppend)
273+
.commit();
274+
275+
console.log(`✓ Linked ${newRefs.length} syndications to parent document ${doc._id}`);
276+
}
277+
} catch (err) {
278+
console.warn("Could not append syndications array to parent:", err);
279+
}
280+
281+
console.log("\nSyndication run completed successfully!");
282+
}
283+
284+
run().catch((err) => {
285+
console.error("Run error:", err);
286+
process.exit(1);
287+
});

0 commit comments

Comments
 (0)