Skip to content

Commit ea04d6b

Browse files
authored
Merge pull request #789 from CodingCatDev/dev
feat(sanity,site): re-enable tags, auto-tag from transcripts, add AWS Builder syndication & /all/rss.xml
2 parents 0c1bfdf + 2212a77 commit ea04d6b

10 files changed

Lines changed: 1294 additions & 46 deletions

File tree

‎.github/workflows/syndicate-post.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ jobs:
4848
PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }}
4949
DEVTO_API_KEY: ${{ secrets.PRIVATE_DEVTO }}
5050
HASHNODE_ACCESS_TOKEN: ${{ secrets.PRIVATE_HASHNODE }}
51+
AWS_BUILDER_SESSION_TOKEN: ${{ secrets.AWS_BUILDER_SESSION_TOKEN }}
52+
DAILY_DEV_TOKEN: ${{ secrets.DAILY_DEV_TOKEN }}

‎apps/sanity/functions/syndicate-content/index.ts‎

Lines changed: 273 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ interface ContentDocument {
1616
episode?: number;
1717
devto?: string;
1818
hashnode?: string;
19+
awsbuilder?: string;
20+
tags?: Array<{ _key?: string; label?: string; value?: string }>;
21+
sponsor?: Array<{ _ref: string }>;
1922
syndications?: Array<{ _type: "reference"; _ref: string; _key?: string }>;
2023
}
2124

@@ -104,10 +107,25 @@ export const handler = documentEventHandler<ContentDocument>(
104107
};
105108

106109
const contentMarkdown = serializeContent(data.content);
107-
const platforms: Array<"devto" | "hashnode"> = ["devto", "hashnode"];
110+
const docTags = Array.isArray(data.tags) ? data.tags : [];
111+
const hasAwsTag = docTags.some(
112+
(t) =>
113+
t?.value?.toLowerCase() === "aws" || t?.label?.toLowerCase() === "aws",
114+
);
115+
const isSponsored = Array.isArray(data.sponsor) && data.sponsor.length > 0;
116+
const shouldSyndicateAws =
117+
(hasAwsTag && !isSponsored) ||
118+
Boolean(data.awsbuilder) ||
119+
process.env.FORCE_AWS_BUILDER === "true";
120+
121+
const platforms: Array<"devto" | "hashnode" | "awsbuilder"> = [
122+
"devto",
123+
"hashnode",
124+
...(shouldSyndicateAws ? (["awsbuilder"] as const) : []),
125+
];
108126

109127
console.log(
110-
`[Syndicate Content] Processing ${data._type} (${data._id}) on dataset: ${dataset}. Production mode: ${isProduction}`,
128+
`[Syndicate Content] Processing ${data._type} (${data._id}) on dataset: ${dataset}. Production mode: ${isProduction}. Platforms: ${platforms.join(", ")}`,
111129
);
112130

113131
const parentPatch: Record<string, unknown> = {};
@@ -122,6 +140,12 @@ export const handler = documentEventHandler<ContentDocument>(
122140
);
123141

124142
try {
143+
const simHost =
144+
platform === "devto"
145+
? "dev.to"
146+
: platform === "hashnode"
147+
? "hashnode.com"
148+
: "builder.aws.com";
125149
await client.createOrReplace({
126150
_id: syndicationDocId,
127151
_type: "syndication",
@@ -132,7 +156,7 @@ export const handler = documentEventHandler<ContentDocument>(
132156
platform,
133157
status: "simulated_complete",
134158
canonicalUrl,
135-
externalUrl: `https://${platform === "devto" ? "dev.to" : "hashnode.com"}/preview-simulated/${slug}`,
159+
externalUrl: `https://${simHost}/preview-simulated/${slug}`,
136160
externalId: `simulated-${data._id}-${platform}`,
137161
syndicatedAt: new Date().toISOString(),
138162
error: undefined,
@@ -170,10 +194,22 @@ export const handler = documentEventHandler<ContentDocument>(
170194
continue;
171195
}
172196

173-
const devtoTags = ["webdev", "javascript", "beginners"];
174-
if (data._type === "podcast") {
175-
devtoTags.push("podcast");
176-
}
197+
const normalizedDocTags = docTags
198+
.map((t) =>
199+
(t?.value || t?.label || "")
200+
.toLowerCase()
201+
.replace(/[^a-z0-9]/g, ""),
202+
)
203+
.filter((v) => v.length >= 2 && v.length <= 20);
204+
205+
const devtoTags = Array.from(
206+
new Set([
207+
...(normalizedDocTags.length > 0
208+
? normalizedDocTags.slice(0, 3)
209+
: ["webdev", "javascript", "programming"]),
210+
...(data._type === "podcast" ? ["podcast"] : []),
211+
]),
212+
).slice(0, 4);
177213

178214
const mainImageUrl =
179215
urlForImage(data.coverImage)?.width(1280).url() || "";
@@ -317,13 +353,30 @@ export const handler = documentEventHandler<ContentDocument>(
317353
? data.excerpt.substring(0, 247) + "..."
318354
: data.excerpt || "";
319355

320-
const hashnodeTags: Array<{ name: string; slug: string }> = [
321-
{ name: "JavaScript", slug: "javascript" },
322-
{ name: "Web Development", slug: "web-development" },
323-
{ name: "Beginner Developers", slug: "beginners" },
324-
];
325-
326-
if (data._type === "podcast") {
356+
const mappedHashnodeTags: Array<{ name: string; slug: string }> =
357+
docTags
358+
.map((t) => ({
359+
name: t?.label || t?.value || "",
360+
slug: (t?.value || t?.label || "")
361+
.toLowerCase()
362+
.replace(/[^a-z0-9-]/g, "-"),
363+
}))
364+
.filter((t) => t.name && t.slug)
365+
.slice(0, 4);
366+
367+
const hashnodeTags: Array<{ name: string; slug: string }> =
368+
mappedHashnodeTags.length > 0
369+
? mappedHashnodeTags
370+
: [
371+
{ name: "JavaScript", slug: "javascript" },
372+
{ name: "Web Development", slug: "web-development" },
373+
{ name: "Beginner Developers", slug: "beginners" },
374+
];
375+
376+
if (
377+
data._type === "podcast" &&
378+
!hashnodeTags.some((t) => t.slug === "podcast")
379+
) {
327380
hashnodeTags.push({
328381
name: "podcast",
329382
slug: "podcast",
@@ -343,7 +396,9 @@ export const handler = documentEventHandler<ContentDocument>(
343396
publicationId,
344397
slug: `${data._type}-${slug}`,
345398
tags: hashnodeTags,
346-
...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}),
399+
...(hashnodeCoverImageUrl
400+
? { coverImageOptions: { coverImageURL: hashnodeCoverImageUrl } }
401+
: {}),
347402
originalArticleURL: canonicalUrl,
348403
contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`,
349404
};
@@ -364,7 +419,7 @@ export const handler = documentEventHandler<ContentDocument>(
364419

365420
if (existingHashnodeSlug && !existingHashnodeSlug.startsWith("simulated-")) {
366421
// Lookup post ID from Hashnode
367-
const getPostRes = await fetch("https://gql-beta.hashnode.com", {
422+
const getPostRes = await fetch("https://gql.hashnode.com", {
368423
method: "POST",
369424
headers: {
370425
"Content-Type": "application/json",
@@ -373,7 +428,7 @@ export const handler = documentEventHandler<ContentDocument>(
373428
body: JSON.stringify({
374429
operationName: "Publication",
375430
query: `query Publication {
376-
publication(host: "hashnode.codingcat.dev") {
431+
publication(id: "${publicationId}") {
377432
post(slug: "${existingHashnodeSlug}") {
378433
id
379434
}
@@ -390,7 +445,7 @@ export const handler = documentEventHandler<ContentDocument>(
390445
const updateInput = { ...articleInput };
391446
delete updateInput.slug; // slug cannot be updated
392447

393-
const updateRes = await fetch("https://gql-beta.hashnode.com", {
448+
const updateRes = await fetch("https://gql.hashnode.com", {
394449
method: "POST",
395450
headers: {
396451
"Content-Type": "application/json",
@@ -424,7 +479,7 @@ export const handler = documentEventHandler<ContentDocument>(
424479

425480
if (!publishedPost) {
426481
// Create post mutation
427-
const publishRes = await fetch("https://gql-beta.hashnode.com", {
482+
const publishRes = await fetch("https://gql.hashnode.com", {
428483
method: "POST",
429484
headers: {
430485
"Content-Type": "application/json",
@@ -487,6 +542,205 @@ export const handler = documentEventHandler<ContentDocument>(
487542
`[Syndicate Content] Successfully published to Hashnode: ${externalUrl}`,
488543
);
489544
}
545+
546+
if (platform === "awsbuilder") {
547+
const awsSessionToken = process.env.AWS_BUILDER_SESSION_TOKEN;
548+
if (!awsSessionToken) {
549+
console.warn(
550+
"[Syndicate Content] AWS_BUILDER_SESSION_TOKEN not configured. Skipping AWS Builder Center dispatch.",
551+
);
552+
continue;
553+
}
554+
555+
const awsHeaders = {
556+
"Content-Type": "application/json",
557+
Accept: "application/json",
558+
"builder-session-token": awsSessionToken,
559+
};
560+
561+
const existingSyndication = await client
562+
.getDocument(syndicationDocId)
563+
.catch(() => null);
564+
565+
const urlContentIdMatch = data.awsbuilder?.match(
566+
/builder\.aws\.com\/content\/([A-Za-z0-9]+)/,
567+
);
568+
let contentId =
569+
(existingSyndication?.externalId &&
570+
!existingSyndication.externalId.startsWith("simulated-")
571+
? existingSyndication.externalId
572+
: null) ||
573+
(urlContentIdMatch ? urlContentIdMatch[1] : null);
574+
let versionId: string | null = null;
575+
576+
if (contentId) {
577+
const editInitRes = await fetch(
578+
"https://api.builder.aws.com/cs/article/InitializeEditArticle",
579+
{
580+
method: "POST",
581+
headers: awsHeaders,
582+
body: JSON.stringify({ contentId }),
583+
},
584+
);
585+
if (editInitRes.ok) {
586+
const editInitJson = (await editInitRes.json()) as any;
587+
versionId = editInitJson?.versionId || null;
588+
}
589+
}
590+
591+
if (!contentId || !versionId) {
592+
const initRes = await fetch(
593+
"https://api.builder.aws.com/cs/article/InitializeArticle",
594+
{
595+
method: "POST",
596+
headers: awsHeaders,
597+
body: JSON.stringify({}),
598+
},
599+
);
600+
if (!initRes.ok) {
601+
throw new Error(
602+
`AWS Builder InitializeArticle failed (${initRes.status}): ${await initRes.text()}`,
603+
);
604+
}
605+
const initJson = (await initRes.json()) as any;
606+
contentId = initJson?.contentId;
607+
versionId = initJson?.versionId;
608+
}
609+
610+
// Upload cover image if present
611+
let coverImageUploadKey: string | undefined;
612+
const coverImageUrl =
613+
urlForImage(data.coverImage)?.width(1280).format("jpg").url() || "";
614+
if (coverImageUrl) {
615+
const imgRes = await fetch(coverImageUrl);
616+
if (imgRes.ok) {
617+
const imgBuf = await imgRes.arrayBuffer();
618+
const presignRes = await fetch(
619+
"https://api.builder.aws.com/cs/upload/generate-presigned-post",
620+
{
621+
method: "POST",
622+
headers: awsHeaders,
623+
body: JSON.stringify({
624+
contentId,
625+
fileName: `${slug}-cover.jpg`,
626+
contentType: "image/jpeg",
627+
uploadType: "ARTICLE",
628+
}),
629+
},
630+
);
631+
if (presignRes.ok) {
632+
const presignData = (await presignRes.json()) as any;
633+
const formData = new FormData();
634+
for (const [k, v] of Object.entries(
635+
presignData.presignedPostFields || {},
636+
)) {
637+
formData.append(k, String(v));
638+
}
639+
formData.append(
640+
"file",
641+
new Blob([imgBuf], { type: "image/jpeg" }),
642+
`${slug}-cover.jpg`,
643+
);
644+
const s3Res = await fetch(presignData.presignedPostUrl, {
645+
method: "POST",
646+
body: formData,
647+
});
648+
if (s3Res.ok || s3Res.status === 204) {
649+
coverImageUploadKey = presignData.uploadKey;
650+
}
651+
}
652+
}
653+
}
654+
655+
const awsTags = Array.from(
656+
new Set(
657+
docTags
658+
.map((t) => (t?.value || t?.label || "").toLowerCase())
659+
.filter(Boolean)
660+
.slice(0, 5),
661+
),
662+
);
663+
if (awsTags.length === 0) awsTags.push("aws");
664+
665+
const youtubeMd = data.youtube
666+
? `Watch on YouTube: [${data.youtube}](${data.youtube})\n\n`
667+
: "";
668+
669+
const draftPayload: any = {
670+
contentId,
671+
versionId,
672+
title: data.title || "Untitled",
673+
summary: (data.excerpt || data.title || "").slice(0, 300),
674+
contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${contentMarkdown}`,
675+
canonicalUrl,
676+
preferredUrlSlug: slug,
677+
tags: awsTags,
678+
autoSave: false,
679+
...(coverImageUploadKey
680+
? {
681+
coverImageUploadKey,
682+
coverImageAlt: data.title || "Cover image",
683+
}
684+
: {}),
685+
};
686+
687+
const saveRes = await fetch(
688+
"https://api.builder.aws.com/cs/article/SaveArticleDraft",
689+
{
690+
method: "POST",
691+
headers: awsHeaders,
692+
body: JSON.stringify(draftPayload),
693+
},
694+
);
695+
if (!saveRes.ok) {
696+
throw new Error(
697+
`AWS Builder SaveArticleDraft failed (${saveRes.status}): ${await saveRes.text()}`,
698+
);
699+
}
700+
701+
await fetch("https://api.builder.aws.com/cs/article/SubmissionCheck", {
702+
method: "POST",
703+
headers: awsHeaders,
704+
body: JSON.stringify({ contentId, versionId }),
705+
});
706+
707+
const pubRes = await fetch(
708+
"https://api.builder.aws.com/cs/article/PublishArticle",
709+
{
710+
method: "POST",
711+
headers: awsHeaders,
712+
body: JSON.stringify({ contentId, versionId }),
713+
},
714+
);
715+
if (!pubRes.ok) {
716+
throw new Error(
717+
`AWS Builder PublishArticle failed (${pubRes.status}): ${await pubRes.text()}`,
718+
);
719+
}
720+
721+
const externalUrl = `https://builder.aws.com/content/${contentId}/${slug}`;
722+
723+
await client.createOrReplace({
724+
_id: syndicationDocId,
725+
_type: "syndication",
726+
target: { _type: "reference", _ref: data._id },
727+
platform: "awsbuilder",
728+
status: "published",
729+
canonicalUrl,
730+
externalUrl,
731+
externalId: String(contentId),
732+
syndicatedAt: new Date().toISOString(),
733+
error: undefined,
734+
});
735+
736+
if (data.awsbuilder !== externalUrl) {
737+
parentPatch.awsbuilder = externalUrl;
738+
}
739+
740+
console.log(
741+
`[Syndicate Content] Successfully published to AWS Builder Center: ${externalUrl}`,
742+
);
743+
}
490744
} catch (publishErr) {
491745
const errMsg =
492746
publishErr instanceof Error ? publishErr.message : String(publishErr);

‎apps/sanity/schemas/documents/syndication.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export default defineType({
2222
list: [
2323
{ title: "Dev.to", value: "devto" },
2424
{ title: "Hashnode", value: "hashnode" },
25+
{ title: "AWS Builder Center", value: "awsbuilder" },
26+
{ title: "daily.dev Squad", value: "dailydev" },
2527
],
2628
},
2729
validation: (rule) => rule.required(),

0 commit comments

Comments
 (0)