diff --git a/.github/workflows/syndicate-post.yml b/.github/workflows/syndicate-post.yml index bf52c4f7..680efb64 100644 --- a/.github/workflows/syndicate-post.yml +++ b/.github/workflows/syndicate-post.yml @@ -48,3 +48,5 @@ jobs: PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }} DEVTO_API_KEY: ${{ secrets.PRIVATE_DEVTO }} HASHNODE_ACCESS_TOKEN: ${{ secrets.PRIVATE_HASHNODE }} + AWS_BUILDER_SESSION_TOKEN: ${{ secrets.AWS_BUILDER_SESSION_TOKEN }} + DAILY_DEV_TOKEN: ${{ secrets.DAILY_DEV_TOKEN }} diff --git a/apps/sanity/functions/syndicate-content/index.ts b/apps/sanity/functions/syndicate-content/index.ts index d3b1281f..345cb9e1 100644 --- a/apps/sanity/functions/syndicate-content/index.ts +++ b/apps/sanity/functions/syndicate-content/index.ts @@ -16,6 +16,9 @@ interface ContentDocument { episode?: number; devto?: string; hashnode?: string; + awsbuilder?: string; + tags?: Array<{ _key?: string; label?: string; value?: string }>; + sponsor?: Array<{ _ref: string }>; syndications?: Array<{ _type: "reference"; _ref: string; _key?: string }>; } @@ -104,10 +107,25 @@ export const handler = documentEventHandler( }; const contentMarkdown = serializeContent(data.content); - const platforms: Array<"devto" | "hashnode"> = ["devto", "hashnode"]; + const docTags = Array.isArray(data.tags) ? data.tags : []; + const hasAwsTag = docTags.some( + (t) => + t?.value?.toLowerCase() === "aws" || t?.label?.toLowerCase() === "aws", + ); + const isSponsored = Array.isArray(data.sponsor) && data.sponsor.length > 0; + const shouldSyndicateAws = + (hasAwsTag && !isSponsored) || + Boolean(data.awsbuilder) || + process.env.FORCE_AWS_BUILDER === "true"; + + const platforms: Array<"devto" | "hashnode" | "awsbuilder"> = [ + "devto", + "hashnode", + ...(shouldSyndicateAws ? (["awsbuilder"] as const) : []), + ]; console.log( - `[Syndicate Content] Processing ${data._type} (${data._id}) on dataset: ${dataset}. Production mode: ${isProduction}`, + `[Syndicate Content] Processing ${data._type} (${data._id}) on dataset: ${dataset}. Production mode: ${isProduction}. Platforms: ${platforms.join(", ")}`, ); const parentPatch: Record = {}; @@ -122,6 +140,12 @@ export const handler = documentEventHandler( ); try { + const simHost = + platform === "devto" + ? "dev.to" + : platform === "hashnode" + ? "hashnode.com" + : "builder.aws.com"; await client.createOrReplace({ _id: syndicationDocId, _type: "syndication", @@ -132,7 +156,7 @@ export const handler = documentEventHandler( platform, status: "simulated_complete", canonicalUrl, - externalUrl: `https://${platform === "devto" ? "dev.to" : "hashnode.com"}/preview-simulated/${slug}`, + externalUrl: `https://${simHost}/preview-simulated/${slug}`, externalId: `simulated-${data._id}-${platform}`, syndicatedAt: new Date().toISOString(), error: undefined, @@ -170,10 +194,22 @@ export const handler = documentEventHandler( continue; } - const devtoTags = ["webdev", "javascript", "beginners"]; - if (data._type === "podcast") { - devtoTags.push("podcast"); - } + const normalizedDocTags = docTags + .map((t) => + (t?.value || t?.label || "") + .toLowerCase() + .replace(/[^a-z0-9]/g, ""), + ) + .filter((v) => v.length >= 2 && v.length <= 20); + + const devtoTags = Array.from( + new Set([ + ...(normalizedDocTags.length > 0 + ? normalizedDocTags.slice(0, 3) + : ["webdev", "javascript", "programming"]), + ...(data._type === "podcast" ? ["podcast"] : []), + ]), + ).slice(0, 4); const mainImageUrl = urlForImage(data.coverImage)?.width(1280).url() || ""; @@ -317,13 +353,30 @@ export const handler = documentEventHandler( ? data.excerpt.substring(0, 247) + "..." : data.excerpt || ""; - const hashnodeTags: Array<{ name: string; slug: string }> = [ - { name: "JavaScript", slug: "javascript" }, - { name: "Web Development", slug: "web-development" }, - { name: "Beginner Developers", slug: "beginners" }, - ]; - - if (data._type === "podcast") { + const mappedHashnodeTags: Array<{ name: string; slug: string }> = + docTags + .map((t) => ({ + name: t?.label || t?.value || "", + slug: (t?.value || t?.label || "") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-"), + })) + .filter((t) => t.name && t.slug) + .slice(0, 4); + + const hashnodeTags: Array<{ name: string; slug: string }> = + mappedHashnodeTags.length > 0 + ? mappedHashnodeTags + : [ + { name: "JavaScript", slug: "javascript" }, + { name: "Web Development", slug: "web-development" }, + { name: "Beginner Developers", slug: "beginners" }, + ]; + + if ( + data._type === "podcast" && + !hashnodeTags.some((t) => t.slug === "podcast") + ) { hashnodeTags.push({ name: "podcast", slug: "podcast", @@ -343,7 +396,9 @@ export const handler = documentEventHandler( publicationId, slug: `${data._type}-${slug}`, tags: hashnodeTags, - ...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}), + ...(hashnodeCoverImageUrl + ? { coverImageOptions: { coverImageURL: hashnodeCoverImageUrl } } + : {}), originalArticleURL: canonicalUrl, contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, }; @@ -364,7 +419,7 @@ export const handler = documentEventHandler( if (existingHashnodeSlug && !existingHashnodeSlug.startsWith("simulated-")) { // Lookup post ID from Hashnode - const getPostRes = await fetch("https://gql-beta.hashnode.com", { + const getPostRes = await fetch("https://gql.hashnode.com", { method: "POST", headers: { "Content-Type": "application/json", @@ -373,7 +428,7 @@ export const handler = documentEventHandler( body: JSON.stringify({ operationName: "Publication", query: `query Publication { - publication(host: "hashnode.codingcat.dev") { + publication(id: "${publicationId}") { post(slug: "${existingHashnodeSlug}") { id } @@ -390,7 +445,7 @@ export const handler = documentEventHandler( const updateInput = { ...articleInput }; delete updateInput.slug; // slug cannot be updated - const updateRes = await fetch("https://gql-beta.hashnode.com", { + const updateRes = await fetch("https://gql.hashnode.com", { method: "POST", headers: { "Content-Type": "application/json", @@ -424,7 +479,7 @@ export const handler = documentEventHandler( if (!publishedPost) { // Create post mutation - const publishRes = await fetch("https://gql-beta.hashnode.com", { + const publishRes = await fetch("https://gql.hashnode.com", { method: "POST", headers: { "Content-Type": "application/json", @@ -487,6 +542,205 @@ export const handler = documentEventHandler( `[Syndicate Content] Successfully published to Hashnode: ${externalUrl}`, ); } + + if (platform === "awsbuilder") { + const awsSessionToken = process.env.AWS_BUILDER_SESSION_TOKEN; + if (!awsSessionToken) { + console.warn( + "[Syndicate Content] AWS_BUILDER_SESSION_TOKEN not configured. Skipping AWS Builder Center dispatch.", + ); + continue; + } + + const awsHeaders = { + "Content-Type": "application/json", + Accept: "application/json", + "builder-session-token": awsSessionToken, + }; + + const existingSyndication = await client + .getDocument(syndicationDocId) + .catch(() => null); + + const urlContentIdMatch = data.awsbuilder?.match( + /builder\.aws\.com\/content\/([A-Za-z0-9]+)/, + ); + let contentId = + (existingSyndication?.externalId && + !existingSyndication.externalId.startsWith("simulated-") + ? existingSyndication.externalId + : null) || + (urlContentIdMatch ? urlContentIdMatch[1] : null); + let versionId: string | null = null; + + if (contentId) { + const editInitRes = await fetch( + "https://api.builder.aws.com/cs/article/InitializeEditArticle", + { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId }), + }, + ); + if (editInitRes.ok) { + const editInitJson = (await editInitRes.json()) as any; + versionId = editInitJson?.versionId || null; + } + } + + if (!contentId || !versionId) { + const initRes = await fetch( + "https://api.builder.aws.com/cs/article/InitializeArticle", + { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({}), + }, + ); + if (!initRes.ok) { + throw new Error( + `AWS Builder InitializeArticle failed (${initRes.status}): ${await initRes.text()}`, + ); + } + const initJson = (await initRes.json()) as any; + contentId = initJson?.contentId; + versionId = initJson?.versionId; + } + + // Upload cover image if present + let coverImageUploadKey: string | undefined; + const coverImageUrl = + urlForImage(data.coverImage)?.width(1280).format("jpg").url() || ""; + if (coverImageUrl) { + const imgRes = await fetch(coverImageUrl); + if (imgRes.ok) { + const imgBuf = await imgRes.arrayBuffer(); + const presignRes = await fetch( + "https://api.builder.aws.com/cs/upload/generate-presigned-post", + { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ + contentId, + fileName: `${slug}-cover.jpg`, + contentType: "image/jpeg", + uploadType: "ARTICLE", + }), + }, + ); + if (presignRes.ok) { + const presignData = (await presignRes.json()) as any; + const formData = new FormData(); + for (const [k, v] of Object.entries( + presignData.presignedPostFields || {}, + )) { + formData.append(k, String(v)); + } + formData.append( + "file", + new Blob([imgBuf], { type: "image/jpeg" }), + `${slug}-cover.jpg`, + ); + const s3Res = await fetch(presignData.presignedPostUrl, { + method: "POST", + body: formData, + }); + if (s3Res.ok || s3Res.status === 204) { + coverImageUploadKey = presignData.uploadKey; + } + } + } + } + + const awsTags = Array.from( + new Set( + docTags + .map((t) => (t?.value || t?.label || "").toLowerCase()) + .filter(Boolean) + .slice(0, 5), + ), + ); + if (awsTags.length === 0) awsTags.push("aws"); + + const youtubeMd = data.youtube + ? `Watch on YouTube: [${data.youtube}](${data.youtube})\n\n` + : ""; + + const draftPayload: any = { + contentId, + versionId, + title: data.title || "Untitled", + summary: (data.excerpt || data.title || "").slice(0, 300), + contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${contentMarkdown}`, + canonicalUrl, + preferredUrlSlug: slug, + tags: awsTags, + autoSave: false, + ...(coverImageUploadKey + ? { + coverImageUploadKey, + coverImageAlt: data.title || "Cover image", + } + : {}), + }; + + const saveRes = await fetch( + "https://api.builder.aws.com/cs/article/SaveArticleDraft", + { + method: "POST", + headers: awsHeaders, + body: JSON.stringify(draftPayload), + }, + ); + if (!saveRes.ok) { + throw new Error( + `AWS Builder SaveArticleDraft failed (${saveRes.status}): ${await saveRes.text()}`, + ); + } + + await fetch("https://api.builder.aws.com/cs/article/SubmissionCheck", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId, versionId }), + }); + + const pubRes = await fetch( + "https://api.builder.aws.com/cs/article/PublishArticle", + { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId, versionId }), + }, + ); + if (!pubRes.ok) { + throw new Error( + `AWS Builder PublishArticle failed (${pubRes.status}): ${await pubRes.text()}`, + ); + } + + const externalUrl = `https://builder.aws.com/content/${contentId}/${slug}`; + + await client.createOrReplace({ + _id: syndicationDocId, + _type: "syndication", + target: { _type: "reference", _ref: data._id }, + platform: "awsbuilder", + status: "published", + canonicalUrl, + externalUrl, + externalId: String(contentId), + syndicatedAt: new Date().toISOString(), + error: undefined, + }); + + if (data.awsbuilder !== externalUrl) { + parentPatch.awsbuilder = externalUrl; + } + + console.log( + `[Syndicate Content] Successfully published to AWS Builder Center: ${externalUrl}`, + ); + } } catch (publishErr) { const errMsg = publishErr instanceof Error ? publishErr.message : String(publishErr); diff --git a/apps/sanity/schemas/documents/syndication.ts b/apps/sanity/schemas/documents/syndication.ts index 03547ee9..a0354c10 100644 --- a/apps/sanity/schemas/documents/syndication.ts +++ b/apps/sanity/schemas/documents/syndication.ts @@ -22,6 +22,8 @@ export default defineType({ list: [ { title: "Dev.to", value: "devto" }, { title: "Hashnode", value: "hashnode" }, + { title: "AWS Builder Center", value: "awsbuilder" }, + { title: "daily.dev Squad", value: "dailydev" }, ], }, validation: (rule) => rule.required(), diff --git a/apps/sanity/schemas/partials/content.ts b/apps/sanity/schemas/partials/content.ts index 1bf1259d..8e9b2eb8 100644 --- a/apps/sanity/schemas/partials/content.ts +++ b/apps/sanity/schemas/partials/content.ts @@ -81,13 +81,36 @@ const content = defineType({ type: "string", group: "data", }), + defineField({ + name: "awsbuilder", + title: "AWS Builder Center", + type: "url", + group: "data", + }), + defineField({ + name: "tags", + title: "Tags", + description: + "Topic, programming language, and company tags used for filtering and platform syndication (e.g., AWS Builder Center requires the AWS tag).", + type: "array", + of: [ + { + type: "object", + name: "tag", + fields: [ + defineField({ name: "label", title: "Label", type: "string" }), + defineField({ name: "value", title: "Value", type: "string" }), + ], + }, + ], + }), defineField({ name: "syndications", title: "Syndications", type: "array", group: "data", of: [{ type: "reference", to: [{ type: "syndication" }] }], - description: "Associated cross-platform syndication records (Dev.to, Hashnode, etc.)", + description: "Associated cross-platform syndication records (Dev.to, Hashnode, AWS Builder Center, etc.)", readOnly: true, }), defineField({ @@ -121,22 +144,6 @@ const content = defineType({ }), // Legacy fields retained as hidden/readOnly so existing production documents // do not trigger "Unknown fields found" warnings in Sanity Studio. - defineField({ - name: "tags", - type: "array", - of: [ - { - type: "object", - name: "tag", - fields: [ - defineField({ name: "label", type: "string" }), - defineField({ name: "value", type: "string" }), - ], - }, - ], - hidden: true, - readOnly: true, - }), defineField({ name: "views", type: "number", diff --git a/apps/site/src/lib/sanity/queries/feeds.ts b/apps/site/src/lib/sanity/queries/feeds.ts index 6a2ea210..08f3a23c 100644 --- a/apps/site/src/lib/sanity/queries/feeds.ts +++ b/apps/site/src/lib/sanity/queries/feeds.ts @@ -21,6 +21,12 @@ export const rssPodcastQuery = spotify, }`); +export const rssAllQuery = + defineQuery(`*[_type in ["post", "podcast"] && _id != $skip && defined(slug.current)] | order(date desc) [$offset...$limit] { + ${baseFieldsNoContent}, + ${rssContentFields}, +}`); + export const sitemapQuery = defineQuery(`*[_type in ["author", "guest", "page", "podcast", "post", "sponsor"] && defined(slug.current)] | order(_type asc) | order(_updatedAt desc) { _type, diff --git a/apps/site/src/pages/all/rss.xml.ts b/apps/site/src/pages/all/rss.xml.ts new file mode 100644 index 00000000..c09786a7 --- /dev/null +++ b/apps/site/src/pages/all/rss.xml.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from "astro"; +import { buildRss2, FEED_LIMIT, type FeedItemSource } from "@/lib/feeds"; +import { rssAllQuery } from "@/lib/sanity/queries"; + +export const GET: APIRoute = async ({ locals }) => { + // Unified RSS 2.0 feed containing both blog posts and podcasts sorted by date. + // Ideal for syndication platforms (e.g., daily.dev Squads) that expect standard + // article RSS 2.0 items with cover image enclosures and HTML content. + const items = await locals.sanity.fetchPublished(rssAllQuery, { + skip: "none", + offset: 0, + limit: FEED_LIMIT, + }); + + const body = buildRss2({ + origin: locals.siteUrl.origin, + sanity: locals.sanity, + type: "post", + items: items as FeedItemSource[], + now: new Date(), + }); + + return new Response(body, { + headers: { + "content-type": "application/rss+xml; charset=utf-8", + "cache-control": "max-age=0, s-maxage=3600", + }, + }); +}; diff --git a/scripts/auto-tag-from-transcripts.mjs b/scripts/auto-tag-from-transcripts.mjs new file mode 100644 index 00000000..37b98199 --- /dev/null +++ b/scripts/auto-tag-from-transcripts.mjs @@ -0,0 +1,769 @@ +import { createClient } from "@sanity/client"; + +const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w"; +const dataset = process.env.SANITY_STUDIO_DATASET || "production"; +const token = + process.env.SANITY_API_WRITE_TOKEN || process.env.SANITY_AUTH_TOKEN; +const isDryRun = process.argv.includes("--dry-run") || !token; + +const client = createClient({ + projectId, + dataset, + ...(token ? { token } : {}), + apiVersion: "2025-09-30", + useCdn: false, +}); + +/** + * Curated taxonomy of Companies/Platforms and Programming Languages/Frameworks/Topics. + * - `strongPatterns`: matching once in title/excerpt/summary/tags/content or >= 2 times in fullText triggers the tag. + * - `contextPatterns`: matching in title/excerpt/tags or >= 3 times in fullText triggers the tag. + */ +export const TAG_TAXONOMY = [ + // --- Cloud & Infrastructure Companies --- + { + label: "AWS", + value: "aws", + category: "company", + strongPatterns: [ + /\baws\b/i, + /\bamazon web services\b/i, + /\bamazon bedrock\b/i, + /\baws amplify\b/i, + /\baws lambda\b/i, + /\baws cdk\b/i, + /\baws sam\b/i, + /\bappsync\b/i, + /\bdynamodb\b/i, + /\bcloudformation\b/i, + /\blocalstack\b/i, + /\bsagemaker\b/i, + /\bamazon s3\b/i, + /\bamazon cognito\b/i, + /\baws community builder/i, + ], + }, + { + label: "Google Cloud", + value: "google-cloud", + category: "company", + strongPatterns: [ + /\bgoogle cloud\b/i, + /\bgcp\b/i, + /\bvertex ai\b/i, + /\bcloud run\b/i, + /\bbigquery\b/i, + ], + }, + { + label: "Firebase", + value: "firebase", + category: "company", + strongPatterns: [ + /\bfirebase\b/i, + /\bfirestore\b/i, + /\bgenkit\b/i, + /\bproject idx\b/i, + ], + }, + { + label: "Cloudflare", + value: "cloudflare", + category: "company", + strongPatterns: [ + /\bcloudflare\b/i, + /\bcloudflare workers\b/i, + /\bwrangler\b/i, + /\bcloudflare pages\b/i, + /\bcloudflare d1\b/i, + /\bcloudflare r2\b/i, + ], + }, + { + label: "Microsoft Azure", + value: "azure", + category: "company", + strongPatterns: [ + /\bmicrosoft azure\b/i, + /\bazure\b/i, + /\bcosmos db\b/i, + ], + }, + { + label: "Vercel", + value: "vercel", + category: "company", + strongPatterns: [/\bvercel\b/i, /\bturborepo\b/i, /\bv0\.dev\b/i], + }, + { + label: "Netlify", + value: "netlify", + category: "company", + strongPatterns: [/\bnetlify\b/i], + }, + { + label: "Sanity", + value: "sanity", + category: "company", + strongPatterns: [ + /\bsanity\.io\b/i, + /\bsanity studio\b/i, + /\bsanity cms\b/i, + /\bportable text\b/i, + /\bgroq\b/i, + ], + }, + { + label: "Supabase", + value: "supabase", + category: "company", + strongPatterns: [/\bsupabase\b/i], + }, + { + label: "Appwrite", + value: "appwrite", + category: "company", + strongPatterns: [/\bappwrite\b/i], + }, + { + label: "Convex", + value: "convex", + category: "company", + strongPatterns: [/\bconvex\.dev\b/i, /\bconvex\b/i], + }, + { + label: "Builder.io", + value: "builder-io", + category: "company", + strongPatterns: [ + /\bbuilder\.io\b/i, + /\bqwik\b/i, + /\bmitosis\b/i, + /\bpartytown\b/i, + ], + }, + { + label: "Algolia", + value: "algolia", + category: "company", + strongPatterns: [/\balgolia\b/i], + }, + { + label: "Auth0", + value: "auth0", + category: "company", + strongPatterns: [/\bauth0\b/i], + }, + { + label: "Clerk", + value: "clerk", + category: "company", + strongPatterns: [/\bclerk\.com\b/i, /\bclerk\.dev\b/i, /\bclerk auth\b/i], + }, + { + label: "Stripe", + value: "stripe", + category: "company", + strongPatterns: [/\bstripe\b/i], + }, + { + label: "Cloudinary", + value: "cloudinary", + category: "company", + strongPatterns: [/\bcloudinary\b/i], + }, + { + label: "GitHub", + value: "github", + category: "company", + strongPatterns: [ + /\bgithub\b/i, + /\bgithub actions\b/i, + /\bgithub copilot\b/i, + /\bcodespaces\b/i, + ], + }, + { + label: "Docker", + value: "docker", + category: "company", + strongPatterns: [/\bdocker\b/i, /\bdockerfile\b/i, /\bkubernetes\b/i, /\bk8s\b/i], + }, + { + label: "Shopify", + value: "shopify", + category: "company", + strongPatterns: [/\bshopify\b/i, /\bshopify hydrogen\b/i], + }, + { + label: "Prisma", + value: "prisma", + category: "company", + strongPatterns: [/\bprisma\b/i], + }, + { + label: "Drizzle", + value: "drizzle", + category: "company", + strongPatterns: [/\bdrizzle orm\b/i, /\bdrizzle\b/i], + }, + { + label: "Turso", + value: "turso", + category: "company", + strongPatterns: [/\bturso\b/i, /\blibsql\b/i], + }, + { + label: "Neon", + value: "neon", + category: "company", + strongPatterns: [/\bneon\.tech\b/i, /\bneon postgres\b/i], + }, + { + label: "MongoDB", + value: "mongodb", + category: "company", + strongPatterns: [/\bmongodb\b/i, /\bmongo atlas\b/i], + }, + { + label: "Redis", + value: "redis", + category: "company", + strongPatterns: [/\bredis\b/i, /\bupstash\b/i], + }, + { + label: "Storyblok", + value: "storyblok", + category: "company", + strongPatterns: [/\bstoryblok\b/i], + }, + { + label: "Contentful", + value: "contentful", + category: "company", + strongPatterns: [/\bcontentful\b/i], + }, + { + label: "Hygraph", + value: "hygraph", + category: "company", + strongPatterns: [/\bhygraph\b/i, /\bgraphcms\b/i], + }, + { + label: "Sentry", + value: "sentry", + category: "company", + strongPatterns: [/\bsentry\.io\b/i, /\bsentry\b/i], + }, + { + label: "Postman", + value: "postman", + category: "company", + strongPatterns: [/\bpostman\b/i], + }, + { + label: "PostHog", + value: "posthog", + category: "company", + strongPatterns: [/\bposthog\b/i], + }, + { + label: "LaunchDarkly", + value: "launchdarkly", + category: "company", + strongPatterns: [/\blaunchdarkly\b/i], + }, + { + label: "AuthZed", + value: "authzed", + category: "company", + strongPatterns: [/\bauthzed\b/i, /\bspicedb\b/i], + }, + + // --- Programming Languages & Frameworks --- + { + label: "JavaScript", + value: "javascript", + category: "language", + strongPatterns: [/\bjavascript\b/i, /\becmascript\b/i, /\bvanilla js\b/i], + }, + { + label: "TypeScript", + value: "typescript", + category: "language", + strongPatterns: [/\btypescript\b/i], + }, + { + label: "React", + value: "react", + category: "framework", + strongPatterns: [ + /\breact\b/i, + /\breactjs\b/i, + /\breact\.js\b/i, + /\breact server components\b/i, + ], + }, + { + label: "Next.js", + value: "nextjs", + category: "framework", + strongPatterns: [/\bnext\.js\b/i, /\bnextjs\b/i], + }, + { + label: "Svelte", + value: "svelte", + category: "framework", + strongPatterns: [/\bsvelte\b/i, /\bsveltekit\b/i], + }, + { + label: "Vue", + value: "vue", + category: "framework", + strongPatterns: [/\bvue\.js\b/i, /\bvuejs\b/i, /\bnuxt\b/i, /\bnuxtjs\b/i], + }, + { + label: "Angular", + value: "angular", + category: "framework", + strongPatterns: [/\bangular\b/i, /\banalogjs\b/i, /\bngrx\b/i], + }, + { + label: "Astro", + value: "astro", + category: "framework", + strongPatterns: [/\bastro\.build\b/i, /\bastrojs\b/i, /\bastro\b/i], + }, + { + label: "Remix", + value: "remix", + category: "framework", + strongPatterns: [/\bremix\.run\b/i, /\bremix\b/i], + }, + { + label: "SolidJS", + value: "solidjs", + category: "framework", + strongPatterns: [/\bsolidjs\b/i, /\bsolid\.js\b/i, /\bsolidstart\b/i], + }, + { + label: "Node.js", + value: "nodejs", + category: "language", + strongPatterns: [/\bnode\.js\b/i, /\bnodejs\b/i, /\bdeno\b/i, /\bbun\.sh\b/i], + }, + { + label: "Python", + value: "python", + category: "language", + strongPatterns: [/\bpython\b/i, /\bfastapi\b/i, /\bdjango\b/i, /\bpytorch\b/i], + }, + { + label: "Rust", + value: "rust", + category: "language", + strongPatterns: [/\brust\b/i, /\brustlang\b/i, /\btauri\b/i, /\bwebassembly\b/i, /\bwasm\b/i], + }, + { + label: "Go", + value: "go", + category: "language", + strongPatterns: [/\bgolang\b/i], + }, + { + label: "PHP", + value: "php", + category: "language", + strongPatterns: [/\bphp\b/i, /\blaravel\b/i, /\bwordpress\b/i, /\bsymfony\b/i, /\bzend engine\b/i], + }, + { + label: "C#", + value: "csharp", + category: "language", + strongPatterns: [/\bc#\b/i, /\bdotnet\b/i, /\.net\b/i, /\bblazor\b/i], + }, + { + label: "Flutter", + value: "flutter", + category: "framework", + strongPatterns: [/\bflutter\b/i, /\bdart\b/i], + }, + { + label: "React Native", + value: "react-native", + category: "framework", + strongPatterns: [/\breact native\b/i, /\bexpo\b/i], + }, + { + label: "Tailwind CSS", + value: "tailwindcss", + category: "framework", + strongPatterns: [/\btailwind\b/i, /\btailwindcss\b/i, /\bdaisyui\b/i], + }, + { + label: "CSS", + value: "css", + category: "language", + strongPatterns: [/\bcss\b/i, /\bcss3\b/i, /\bsass\b/i, /\bscss\b/i], + }, + { + label: "HTML", + value: "html", + category: "language", + strongPatterns: [/\bhtml5?\b/i, /\bweb components\b/i, /\bhtmx\b/i], + }, + { + label: "GraphQL", + value: "graphql", + category: "language", + strongPatterns: [/\bgraphql\b/i, /\bapollo\b/i], + }, + { + label: "SQL", + value: "sql", + category: "language", + strongPatterns: [/\bpostgres\b/i, /\bpostgresql\b/i, /\bmysql\b/i, /\bsqlite\b/i, /\bsql\b/i], + }, + { + label: "AI", + value: "ai", + category: "topic", + strongPatterns: [ + /\b(?:artificial intelligence|genai|generative ai|llm|llms|openai|chatgpt|anthropic|claude|gemini|langchain|vector database|rag|machine learning|ai agent|ai agents)\b/i, + ], + }, + { + label: "MCP", + value: "mcp", + category: "topic", + strongPatterns: [/\bmodel context protocol\b/i, /\bmcp server\b/i, /\bmcp\b/i], + }, + { + label: "Security", + value: "security", + category: "topic", + strongPatterns: [ + /\bcybersecurity\b/i, + /\bcve-\d{4}-\d+/i, + /\bvulnerability\b/i, + /\bauthentication\b/i, + /\bauthorization\b/i, + /\boauth\b/i, + /\bpasskeys?\b/i, + /\brbac\b/i, + /\bzero trust\b/i, + ], + }, + { + label: "Serverless", + value: "serverless", + category: "topic", + strongPatterns: [/\bserverless\b/i, /\bjamstack\b/i, /\bedge functions?\b/i], + }, + { + label: "DevOps", + value: "devops", + category: "topic", + strongPatterns: [/\bdevops\b/i, /\bci\/cd\b/i, /\bterraform\b/i, /\bobservability\b/i, /\bmonitoring\b/i], + }, + { + label: "Accessibility", + value: "a11y", + category: "topic", + strongPatterns: [/\baccessibility\b/i, /\ba11y\b/i, /\bwcag\b/i], + }, + { + label: "Design", + value: "design", + category: "topic", + strongPatterns: [/\bfigma\b/i, /\bdesign system/i, /\bui\/ux\b/i], + }, + { + label: "Career", + value: "career", + category: "topic", + strongPatterns: [/\bdevrel\b/i, /\bdeveloper relations\b/i, /\bdeveloper advocacy\b/i, /\btech career\b/i], + }, +]; + +function extractPortableTextPlain(blocks) { + if (!Array.isArray(blocks)) return ""; + const parts = []; + for (const b of blocks) { + if (b?._type === "block" && Array.isArray(b.children)) { + parts.push(b.children.map((c) => c.text || "").join("")); + } else if (b?._type === "code" && b.code) { + parts.push(b.language || ""); + } + } + return parts.join("\n"); +} + +function extractYouTubeVideoId(url) { + if (!url || typeof url !== "string") return null; + const m = + url.match(/(?:v=|youtu\.be\/|embed\/|live\/|shorts\/)([A-Za-z0-9_-]{11})/) || + url.match(/^([A-Za-z0-9_-]{11})$/); + return m ? m[1] : null; +} + +function countPatternMatches(text, patterns) { + if (!text) return 0; + let total = 0; + for (const p of patterns) { + const globalRegex = new RegExp( + p.source, + p.flags.includes("g") ? p.flags : p.flags + "g", + ); + const matches = text.match(globalRegex); + if (matches) total += matches.length; + } + return total; +} + +function stripSponsorBoilerplate(text) { + if (!text) return ""; + return text + .replace(/Sponsors?:[\s\S]*?(?=Recorded:|\n00:00|$)/gi, "") + .replace(/^\d{1,2}:\d{2}(?::\d{2})?\s*[-–]?\s*Sponsors?:.*$/gim, "") + .replace( + /brought to you by (?:cloudinary|algolia|storyblok|builder\.io|auth0|fauna|supabase)[\s\S]{0,300}/gi, + "", + ) + .replace(/Have you already discovered Storyblok\?[\s\S]{0,300}/gi, ""); +} + +export function inferTagsForDocument(doc, transcript) { + const existingTags = Array.isArray(doc.tags) ? doc.tags : []; + const byValue = new Map(); + + for (const t of existingTags) { + const val = (t?.value || t?.label || "").trim().toLowerCase(); + const label = (t?.label || t?.value || "").trim(); + if (val && label) { + byValue.set(val, { + _key: t._key || `tag_${val.replace(/[^a-z0-9]/g, "_")}`, + _type: "tag", + label, + value: val, + score: 100, + }); + } + } + + const titleAndSlug = [ + doc.title || "", + typeof doc.slug === "object" ? doc.slug?.current || "" : doc.slug || "", + transcript?.title || "", + ].join("\n"); + + const ytTagsText = Array.isArray(transcript?.tags) + ? transcript.tags.join("\n") + : ""; + + const excerptAndDesc = stripSponsorBoilerplate( + [doc.excerpt || "", transcript?.description || ""].join("\n"), + ); + + const bodyText = stripSponsorBoilerplate(extractPortableTextPlain(doc.content)); + const spokenText = stripSponsorBoilerplate(transcript?.fullText || ""); + + const scoredCandidates = []; + + for (const entry of TAG_TAXONOMY) { + if (byValue.has(entry.value)) continue; + + const titleHits = countPatternMatches(titleAndSlug, entry.strongPatterns); + const ytTagHits = countPatternMatches(ytTagsText, entry.strongPatterns); + const descHits = countPatternMatches(excerptAndDesc, entry.strongPatterns); + const bodyHits = countPatternMatches(bodyText, entry.strongPatterns); + const spokenHits = countPatternMatches(spokenText, entry.strongPatterns); + + const score = + (titleHits > 0 ? 10 : 0) + + (ytTagHits > 0 ? 6 : 0) + + (descHits > 0 ? 5 : 0) + + Math.min(8, bodyHits * 2) + + Math.floor(spokenHits / 3); + + if (score >= 5) { + scoredCandidates.push({ + _key: `tag_${entry.value.replace(/[^a-z0-9]/g, "_")}`, + _type: "tag", + label: entry.label, + value: entry.value, + score, + }); + } + } + + scoredCandidates.sort((a, b) => b.score - a.score); + + for (const cand of scoredCandidates) { + if (byValue.size >= 8) break; + byValue.set(cand.value, cand); + } + + // Ensure WebDev fallback tag if nothing else matched + if (byValue.size === 0) { + byValue.set("webdev", { + _key: "tag_webdev", + _type: "tag", + label: "WebDev", + value: "webdev", + score: 1, + }); + } + + return Array.from(byValue.values()).map(({ _key, _type, label, value }) => ({ + _key, + _type, + label, + value, + })); +} + +export async function autoTagAllContent({ dryRun = isDryRun } = {}) { + console.log( + `\n=== Running Transcript & Content Auto-Tagger (dataset: ${dataset}, dryRun: ${dryRun}) ===`, + ); + + const docs = await client.fetch( + `*[_type in ["post", "podcast"] && !(_id in path("drafts.**"))]{ + _id, + _type, + title, + "slug": slug.current, + excerpt, + youtube, + tags, + transcript, + sponsor, + content, + devto, + hashnode, + awsbuilder + }`, + ); + + const transcripts = await client.fetch( + `*[_type == "transcript" && !(_id in path("drafts.**"))]{ + _id, + videoId, + title, + description, + tags, + topicCategories, + summary, + fullText, + contentRef + }`, + ); + + console.log( + `Loaded ${docs.length} content documents and ${transcripts.length} transcripts.`, + ); + + const transcriptById = new Map(); + const transcriptByContentId = new Map(); + const transcriptByVideoId = new Map(); + + for (const tr of transcripts) { + transcriptById.set(tr._id, tr); + if (tr.contentRef?._ref) { + transcriptByContentId.set(tr.contentRef._ref, tr); + } + if (tr.videoId) { + transcriptByVideoId.set(tr.videoId, tr); + } + } + + let updatedCount = 0; + let linkedTranscriptCount = 0; + const tagStats = new Map(); + const awsMatchedDocs = []; + + for (const doc of docs) { + const ytId = extractYouTubeVideoId(doc.youtube); + const linkedTranscript = + (doc.transcript?._ref && transcriptById.get(doc.transcript._ref)) || + transcriptByContentId.get(doc._id) || + (ytId && transcriptByVideoId.get(ytId)) || + null; + + const inferredTags = inferTagsForDocument(doc, linkedTranscript); + + for (const t of inferredTags) { + tagStats.set(t.label, (tagStats.get(t.label) || 0) + 1); + } + + if (inferredTags.some((t) => t.value === "aws")) { + awsMatchedDocs.push({ + _id: doc._id, + _type: doc._type, + title: doc.title, + slug: doc.slug, + hasSponsor: Array.isArray(doc.sponsor) && doc.sponsor.length > 0, + hasTranscript: Boolean(linkedTranscript), + tags: inferredTags.map((t) => t.label), + }); + } + + const prevValues = (doc.tags || []).map((t) => t.value).join(","); + const nextValues = inferredTags.map((t) => t.value).join(","); + const needsTranscriptLink = + linkedTranscript && doc.transcript?._ref !== linkedTranscript._id; + + if (prevValues !== nextValues || needsTranscriptLink) { + updatedCount++; + if (needsTranscriptLink) linkedTranscriptCount++; + + if (!dryRun) { + const patchObj = { tags: inferredTags }; + if (needsTranscriptLink) { + patchObj.transcript = { + _type: "reference", + _ref: linkedTranscript._id, + }; + } + await client + .patch(doc._id) + .set(patchObj) + .commit() + .catch((err) => + console.error(`Failed patching ${doc._id}:`, err.message), + ); + } + } + } + + console.log(`\n=== Auto-Tagging Summary ===`); + console.log(`Total content docs scanned: ${docs.length}`); + console.log(`Docs needing tag/transcript updates: ${updatedCount}`); + console.log(`Docs newly linked to transcripts: ${linkedTranscriptCount}`); + console.log( + `AWS-tagged content found: ${awsMatchedDocs.length} (${awsMatchedDocs.filter((d) => !d.hasSponsor).length} unsponsored & eligible for builder.aws.com)`, + ); + + console.log(`\n=== Top Inferred Tags Across Catalog ===`); + const sortedTags = Array.from(tagStats.entries()).sort((a, b) => b[1] - a[1]); + for (const [label, count] of sortedTags.slice(0, 35)) { + console.log(`- ${label}: ${count}`); + } + + console.log(`\n=== AWS-Tagged Posts & Podcasts ===`); + for (const item of awsMatchedDocs) { + console.log( + `- [${item._type}] ${item.title} (https://codingcat.dev/${item._type}/${item.slug}) | sponsored=${item.hasSponsor} | tags=${item.tags.join(", ")}`, + ); + } + + return { updatedCount, awsMatchedDocs }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + autoTagAllContent().catch((err) => { + console.error("Auto-tag error:", err); + process.exit(1); + }); +} diff --git a/scripts/backfill-content-from-transcripts.mjs b/scripts/backfill-content-from-transcripts.mjs index 2d686d2c..2cc5776d 100644 --- a/scripts/backfill-content-from-transcripts.mjs +++ b/scripts/backfill-content-from-transcripts.mjs @@ -1,5 +1,6 @@ import { createClient } from "@sanity/client"; import { publishPhp2ShellPost } from "./publish-php2shell-post.mjs"; +import { autoTagAllContent } from "./auto-tag-from-transcripts.mjs"; const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w"; const dataset = process.env.SANITY_STUDIO_DATASET || "production"; @@ -177,6 +178,9 @@ async function run() { // 3. Publish PHP2Shell blog post & patch missing excerpts await publishPhp2ShellPost(); + // 4. Auto-tag all posts and podcasts from transcripts & content + await autoTagAllContent({ dryRun: false }); + console.log("Backfill complete!"); } diff --git a/scripts/publish-php2shell-post.mjs b/scripts/publish-php2shell-post.mjs index a23c5705..304cd682 100644 --- a/scripts/publish-php2shell-post.mjs +++ b/scripts/publish-php2shell-post.mjs @@ -456,6 +456,7 @@ sudo systemctl restart php8.4-fpm apache2` author: postDoc.author, coverImage: postDoc.coverImage, content: postDoc.content, + awsbuilder: "https://builder.aws.com/content/3L50qXKr83Y2r9mA6ZsGhcc4cRJ/php-shell-cve-how-ai-found-a-decade-old-bug-in-of-the-web", }, }, }, diff --git a/scripts/run-syndicate-post.mjs b/scripts/run-syndicate-post.mjs index fe7a5afb..589e5d53 100644 --- a/scripts/run-syndicate-post.mjs +++ b/scripts/run-syndicate-post.mjs @@ -77,12 +77,31 @@ async function run() { const contentMarkdown = serializeContent(doc.content); console.log(`Content serialized to Markdown: ${contentMarkdown.length} characters`); + const docTags = Array.isArray(doc.tags) ? doc.tags : []; + const hasAwsTag = docTags.some( + (t) => t?.value?.toLowerCase() === "aws" || t?.label?.toLowerCase() === "aws" + ); + const isSponsored = Array.isArray(doc.sponsor) && doc.sponsor.length > 0; + const shouldSyndicateAws = + (hasAwsTag && !isSponsored) || + Boolean(doc.awsbuilder) || + process.env.FORCE_AWS_BUILDER === "true"; + // --- DEV.TO SYNDICATION --- if (devtoApiKey) { console.log("\n--- Syndicating to Dev.to ---"); const syndicationDocId = `syndication.${doc._id}.devto`; - const devtoTags = ["webdev", "javascript", "beginners"]; - if (doc._type === "podcast") devtoTags.push("podcast"); + const normalizedDocTags = docTags + .map((t) => (t?.value || t?.label || "").toLowerCase().replace(/[^a-z0-9]/g, "")) + .filter((v) => v.length >= 2 && v.length <= 20); + const devtoTags = Array.from( + new Set([ + ...(normalizedDocTags.length > 0 + ? normalizedDocTags.slice(0, 3) + : ["webdev", "javascript", "programming"]), + ...(doc._type === "podcast" ? ["podcast"] : []), + ]) + ).slice(0, 4); const mainImageUrl = urlForImage(doc.coverImage)?.width(1280).url() || ""; const youtubeEmbed = doc.youtube ? `{% youtube ${doc.youtube.replace("live", "embed")} %}\n\n` : ""; @@ -204,11 +223,21 @@ async function run() { ? doc.excerpt.substring(0, 247) + "..." : (doc.excerpt || ""); - const hashnodeTags = [ - { name: "JavaScript", slug: "javascript" }, - { name: "Web Development", slug: "web-development" }, - { name: "Beginner Developers", slug: "beginners" }, - ]; + const mappedHashnodeTags = docTags + .map((t) => ({ + name: t?.label || t?.value || "", + slug: (t?.value || t?.label || "").toLowerCase().replace(/[^a-z0-9-]/g, "-"), + })) + .filter((t) => t.name && t.slug) + .slice(0, 4); + + const hashnodeTags = mappedHashnodeTags.length > 0 + ? mappedHashnodeTags + : [ + { name: "JavaScript", slug: "javascript" }, + { name: "Web Development", slug: "web-development" }, + { name: "Beginner Developers", slug: "beginners" }, + ]; const hashnodeCoverImageUrl = urlForImage(doc.coverImage)?.width(1600).url() || ""; const youtubeEmbed = doc.youtube ? `%[${doc.youtube.replace("live", "embed")}]\n\n` : ""; @@ -219,13 +248,13 @@ async function run() { publicationId, slug: `${doc._type}-${slug}`, tags: hashnodeTags, - ...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}), + ...(hashnodeCoverImageUrl ? { coverImageOptions: { coverImageURL: hashnodeCoverImageUrl } } : {}), originalArticleURL: canonicalUrl, contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, }; try { - const publishRes = await fetch("https://gql-beta.hashnode.com", { + const publishRes = await fetch("https://gql.hashnode.com", { method: "POST", headers: { "Content-Type": "application/json", @@ -278,9 +307,154 @@ async function run() { } } + // --- AWS BUILDER CENTER SYNDICATION (Gated on AWS tag) --- + const awsSessionToken = process.env.AWS_BUILDER_SESSION_TOKEN; + if (shouldSyndicateAws && awsSessionToken) { + console.log("\n--- Syndicating to AWS Builder Center ---"); + const syndicationDocId = `syndication.${doc._id}.awsbuilder`; + const awsHeaders = { + "Content-Type": "application/json", + Accept: "application/json", + "builder-session-token": awsSessionToken, + }; + + try { + const existingSyndication = await client.getDocument(syndicationDocId).catch(() => null); + const urlContentIdMatch = doc.awsbuilder?.match(/builder\.aws\.com\/content\/([A-Za-z0-9]+)/); + let contentId = + (existingSyndication?.externalId && !String(existingSyndication.externalId).startsWith("simulated-") + ? String(existingSyndication.externalId) + : null) || + (urlContentIdMatch ? urlContentIdMatch[1] : null); + let versionId = null; + + if (contentId) { + const editInitRes = await fetch("https://api.builder.aws.com/cs/article/InitializeEditArticle", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId }), + }); + if (editInitRes.ok) { + const editInitJson = await editInitRes.json(); + versionId = editInitJson?.versionId || null; + } + } + + if (!contentId || !versionId) { + const initRes = await fetch("https://api.builder.aws.com/cs/article/InitializeArticle", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({}), + }); + if (!initRes.ok) { + throw new Error(`InitializeArticle failed (${initRes.status}): ${await initRes.text()}`); + } + const initJson = await initRes.json(); + contentId = initJson?.contentId; + versionId = initJson?.versionId; + } + + let coverImageUploadKey; + const coverImageUrl = urlForImage(doc.coverImage)?.width(1280).format("jpg").url() || ""; + if (coverImageUrl) { + const imgRes = await fetch(coverImageUrl); + if (imgRes.ok) { + const imgBuf = await imgRes.arrayBuffer(); + const presignRes = await fetch("https://api.builder.aws.com/cs/upload/generate-presigned-post", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ + contentId, + fileName: `${slug}-cover.jpg`, + contentType: "image/jpeg", + uploadType: "ARTICLE", + }), + }); + if (presignRes.ok) { + const presignData = await presignRes.json(); + const formData = new FormData(); + for (const [k, v] of Object.entries(presignData.presignedPostFields || {})) { + formData.append(k, String(v)); + } + formData.append("file", new Blob([imgBuf], { type: "image/jpeg" }), `${slug}-cover.jpg`); + const s3Res = await fetch(presignData.presignedPostUrl, { + method: "POST", + body: formData, + }); + if (s3Res.ok || s3Res.status === 204) { + coverImageUploadKey = presignData.uploadKey; + } + } + } + } + + const awsTags = Array.from( + new Set(docTags.map((t) => (t?.value || t?.label || "").toLowerCase()).filter(Boolean).slice(0, 5)) + ); + if (awsTags.length === 0) awsTags.push("aws"); + + const youtubeMd = doc.youtube ? `Watch on YouTube: [${doc.youtube}](${doc.youtube})\n\n` : ""; + const draftPayload = { + contentId, + versionId, + title: doc.title || "Untitled", + summary: (doc.excerpt || doc.title || "").slice(0, 300), + contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${contentMarkdown}`, + canonicalUrl, + preferredUrlSlug: slug, + tags: awsTags, + autoSave: false, + ...(coverImageUploadKey ? { coverImageUploadKey, coverImageAlt: doc.title || "Cover image" } : {}), + }; + + const saveRes = await fetch("https://api.builder.aws.com/cs/article/SaveArticleDraft", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify(draftPayload), + }); + if (!saveRes.ok) { + throw new Error(`SaveArticleDraft failed (${saveRes.status}): ${await saveRes.text()}`); + } + + await fetch("https://api.builder.aws.com/cs/article/SubmissionCheck", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId, versionId }), + }); + + const pubRes = await fetch("https://api.builder.aws.com/cs/article/PublishArticle", { + method: "POST", + headers: awsHeaders, + body: JSON.stringify({ contentId, versionId }), + }); + if (!pubRes.ok) { + throw new Error(`PublishArticle failed (${pubRes.status}): ${await pubRes.text()}`); + } + + const externalUrl = `https://builder.aws.com/content/${contentId}/${slug}`; + console.log(`✓ AWS Builder Center published: ${externalUrl}`); + + await client.createOrReplace({ + _id: syndicationDocId, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: "awsbuilder", + status: "published", + canonicalUrl, + externalUrl, + externalId: String(contentId), + syndicatedAt: new Date().toISOString(), + }); + + await client.patch(doc._id).set({ awsbuilder: externalUrl }).commit(); + } catch (err) { + console.error("✗ AWS Builder Center syndication error:", err); + } + } + // --- LINK SYNDICATION REFERENCES ON PARENT --- try { - const platforms = ["devto", "hashnode"]; + const platforms = ["devto", "hashnode", ...(shouldSyndicateAws ? ["awsbuilder"] : [])]; const expectedRefs = platforms.map((p) => `syndication.${doc._id}.${p}`); const latestDoc = await client.getDocument(doc._id); const existingRefs = (latestDoc?.syndications || []).map((s) => s._ref);