From e6d3a06115d1f6b0d6bea9e3dcd96dbe65681cc1 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 24 Oct 2026 00:25:13 -0400 Subject: [PATCH] feat(syndication): backfill daily.dev Squad, add full platform link tracking, YouTube embeds, and Reddit Devvit app --- .github/workflows/syndicate-post.yml | 6 +- apps/ops/src/App.tsx | 173 +++++- apps/reddit-devvit/README.md | 22 + apps/reddit-devvit/devvit.yaml | 2 + apps/reddit-devvit/package.json | 19 + apps/reddit-devvit/src/main.ts | 213 +++++++ apps/reddit-devvit/tsconfig.json | 12 + .../functions/syndicate-content/index.ts | 121 +++- apps/sanity/schemas/documents/syndication.ts | 8 +- apps/sanity/schemas/partials/content.ts | 12 + apps/sanity/structure.ts | 95 ++- scripts/backfill-dailydev-and-links.mjs | 537 +++++++++++++++++ scripts/dailydev-squad-map.json | 547 ++++++++++++++++++ scripts/run-syndicate-post.mjs | 145 ++++- 14 files changed, 1880 insertions(+), 32 deletions(-) create mode 100644 apps/reddit-devvit/README.md create mode 100644 apps/reddit-devvit/devvit.yaml create mode 100644 apps/reddit-devvit/package.json create mode 100644 apps/reddit-devvit/src/main.ts create mode 100644 apps/reddit-devvit/tsconfig.json create mode 100644 scripts/backfill-dailydev-and-links.mjs create mode 100644 scripts/dailydev-squad-map.json diff --git a/.github/workflows/syndicate-post.yml b/.github/workflows/syndicate-post.yml index 48ec9033..1a470268 100644 --- a/.github/workflows/syndicate-post.yml +++ b/.github/workflows/syndicate-post.yml @@ -37,7 +37,11 @@ jobs: - name: Syndicate Post run: | - node scripts/run-syndicate-post.mjs + if [ "$TARGET_DOCUMENT_ID" = "backfill-dailydev" ]; then + node scripts/backfill-dailydev-and-links.mjs + else + node scripts/run-syndicate-post.mjs + fi env: TARGET_DOCUMENT_ID: ${{ inputs.document_id }} SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} diff --git a/apps/ops/src/App.tsx b/apps/ops/src/App.tsx index f69b7717..69cc390a 100644 --- a/apps/ops/src/App.tsx +++ b/apps/ops/src/App.tsx @@ -1,7 +1,7 @@ import { useDocuments } from "@sanity/sdk-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; -type WorkspaceTab = "crm" | "bookings" | "billing"; +type WorkspaceTab = "crm" | "bookings" | "billing" | "syndication"; interface SeededGmailDraft { companyName: string; @@ -111,6 +111,33 @@ export function App() { const sponsorLeads = useDocuments({ documentType: "sponsorLead" }); const sponsorPool = useDocuments({ documentType: "sponsorPool" }); const bookings = useDocuments({ documentType: "booking" }); + const syndications = useDocuments({ documentType: "syndication" }); + const [syndicatedContent, setSyndicatedContent] = useState>([]); + + useEffect(() => { + const query = encodeURIComponent( + `*[_type in ["post", "podcast"] && !(_id in path("drafts.**"))] | order(coalesce(date, _createdAt) desc)[0...40] { + _id, + _type, + title, + "slug": slug.current, + "date": coalesce(date, _createdAt), + youtube, + dailydev, + devto, + hashnode, + awsbuilder, + medium, + reddit + }`, + ); + fetch(`https://${projectId}.apicdn.sanity.io/v2025-09-30/data/query/${dataset}?query=${query}`) + .then((r) => r.json()) + .then((d) => { + if (Array.isArray(d?.result)) setSyndicatedContent(d.result); + }) + .catch(() => {}); + }, [projectId, dataset]); const handleDiscoverOutbound = async () => { setDiscovering(true); @@ -185,6 +212,21 @@ export function App() { > 3. Stripe 50/50 Billing & Deal Rooms + @@ -318,6 +360,133 @@ export function App() { )} + + {activeTab === "syndication" && ( +
+
+
+

Cross-Platform Syndication & YouTube Funnel Tracker

+

+ Tracks canonical codingcat.dev URLs and outbound links across YouTube, daily.dev Squad, Dev.to, Hashnode, AWS Builder Center, Medium, and Reddit. +

+
+
+ + daily.dev Squad ↗ + + + Dev.to Org ↗ + +
+
+
+ + + + + + + + + + + + + + + {syndicatedContent.map((item) => { + const canonical = `${siteOrigin}/${item._type}/${item.slug}`; + const hnUrl = item.hashnode + ? item.hashnode.startsWith("http") + ? item.hashnode + : `https://hashnode.codingcat.dev/${item.hashnode}` + : null; + return ( + + + + + + + + + + + ); + })} + +
DateTitle (Canonical)YouTubedaily.dev SquadDev.toHashnodeAWS BuilderMedium / Reddit
+ {(item.date || "").slice(0, 10)} + + + {item.title} + + + {item.youtube ? ( + + Watch ↗ + + ) : ( + - + )} + + {item.dailydev ? ( + + Squad ↗ + + ) : ( + - + )} + + {item.devto ? ( + + Dev.to ↗ + + ) : ( + - + )} + + {hnUrl ? ( + + Hashnode ↗ + + ) : ( + - + )} + + {item.awsbuilder ? ( + + AWS ↗ + + ) : ( + - + )} + + {item.medium && ( + + Medium ↗ + + )} + {item.reddit && ( + + Reddit ↗ + + )} + {!item.medium && !item.reddit && -} +
+
+
+ )} ); } diff --git a/apps/reddit-devvit/README.md b/apps/reddit-devvit/README.md new file mode 100644 index 00000000..e95dfabc --- /dev/null +++ b/apps/reddit-devvit/README.md @@ -0,0 +1,22 @@ +# CodingCat.dev Reddit Devvit Auto-Poster (`r/CodingCatDev`) + +This Reddit Devvit app runs inside Reddit's own infrastructure (`@devvit/public-api`) so **it does not require external Reddit Data API approval**. + +## What It Does +1. **Polls Sanity (`production`) every 30 minutes** via `Devvit.addSchedulerJob` (or on-demand via the subreddit moderator menu item **"Sync Latest from CodingCat.dev Now"**). +2. **YouTube-First Native Player**: + - When a post or podcast has a `youtube` URL in Sanity, it submits the `https://www.youtube.com/watch?v=` URL as the Reddit Link Post so Reddit renders the **native inline YouTube player** right in the `r/CodingCatDev` feed. + - Automatically adds a pinned moderator comment containing the excerpt, the canonical `https://codingcat.dev/post/` guide + transcript link, and a direct YouTube subscribe link (`?sub_confirmation=1`). +3. **Automatic Sanity Link Tracking**: + - When `sanityWriteToken` is configured in the app's installation settings, it automatically creates `syndication..reddit` and populates the `reddit` URL field on the Sanity document. + +## 2-Command Setup & Deployment + +```bash +cd apps/reddit-devvit +npx devvit login +npx devvit upload +npx devvit install r/CodingCatDev +``` + +*(Optional)* After installing on `r/CodingCatDev`, open `https://developers.reddit.com/r/CodingCatDev/apps/codingcat-syndicator` and paste your `SANITY_API_WRITE_TOKEN` into the **Sanity API Write Token** field so every Reddit post URL is automatically saved back to Sanity. diff --git a/apps/reddit-devvit/devvit.yaml b/apps/reddit-devvit/devvit.yaml new file mode 100644 index 00000000..db46c421 --- /dev/null +++ b/apps/reddit-devvit/devvit.yaml @@ -0,0 +1,2 @@ +name: codingcat-syndicator +version: 0.1.0 diff --git a/apps/reddit-devvit/package.json b/apps/reddit-devvit/package.json new file mode 100644 index 00000000..2c71b2f8 --- /dev/null +++ b/apps/reddit-devvit/package.json @@ -0,0 +1,19 @@ +{ + "name": "@codingcatdev/reddit-devvit", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "login": "devvit login", + "upload": "devvit upload", + "playtest": "devvit playtest r/CodingCatDev", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@devvit/public-api": "^0.11.0" + }, + "devDependencies": { + "devvit": "^0.11.0", + "typescript": "^5.7.0" + } +} diff --git a/apps/reddit-devvit/src/main.ts b/apps/reddit-devvit/src/main.ts new file mode 100644 index 00000000..c1e01e87 --- /dev/null +++ b/apps/reddit-devvit/src/main.ts @@ -0,0 +1,213 @@ +import { Devvit, SettingScope } from "@devvit/public-api"; + +Devvit.configure({ + redditAPI: true, + redis: true, + http: true, +}); + +Devvit.addSettings([ + { + type: "string", + name: "sanityProjectId", + label: "Sanity Project ID", + defaultValue: "hfh83o0w", + scope: SettingScope.Installation, + }, + { + type: "string", + name: "sanityDataset", + label: "Sanity Dataset", + defaultValue: "production", + scope: SettingScope.Installation, + }, + { + type: "string", + name: "sanityWriteToken", + label: "Sanity API Write Token (optional: writes Reddit URL back to Sanity)", + isSecret: true, + scope: SettingScope.Installation, + }, +]); + +interface SanityContentDoc { + _id: string; + _type: "post" | "podcast"; + title: string; + slug: string; + date: string; + excerpt?: string; + youtube?: string; + reddit?: string; +} + +function extractYouTubeId(url?: string): string | null { + if (!url) return null; + const trimmed = url.trim(); + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/, + /^([A-Za-z0-9_-]{11})$/, + ]; + for (const re of patterns) { + const m = trimmed.match(re); + if (m) return m[1]; + } + return null; +} + +async function syncLatestCodingCatContent(context: Devvit.Context): Promise { + const settings = await context.settings.getAll(); + const projectId = (settings.sanityProjectId as string) || "hfh83o0w"; + const dataset = (settings.sanityDataset as string) || "production"; + const sanityWriteToken = (settings.sanityWriteToken as string) || ""; + + const subreddit = await context.reddit.getCurrentSubreddit(); + const subredditName = subreddit.name; + + const query = encodeURIComponent( + `*[_type in ["post", "podcast"] && !(_id in path("drafts.**")) && !defined(reddit)] | order(coalesce(date, _createdAt) desc)[0...5] { + _id, + _type, + title, + "slug": slug.current, + "date": coalesce(date, _createdAt), + excerpt, + youtube, + reddit + }` + ); + + const res = await fetch( + `https://${projectId}.apicdn.sanity.io/v2025-09-30/data/query/${dataset}?query=${query}` + ); + if (!res.ok) { + console.error(`Failed to query Sanity (${res.status}): ${await res.text()}`); + return 0; + } + + const json = (await res.json()) as { result?: SanityContentDoc[] }; + const docs = (json.result || []).reverse(); + let postedCount = 0; + + for (const doc of docs) { + const redisKey = `posted:${doc._id}`; + const alreadyPosted = await context.redis.get(redisKey); + if (alreadyPosted || doc.reddit) { + continue; + } + + const canonicalUrl = `https://codingcat.dev/${doc._type}/${doc.slug}`; + const ytId = extractYouTubeId(doc.youtube); + const youtubeWatchUrl = ytId ? `https://www.youtube.com/watch?v=${ytId}` : null; + + // Submit YouTube watch URL as the primary link when present so Reddit renders the native inline YouTube player + const targetLinkUrl = youtubeWatchUrl || canonicalUrl; + const prefix = doc._type === "podcast" ? "[Podcast] " : ""; + const postTitle = `${prefix}${doc.title}`.slice(0, 300); + + const submission = await context.reddit.submitPost({ + subredditName, + title: postTitle, + url: targetLinkUrl, + }); + + const redditPostUrl = `https://www.reddit.com${submission.permalink || `/r/${subredditName}/comments/${submission.id.replace(/^t3_/, "")}`}`; + + // Post an automatic first comment with the excerpt, codingcat.dev article + transcript link, and YouTube subscribe CTA + const commentLines = [ + doc.excerpt ? `> ${doc.excerpt.trim()}` : "", + "", + `📖 **Read the full guide, code snippets & transcript on CodingCat.dev:** [${canonicalUrl}](${canonicalUrl})`, + youtubeWatchUrl + ? `📺 **Watch on YouTube & Subscribe:** [${youtubeWatchUrl}](${youtubeWatchUrl}?sub_confirmation=1)` + : `📺 **Subscribe to CodingCat.dev on YouTube:** [https://www.youtube.com/@CodingCatDev](https://www.youtube.com/@CodingCatDev?sub_confirmation=1)`, + ].filter(Boolean); + + try { + const comment = await context.reddit.submitComment({ + id: submission.id, + text: commentLines.join("\n"), + }); + await comment.distinguish(true); + } catch (commentErr) { + console.warn("Could not distinguish comment:", commentErr); + } + + await context.redis.set(redisKey, redditPostUrl); + postedCount++; + + // Write Reddit URL and syndication doc back to Sanity if sanityWriteToken is configured + if (sanityWriteToken) { + const synId = `syndication.${doc._id}.reddit`; + const mutations = [ + { + createOrReplace: { + _id: synId, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: "reddit", + status: "published", + canonicalUrl, + externalUrl: redditPostUrl, + externalId: submission.id, + syndicatedAt: new Date().toISOString(), + }, + }, + { + patch: { + id: doc._id, + set: { reddit: redditPostUrl }, + }, + }, + ]; + + await fetch( + `https://${projectId}.api.sanity.io/v2025-09-30/data/mutate/${dataset}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sanityWriteToken}`, + }, + body: JSON.stringify({ mutations }), + } + ).catch((err) => console.warn("Failed writing Reddit URL back to Sanity:", err)); + } + } + + return postedCount; +} + +Devvit.addSchedulerJob({ + name: "codingcat_sanity_sync", + onRun: async (_event, context) => { + const count = await syncLatestCodingCatContent(context); + console.log(`[codingcat_sanity_sync] Published ${count} new items to subreddit.`); + }, +}); + +Devvit.addTrigger({ + event: "AppInstall", + onEvent: async (_event, context) => { + await context.scheduler.runJob({ + name: "codingcat_sanity_sync", + cron: "*/30 * * * *", + }); + }, +}); + +Devvit.addMenuItem({ + label: "Sync Latest from CodingCat.dev Now", + location: "subreddit", + forUserType: "moderator", + onPress: async (_event, context) => { + const count = await syncLatestCodingCatContent(context); + context.ui.showToast( + count > 0 + ? `Published ${count} new CodingCat.dev post(s) to r/CodingCatDev!` + : "r/CodingCatDev is already up to date with Sanity!" + ); + }, +}); + +export default Devvit; diff --git a/apps/reddit-devvit/tsconfig.json b/apps/reddit-devvit/tsconfig.json new file mode 100644 index 00000000..3af2f8d3 --- /dev/null +++ b/apps/reddit-devvit/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/sanity/functions/syndicate-content/index.ts b/apps/sanity/functions/syndicate-content/index.ts index 247bfbde..68df19b8 100644 --- a/apps/sanity/functions/syndicate-content/index.ts +++ b/apps/sanity/functions/syndicate-content/index.ts @@ -77,8 +77,44 @@ export const handler = documentEventHandler( return imgBuilder.image(source).auto("format").fit("max"); }; + const extractYouTubeId = (url: string | undefined): string | null => { + if (!url || typeof url !== "string") return null; + const trimmed = url.trim(); + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/, + /^([A-Za-z0-9_-]{11})$/, + ]; + for (const re of patterns) { + const m = trimmed.match(re); + if (m) return m[1]; + } + return null; + }; + + const formatYouTubeBlock = ( + url: string | undefined, + targetPlatform: "devto" | "hashnode" | "awsbuilder" = "devto", + ): string => { + const ytId = extractYouTubeId(url); + if (!ytId && !url) return ""; + const watchUrl = ytId ? `https://www.youtube.com/watch?v=${ytId}` : url || ""; + if (targetPlatform === "devto") { + return `{% youtube ${ytId || (url || "").replace("live", "embed")} %}`; + } + if (targetPlatform === "hashnode") { + return `%[${watchUrl}]`; + } + const thumbUrl = ytId ? `https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg` : ""; + return thumbUrl + ? `[![Watch on YouTube](${thumbUrl})](${watchUrl})\n\n**[▶ Watch the video on YouTube](${watchUrl})**` + : `**[▶ Watch on YouTube](${watchUrl})**`; + }; + // Helper to serialize Portable Text to Markdown matching exact legacy serializer specifications - const serializeContent = (content: any[] | undefined): string => { + const serializeContent = ( + content: any[] | undefined, + targetPlatform: "devto" | "hashnode" | "awsbuilder" = "devto", + ): string => { if (!content || !Array.isArray(content) || content.length === 0) { return ""; } @@ -92,13 +128,21 @@ export const handler = documentEventHandler( const url = urlForImage(value)?.url(); return url ? `![](${url})` : ""; }, + youtube: ({ value }) => + formatYouTubeBlock(value?.youtube || value?.url || "", targetPlatform), + youtubeShorts: ({ value }) => + Array.isArray(value?.shorts) + ? value.shorts + .map((s: string) => formatYouTubeBlock(s, targetPlatform)) + .join("\n\n") + : "", codepen: ({ value }) => `{% codepen ${value?.url || ""} %}`, codesandbox: ({ value }) => { const id = value?.url?.split("https://codesandbox.io/p/sandbox/")?.at(-1); return `{% codesandbox ${id || value?.url || ""} %}`; }, twitter: ({ value }) => `{% twitter ${value?.id || ""} %}`, - quote: ({ value }) => `> ${serializeContent(value?.content)}`, + quote: ({ value }) => `> ${serializeContent(value?.content, targetPlatform)}`, }, }); } catch (err) { @@ -107,7 +151,13 @@ export const handler = documentEventHandler( } }; - const contentMarkdown = serializeContent(data.content); + const contentMarkdown = serializeContent(data.content, "devto"); + const hashnodeMarkdown = serializeContent(data.content, "hashnode"); + const awsMarkdown = serializeContent(data.content, "awsbuilder"); + const topYouTubeId = extractYouTubeId(data.youtube); + const topYouTubeWatchUrl = topYouTubeId + ? `https://www.youtube.com/watch?v=${topYouTubeId}` + : data.youtube || ""; const docTags = Array.isArray(data.tags) ? data.tags : []; const hasAwsTag = docTags.some( (t) => @@ -219,7 +269,7 @@ export const handler = documentEventHandler( urlForImage(data.coverImage)?.width(1280).url() || ""; const youtubeEmbed = data.youtube - ? `{% youtube ${data.youtube.replace("live", "embed")} %}\n\n` + ? `{% youtube ${topYouTubeId || data.youtube.replace("live", "embed")} %}\n\n**[▶ Watch on YouTube & Subscribe to CodingCat.dev](${topYouTubeWatchUrl}${topYouTubeWatchUrl.includes("?") ? "&" : "?"}sub_confirmation=1)**\n\n` : ""; const devtoArticlePayload: any = { @@ -391,7 +441,7 @@ export const handler = documentEventHandler( urlForImage(data.coverImage)?.width(1600).height(840).url() || ""; const youtubeEmbed = data.youtube - ? `%[${data.youtube.replace("live", "embed")}]\n\n` + ? `%[${topYouTubeWatchUrl}]\n\n**[▶ Watch on YouTube & Subscribe to CodingCat.dev](${topYouTubeWatchUrl}${topYouTubeWatchUrl.includes("?") ? "&" : "?"}sub_confirmation=1)**\n\n` : ""; const articleInput: any = { @@ -404,7 +454,7 @@ export const handler = documentEventHandler( ? { coverImage: hashnodeCoverImageUrl } : {}), originalArticleURL: canonicalUrl, - contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, + contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${hashnodeMarkdown}`, }; if (data._type === "podcast") { @@ -672,7 +722,7 @@ export const handler = documentEventHandler( if (awsTags.length === 0) awsTags.push("aws"); const youtubeMd = data.youtube - ? `Watch on YouTube: [${data.youtube}](${data.youtube})\n\n` + ? `${formatYouTubeBlock(data.youtube, "awsbuilder")}\n\n` : ""; const draftPayload: any = { @@ -680,7 +730,7 @@ export const handler = documentEventHandler( versionId, title: data.title || "Untitled", summary: (data.excerpt || data.title || "").slice(0, 300), - contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${contentMarkdown}`, + contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${awsMarkdown}`, canonicalUrl, preferredUrlSlug: slug, tags: awsTags, @@ -795,13 +845,22 @@ export const handler = documentEventHandler( ? String(existingSyndication.externalId) : null; - const commentary = ( + const squadSuffix = topYouTubeWatchUrl ? `\nFull guide: ${canonicalUrl}` : ""; + const maxExcerptLen = Math.max(40, 240 - squadSuffix.length); + const rawCommentary = ( data.excerpt || data.title || "New on CodingCat.dev!" - ).slice(0, 240); + ).trim(); + const trimmedCommentary = + rawCommentary.length > maxExcerptLen + ? `${rawCommentary.slice(0, maxExcerptLen - 3).trim()}...` + : rawCommentary; + const commentary = `${trimmedCommentary}${squadSuffix}`.slice(0, 240); const coverImageUrl = - urlForImage(data.coverImage)?.width(1280).format("jpg").url() || ""; + urlForImage(data.coverImage)?.width(1280).format("jpg").url() || + (topYouTubeId ? `https://i.ytimg.com/vi/${topYouTubeId}/maxresdefault.jpg` : ""); + const squadExternalLink = topYouTubeWatchUrl || canonicalUrl; let postId = existingPostId; if (!postId && data.dailydev) { @@ -862,11 +921,47 @@ export const handler = documentEventHandler( title: data.title || "Untitled", commentary, ...(coverImageUrl ? { imageUrl: coverImageUrl } : {}), - externalLink: canonicalUrl, + externalLink: squadExternalLink, }, }), }); - const createData = (await createRes.json()) as any; + let createData = (await createRes.json()) as any; + if (createData?.errors && squadExternalLink !== canonicalUrl) { + const retryRes = await fetch("https://api.daily.dev/graphql", { + method: "POST", + headers: gqlHeaders, + body: JSON.stringify({ + query: `mutation CreatePostInMultipleSources( + $sourceIds: [ID!]! + $title: String + $commentary: String + $imageUrl: String + $externalLink: String + ) { + createPostInMultipleSources( + sourceIds: $sourceIds + title: $title + commentary: $commentary + imageUrl: $imageUrl + externalLink: $externalLink + ) { + id + sourceId + type + slug + } + }`, + variables: { + sourceIds: [squadSourceId], + title: data.title || "Untitled", + commentary: rawCommentary.slice(0, 240), + ...(coverImageUrl ? { imageUrl: coverImageUrl } : {}), + externalLink: canonicalUrl, + }, + }), + }); + createData = (await retryRes.json()) as any; + } if (createData?.errors) { throw new Error( `daily.dev createPostInMultipleSources error: ${JSON.stringify(createData.errors)}`, diff --git a/apps/sanity/schemas/documents/syndication.ts b/apps/sanity/schemas/documents/syndication.ts index a0354c10..fd65d4e1 100644 --- a/apps/sanity/schemas/documents/syndication.ts +++ b/apps/sanity/schemas/documents/syndication.ts @@ -24,6 +24,9 @@ export default defineType({ { title: "Hashnode", value: "hashnode" }, { title: "AWS Builder Center", value: "awsbuilder" }, { title: "daily.dev Squad", value: "dailydev" }, + { title: "Medium", value: "medium" }, + { title: "Reddit (r/CodingCatDev)", value: "reddit" }, + { title: "YouTube", value: "youtube" }, ], }, validation: (rule) => rule.required(), @@ -73,11 +76,12 @@ export default defineType({ platform: "platform", status: "status", targetTitle: "target.title", + externalUrl: "externalUrl", }, - prepare({ platform, status, targetTitle }) { + prepare({ platform, status, targetTitle, externalUrl }) { return { title: `${(platform || "Syndication").toUpperCase()}: ${targetTitle || "Unknown Content"}`, - subtitle: `Status: ${status || "pending"}`, + subtitle: `${status || "pending"}${externalUrl ? ` • ${externalUrl}` : ""}`, }; }, }, diff --git a/apps/sanity/schemas/partials/content.ts b/apps/sanity/schemas/partials/content.ts index 8af0422c..c8a5f250 100644 --- a/apps/sanity/schemas/partials/content.ts +++ b/apps/sanity/schemas/partials/content.ts @@ -93,6 +93,18 @@ const content = defineType({ type: "url", group: "data", }), + defineField({ + name: "medium", + title: "Medium", + type: "url", + group: "data", + }), + defineField({ + name: "reddit", + title: "Reddit (r/CodingCatDev)", + type: "url", + group: "data", + }), defineField({ name: "tags", title: "Tags", diff --git a/apps/sanity/structure.ts b/apps/sanity/structure.ts index 41c938bc..72e9f5d7 100644 --- a/apps/sanity/structure.ts +++ b/apps/sanity/structure.ts @@ -146,6 +146,24 @@ export const editorialStructure: StructureResolver = (S) => .schemaType("post") .filter('_type == "post" && !defined(hashnode)'), ), + S.listItem() + .title("Missing daily.dev Squad") + .child( + S.documentList() + .title("Missing daily.dev Squad") + .schemaType("post") + .filter('_type == "post" && !defined(dailydev)'), + ), + S.listItem() + .title("Missing AWS Builder Center (AWS Tagged)") + .child( + S.documentList() + .title("Missing AWS Builder Center (AWS Tagged)") + .schemaType("post") + .filter( + '_type == "post" && "aws" in tags[].value && !defined(awsbuilder)', + ), + ), ]), ), @@ -243,6 +261,24 @@ export const editorialStructure: StructureResolver = (S) => .schemaType("podcast") .filter('_type == "podcast" && !defined(hashnode)'), ), + S.listItem() + .title("Missing daily.dev Squad") + .child( + S.documentList() + .title("Missing daily.dev Squad") + .schemaType("podcast") + .filter('_type == "podcast" && !defined(dailydev)'), + ), + S.listItem() + .title("Missing AWS Builder Center (AWS Tagged)") + .child( + S.documentList() + .title("Missing AWS Builder Center (AWS Tagged)") + .schemaType("podcast") + .filter( + '_type == "podcast" && "aws" in tags[].value && !defined(awsbuilder)', + ), + ), ]), ), @@ -303,7 +339,7 @@ export const editorialStructure: StructureResolver = (S) => .icon(FaShareAlt) .child( S.list() - .title("Syndication Status") + .title("Syndication Status & Platform Links") .items([ S.listItem() .title("All Syndications") @@ -337,6 +373,63 @@ export const editorialStructure: StructureResolver = (S) => .schemaType("syndication") .filter('_type == "syndication" && status == "failed"'), ), + S.divider(), + S.listItem() + .title("By Platform: YouTube") + .child( + S.documentList() + .title("YouTube Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "youtube"'), + ), + S.listItem() + .title("By Platform: daily.dev Squad") + .child( + S.documentList() + .title("daily.dev Squad Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "dailydev"'), + ), + S.listItem() + .title("By Platform: Dev.to") + .child( + S.documentList() + .title("Dev.to Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "devto"'), + ), + S.listItem() + .title("By Platform: Hashnode") + .child( + S.documentList() + .title("Hashnode Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "hashnode"'), + ), + S.listItem() + .title("By Platform: AWS Builder Center") + .child( + S.documentList() + .title("AWS Builder Center Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "awsbuilder"'), + ), + S.listItem() + .title("By Platform: Medium") + .child( + S.documentList() + .title("Medium Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "medium"'), + ), + S.listItem() + .title("By Platform: Reddit (r/CodingCatDev)") + .child( + S.documentList() + .title("Reddit Links") + .schemaType("syndication") + .filter('_type == "syndication" && platform == "reddit"'), + ), ]), ), diff --git a/scripts/backfill-dailydev-and-links.mjs b/scripts/backfill-dailydev-and-links.mjs new file mode 100644 index 00000000..0cc0ce4e --- /dev/null +++ b/scripts/backfill-dailydev-and-links.mjs @@ -0,0 +1,537 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createClient } from "@sanity/client"; +import { createImageUrlBuilder } from "@sanity/image-url"; + +const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w"; +const token = process.env.SANITY_API_WRITE_TOKEN || process.env.SANITY_AUTH_TOKEN; +const dast = + process.env.DAILY_DEV_SESSION_COOKIE || + "LLanoEYNBQITREh4k48Zpckdd3UxGDTV.%2B%2FeFxFfX%2F3tZBARFA3zuJFVQ%2F2FnrER0SjdVnRaQDNs%3D"; + +const prodClient = createClient({ + projectId, + dataset: "production", + ...(token ? { token } : {}), + apiVersion: "2025-09-30", + useCdn: false, +}); + +const imgBuilder = createImageUrlBuilder({ projectId, dataset: "production" }); +const urlForImage = (source) => { + if (!source || !source.asset) return undefined; + return imgBuilder.image(source).auto("format").fit("max"); +}; + +function extractYouTubeId(url) { + if (!url || typeof url !== "string") return null; + const trimmed = url.trim(); + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/, + /^([A-Za-z0-9_-]{11})$/, + ]; + for (const re of patterns) { + const m = trimmed.match(re); + if (m) return m[1]; + } + return null; +} + +function normalizeTitle(str) { + return (str || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function getDailyDevCookies() { + const baseCookie = dast.includes("__Secure-dast=") + ? dast + : `da2=m7Z6H7R0hwoOhyQZ1WW7K; __Secure-dast=${dast}`; + const bootRes = await fetch("https://api.daily.dev/boot?v=3.47.11", { + headers: { + app: "webapp", + "Content-Type": "application/json", + Cookie: baseCookie, + Origin: "https://app.daily.dev", + Referer: "https://app.daily.dev/", + }, + }); + const setCookies = bootRes.headers + .getSetCookie?.() + ?.map((c) => c.split(";")[0]) + .join("; "); + return setCookies || baseCookie; +} + +async function fetchAllSquadPosts(cookies) { + const squadSourceId = "dc8ed5af-d1f6-423d-9f17-703d33946006"; + let allSquadPosts = []; + let after = null; + + while (true) { + const res = await fetch("https://api.daily.dev/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookies, + Origin: "https://app.daily.dev", + Referer: "https://app.daily.dev/", + }, + body: JSON.stringify({ + query: `query($after: String) { + sourceFeed( + source: "${squadSourceId}" + first: 50 + after: $after + ranking: TIME + supportedTypes: ["article", "share", "freeform", "video:youtube", "collection", "poll"] + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + title + type + createdAt + permalink + commentsPermalink + sharedPost { + id + title + type + permalink + commentsPermalink + } + } + } + } + }`, + variables: { after }, + }), + }); + + const data = await res.json(); + const feed = data?.data?.sourceFeed; + if (!feed) break; + allSquadPosts.push(...feed.edges.map((e) => e.node)); + if (!feed.pageInfo?.hasNextPage) break; + after = feed.pageInfo.endCursor; + } + + return allSquadPosts; +} + +function buildSquadCommentary(doc, canonicalUrl) { + const suffix = `\nFull guide: ${canonicalUrl}`; + const maxBodyLen = Math.max(40, 240 - suffix.length); + const rawBody = (doc.excerpt || doc.title || "Watch & read on CodingCat.dev").trim(); + const trimmedBody = + rawBody.length > maxBodyLen ? `${rawBody.slice(0, maxBodyLen - 3).trim()}...` : rawBody; + return `${trimmedBody}${suffix}`.slice(0, 240); +} + +async function createSquadPostWithRetry({ squadSourceId, title, commentary, coverImageUrl, externalLink }) { + for (let attempt = 1; attempt <= 4; attempt++) { + const cookies = await getDailyDevCookies(); + const gqlHeaders = { + "Content-Type": "application/json", + Cookie: cookies, + Origin: "https://app.daily.dev", + Referer: "https://app.daily.dev/", + }; + + const createRes = await fetch("https://api.daily.dev/graphql", { + method: "POST", + headers: gqlHeaders, + body: JSON.stringify({ + query: `mutation CreatePostInMultipleSources( + $sourceIds: [ID!]! + $title: String + $commentary: String + $imageUrl: String + $externalLink: String + ) { + createPostInMultipleSources( + sourceIds: $sourceIds + title: $title + commentary: $commentary + imageUrl: $imageUrl + externalLink: $externalLink + ) { + id + sourceId + type + slug + } + }`, + variables: { + sourceIds: [squadSourceId], + title, + commentary, + ...(coverImageUrl ? { imageUrl: coverImageUrl } : {}), + externalLink, + }, + }), + }); + + const createData = await createRes.json(); + const createdId = createData?.data?.createPostInMultipleSources?.[0]?.id; + if (createdId) { + const postRes = await fetch("https://api.daily.dev/graphql", { + method: "POST", + headers: gqlHeaders, + body: JSON.stringify({ + query: `query GetSquadPost($id: ID!) { + post(id: $id) { + id + title + type + createdAt + permalink + commentsPermalink + sharedPost { + id + title + type + permalink + commentsPermalink + } + } + }`, + variables: { id: createdId }, + }), + }); + const postJson = await postRes.json(); + return ( + postJson?.data?.post || { + id: createdId, + title, + commentsPermalink: `https://daily.dev/posts/${createdId}`, + } + ); + } + + const rateLimitErr = createData?.errors?.find((e) => e?.extensions?.code === "RATE_LIMITED"); + if (rateLimitErr) { + const waitSec = (Number(rateLimitErr.extensions?.retryAfter) || 30) + 2; + console.log(` Rate limited; waiting ${waitSec}s before retry (attempt ${attempt}/4)...`); + await sleep(waitSec * 1000); + continue; + } + + return { error: createData?.errors }; + } + return { error: "Max retries exceeded" }; +} + +async function main() { + const cookies = await getDailyDevCookies(); + + console.log("1. Fetching existing posts in CodingCat.dev Squad on daily.dev..."); + let squadPosts = await fetchAllSquadPosts(cookies); + console.log(` Found ${squadPosts.length} existing Squad posts.`); + + console.log("2. Fetching all published posts & podcasts from Sanity..."); + const sanityDocs = await prodClient.fetch( + `*[_type in ["post", "podcast"] && !(_id in path("drafts.**"))] | order(coalesce(date, _createdAt) desc) { + _id, + _type, + title, + "slug": slug.current, + "date": coalesce(date, _createdAt), + excerpt, + youtube, + coverImage, + devto, + hashnode, + awsbuilder, + dailydev, + medium, + reddit, + syndications + }` + ); + console.log(` Found ${sanityDocs.length} published Sanity documents.`); + + const matchSquadPost = (doc) => { + const normDocTitle = normalizeTitle(doc.title); + const slug = doc.slug || doc._id; + for (const sp of squadPosts) { + const normShared = normalizeTitle(sp.sharedPost?.title); + const normSpTitle = normalizeTitle(sp.title); + if ( + (normShared && + (normShared === normDocTitle || + normShared.includes(normDocTitle) || + normDocTitle.includes(normShared))) || + (normSpTitle && (normSpTitle === normDocTitle || normSpTitle.includes(normDocTitle))) || + (sp.title && sp.title.includes(`/${doc._type}/${slug}`)) + ) { + return sp; + } + } + return null; + }; + + const mapPath = path.resolve("scripts/dailydev-squad-map.json"); + const existingMap = fs.existsSync(mapPath) + ? JSON.parse(fs.readFileSync(mapPath, "utf8")) + : {}; + + const docsSince2025 = sanityDocs.filter((d) => d.date >= "2025-01-01"); + const missingSince2025 = docsSince2025.filter( + (d) => !matchSquadPost(d) && !existingMap[d._id] && !d.dailydev + ); + console.log( + `3. Found ${missingSince2025.length} missing 2025-2026 posts/podcasts to backfill to daily.dev Squad.` + ); + + const squadSourceId = "dc8ed5af-d1f6-423d-9f17-703d33946006"; + const chronologicalMissing = [...missingSince2025].reverse(); + + for (let i = 0; i < chronologicalMissing.length; i++) { + const doc = chronologicalMissing[i]; + const slug = doc.slug || doc._id; + const canonicalUrl = `https://codingcat.dev/${doc._type}/${slug}`; + const ytId = extractYouTubeId(doc.youtube); + const externalLink = ytId ? `https://www.youtube.com/watch?v=${ytId}` : canonicalUrl; + const commentary = ytId + ? buildSquadCommentary(doc, canonicalUrl) + : (doc.excerpt || doc.title || "Read on CodingCat.dev").slice(0, 240); + const coverImageUrl = + urlForImage(doc.coverImage)?.width(1280).format("jpg").url() || + (ytId ? `https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg` : ""); + + console.log( + ` [${i + 1}/${chronologicalMissing.length}] Publishing "${doc.title}" (${doc.date.slice(0, 10)}) [link: ${externalLink}]...` + ); + + let result = await createSquadPostWithRetry({ + squadSourceId, + title: doc.title || "Untitled", + commentary, + coverImageUrl, + externalLink, + }); + + if (result?.error && ytId) { + console.warn( + ` Warning with YouTube link (${JSON.stringify(result.error)}), retrying with canonicalUrl...` + ); + result = await createSquadPostWithRetry({ + squadSourceId, + title: doc.title || "Untitled", + commentary: (doc.excerpt || doc.title || "").slice(0, 240), + coverImageUrl, + externalLink: canonicalUrl, + }); + } + + if (result?.id) { + squadPosts.unshift(result); + console.log(` ✓ Published: ${result.commentsPermalink}`); + } else { + console.error(` ✗ Failed to publish "${doc.title}":`, JSON.stringify(result?.error)); + } + + if (i < chronologicalMissing.length - 1) { + await sleep(31000); + } + } + + console.log("\n4. Re-fetching complete Squad feed and syncing all platform links & syndication docs to Sanity..."); + const refreshedCookies = await getDailyDevCookies(); + squadPosts = await fetchAllSquadPosts(refreshedCookies); + console.log(` Total Squad posts now in CodingCat.dev Squad: ${squadPosts.length}`); + + let matchedDailyDevCount = 0; + let totalSyndicationDocsSynced = 0; + const prodMutations = []; + const devMutations = []; + const squadMap = { ...existingMap }; + + for (const doc of sanityDocs) { + const slug = doc.slug || doc._id; + const canonicalUrl = `https://codingcat.dev/${doc._type}/${slug}`; + const matchedSp = matchSquadPost(doc); + const savedEntry = existingMap[doc._id]; + + const dailydevUrl = + matchedSp?.commentsPermalink || + savedEntry?.dailydevUrl || + doc.dailydev || + (doc._id === "post-php2shell-cve-2026-109618" + ? "https://daily.dev/posts/two-critical-pre-authentication-rce-flaws-in-php-cve-2026-109618-and-cve-2026-109619-uncovered-by-lmbg2iyss" + : null); + + if (dailydevUrl) { + matchedDailyDevCount++; + const m = dailydevUrl.match(/-([A-Za-z0-9]{9})$/); + squadMap[doc._id] = { + dailydevUrl, + externalId: matchedSp?.id || savedEntry?.externalId || (m ? m[1] : doc._id), + syndicatedAt: + matchedSp?.createdAt || savedEntry?.syndicatedAt || doc.date || new Date().toISOString(), + }; + } + + const platformEntries = []; + if (doc.devto) { + platformEntries.push({ + platform: "devto", + externalUrl: doc.devto, + externalId: doc.devto.split("/").at(-1) || doc._id, + }); + } + if (doc.hashnode) { + const hnUrl = doc.hashnode.startsWith("http") + ? doc.hashnode + : `https://hashnode.codingcat.dev/${doc.hashnode}`; + platformEntries.push({ + platform: "hashnode", + externalUrl: hnUrl, + externalId: doc.hashnode, + }); + } + if (doc.awsbuilder) { + const m = doc.awsbuilder.match(/builder\.aws\.com\/content\/([A-Za-z0-9]+)/); + platformEntries.push({ + platform: "awsbuilder", + externalUrl: doc.awsbuilder, + externalId: m ? m[1] : doc._id, + }); + } + if (dailydevUrl) { + const m = dailydevUrl.match(/-([A-Za-z0-9]{9})$/); + platformEntries.push({ + platform: "dailydev", + externalUrl: dailydevUrl, + externalId: matchedSp?.id || (m ? m[1] : doc._id), + syndicatedAt: matchedSp?.createdAt || doc.date || new Date().toISOString(), + }); + } + if (doc.youtube) { + const ytId = extractYouTubeId(doc.youtube); + const ytUrl = ytId ? `https://www.youtube.com/watch?v=${ytId}` : doc.youtube; + platformEntries.push({ + platform: "youtube", + externalUrl: ytUrl, + externalId: ytId || doc.youtube, + }); + } + if (doc.medium) { + platformEntries.push({ + platform: "medium", + externalUrl: doc.medium, + externalId: doc.medium.split("/").at(-1) || doc._id, + }); + } + if (doc.reddit) { + platformEntries.push({ + platform: "reddit", + externalUrl: doc.reddit, + externalId: doc.reddit.split("/").filter(Boolean).at(-1) || doc._id, + }); + } + + const synRefs = []; + for (const entry of platformEntries) { + const synId = `syndication.${doc._id}.${entry.platform}`; + const synDoc = { + _id: synId, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: entry.platform, + status: "published", + canonicalUrl, + externalUrl: entry.externalUrl, + externalId: String(entry.externalId), + syndicatedAt: entry.syndicatedAt || doc.date || new Date().toISOString(), + }; + prodMutations.push({ createOrReplace: synDoc }); + devMutations.push({ createOrReplace: synDoc }); + totalSyndicationDocsSynced++; + + synRefs.push({ + _key: `syn_${synId.replace(/[^a-zA-Z0-9]/g, "_")}`, + _type: "reference", + _ref: synId, + }); + } + + const docSetPatch = {}; + if (dailydevUrl && doc.dailydev !== dailydevUrl) { + docSetPatch.dailydev = dailydevUrl; + } + if (synRefs.length > 0) { + docSetPatch.syndications = synRefs; + } + + if (Object.keys(docSetPatch).length > 0) { + prodMutations.push({ + patch: { + id: doc._id, + set: docSetPatch, + }, + }); + devMutations.push({ + patch: { + id: doc._id, + set: docSetPatch, + }, + }); + } + } + + const mapPath = path.resolve("scripts/dailydev-squad-map.json"); + fs.writeFileSync(mapPath, JSON.stringify(squadMap, null, 2)); + console.log(` Saved ${Object.keys(squadMap).length} Squad mappings to ${mapPath}`); + + console.log( + ` Prepared ${prodMutations.length} mutations (${totalSyndicationDocsSynced} syndication docs across ${sanityDocs.length} content items; ${matchedDailyDevCount} matched to daily.dev Squad).` + ); + + if (!token) { + console.log( + " (Skipping Sanity write commit locally because SANITY_API_WRITE_TOKEN is not set; will commit in GitHub Actions)" + ); + return; + } + + const commitBatches = async (datasetName, mutations) => { + const batchSize = 100; + for (let i = 0; i < mutations.length; i += batchSize) { + const slice = mutations.slice(i, i + batchSize); + const res = await fetch( + `https://${projectId}.api.sanity.io/v2025-09-30/data/mutate/${datasetName}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ mutations: slice }), + } + ); + if (!res.ok) { + console.warn(`Batch mutation warning on ${datasetName}:`, await res.text()); + } + } + }; + + await commitBatches("production", prodMutations); + console.log(" ✓ Committed all syndication records & platform links to Sanity (production)"); + await commitBatches("dev", devMutations); + console.log(" ✓ Committed all syndication records & platform links to Sanity (dev)"); +} + +main().catch((err) => { + console.error("Backfill error:", err); + process.exit(1); +}); diff --git a/scripts/dailydev-squad-map.json b/scripts/dailydev-squad-map.json new file mode 100644 index 00000000..fab720ef --- /dev/null +++ b/scripts/dailydev-squad-map.json @@ -0,0 +1,547 @@ +{ + "post-php2shell-cve-2026-109618": { + "dailydevUrl": "https://daily.dev/posts/two-critical-pre-authentication-rce-flaws-in-php-cve-2026-109618-and-cve-2026-109619-uncovered-by-lmbg2iyss", + "externalId": "lmBG2IYsS", + "syndicatedAt": "2026-10-24T03:38:28.188Z" + }, + "0531c732-d750-4dc4-b81f-7b6cfa7ebfae": { + "dailydevUrl": "https://daily.dev/posts/we-replaced-our-custom-pinecone-vector-rag-pipeline-with-sanity-context-knowledge-bases-and-the-nati-g0hcjrb95", + "externalId": "g0HCjrb95", + "syndicatedAt": "2026-10-24T04:04:42.303Z" + }, + "12da09be-f4cd-4fd4-be7e-0333dace2024": { + "dailydevUrl": "https://daily.dev/posts/a-deep-dive-review-of-magnus-hillestad-s-five-layers-of-agentic-context-on-the-sanity-blog-explorin-utdq1xczx", + "externalId": "Utdq1XCzx", + "syndicatedAt": "2026-10-24T04:24:04.574Z" + }, + "05eda048-efff-4e0f-8b76-83a2f482fa13": { + "dailydevUrl": "https://daily.dev/posts/hosted-image-apis-cost-3-to-4-cents-per-generation-using-runpod-flash-for-dockerless-serverless-gpu-hsmuowl3n", + "externalId": "hSmuoWL3n", + "syndicatedAt": "2026-10-24T04:23:33.175Z" + }, + "ccddd177-443b-4614-896c-9a8a801278ef": { + "dailydevUrl": "https://daily.dev/posts/from-the-first-unboxing-to-daily-wear-on-laptops-bottles-and-notebooks-these-iridescent-ultra-du-biq3wh0mo", + "externalId": "biq3wh0mO", + "syndicatedAt": "2026-10-24T04:23:01.757Z" + }, + "292601cc-5365-4644-9b34-82e67d033e4b": { + "dailydevUrl": "https://daily.dev/posts/curious-about-genai-for-engineers-learn-what-s-real-what-s-not-and-what-s-next-in-ai-full-guide--spm6hbumg", + "externalId": "sPM6hBUmG", + "syndicatedAt": "2026-10-24T04:22:30.355Z" + }, + "ab22f27a-2d5d-4dfa-ad61-b5dd9ef85334": { + "dailydevUrl": "https://daily.dev/posts/curious-about-oauth-mcp-servers-and-building-cool-chatgpt-apps-hear-from-max-of-stytch-as-we-dive-mocogjy41", + "externalId": "MoCOGjY41", + "syndicatedAt": "2026-10-24T04:21:58.934Z" + }, + "7fffc615-ed5a-491a-a5f3-e1dcf3338612": { + "dailydevUrl": "https://daily.dev/posts/firebase-sql-with-data-connect-how-to-use-semantic-searches-with-vector-embeddings-full-guide-http-spskkdg69", + "externalId": "SpSkKdG69", + "syndicatedAt": "2026-10-24T04:21:27.545Z" + }, + "f78ab69a-69a7-4e88-9496-22d1217bbd72": { + "dailydevUrl": "https://daily.dev/posts/explore-ai-identity-management-with-expert-insights-on-mcp-servers-agent-security-oauth-and-futur-zdbxfqv7p", + "externalId": "zDBxfqV7P", + "syndicatedAt": "2026-10-24T04:20:56.153Z" + }, + "a6cd2275-35e8-43a9-90fc-e114a5565103": { + "dailydevUrl": "https://daily.dev/posts/the-firebase-platform-is-evolving-with-a-focus-on-generative-ai-deeper-integration-with-google-clou-q9gigwmey", + "externalId": "Q9GIgWmeY", + "syndicatedAt": "2026-10-24T04:20:24.727Z" + }, + "3a517661-b995-46e3-bed0-a755bb8e6c9b": { + "dailydevUrl": "https://daily.dev/posts/explore-firebase-data-connect-to-enhance-your-app-with-powerful-sql-capabilities-bridging-the-gap-b-r8mwr6uhp", + "externalId": "R8MWR6UHP", + "syndicatedAt": "2026-10-24T04:19:53.311Z" + }, + "a3607438-2fa0-4660-be27-0b16bfb22b6c": { + "dailydevUrl": "https://daily.dev/posts/curious-about-no-code-app-building-dive-in-as-we-explore-adalo-vs-bubble-micro-saas-ideas-and-rea-boisy0qwm", + "externalId": "BOiSY0QwM", + "syndicatedAt": "2026-10-24T04:19:21.895Z" + }, + "28b2a0b5-07d1-4b23-a515-2cdc1c1e7731": { + "dailydevUrl": "https://daily.dev/posts/discover-how-to-automate-your-tasks-and-reclaim-your-time-join-alex-and-joe-as-they-discuss-podcast-i4jiigxcj", + "externalId": "I4JIIGXcJ", + "syndicatedAt": "2026-10-24T04:18:50.460Z" + }, + "d2d147a2-5954-4ee4-9548-222667035923": { + "dailydevUrl": "https://daily.dev/posts/tired-of-aws-running-in-the-cloud-how-about-right-on-your-desktop-full-guide-https-codingcat-de-ahrg7mxgf", + "externalId": "AhRg7mXgf", + "syndicatedAt": "2026-10-24T04:18:19.029Z" + }, + "e4aefc68-fdf9-441a-962f-653d10a3f01a": { + "dailydevUrl": "https://daily.dev/posts/dive-into-hands-on-spicedb-demos-real-world-permission-modeling-and-ai-powered-scenario-testing-f-rsnu3kl8m", + "externalId": "RSnU3kl8M", + "syndicatedAt": "2026-10-24T04:17:47.616Z" + }, + "b9dfe3ba-bd69-4588-97fe-012b3a370f14": { + "dailydevUrl": "https://daily.dev/posts/dive-into-authzed-spicedb-and-permissions-full-guide-https-codingcat-dev-podcast-scaling-permi-geecxk2xu", + "externalId": "gEeCXK2Xu", + "syndicatedAt": "2026-10-24T04:17:16.193Z" + }, + "4628e5fe-28d8-49c4-af37-15904dcf787a": { + "dailydevUrl": "https://daily.dev/posts/anthony-is-back-join-us-for-a-fun-chat-on-coding-dev-tools-and-ai-full-guide-https-codingcat--y5jadtty7", + "externalId": "Y5JaDtTy7", + "syndicatedAt": "2026-10-24T04:16:44.791Z" + }, + "8efab30e-08b7-408d-ac98-c51238069042": { + "dailydevUrl": "https://daily.dev/posts/explore-ray-camden-s-tech-journey-dive-into-ai-for-beginners-discover-the-latest-in-ai-from-codi-lnd5602ak", + "externalId": "LND5602AK", + "syndicatedAt": "2026-10-24T04:16:13.381Z" + }, + "59b0c713-79dc-45cc-b7ef-95479932611b": { + "dailydevUrl": "https://daily.dev/posts/how-are-you-using-ai-is-it-the-right-way--qq8v9nwwt", + "externalId": "qQ8v9nwwt", + "syndicatedAt": "2025-02-19T21:54:16.927Z" + }, + "668e776c-0102-416a-9f83-78c024dae636": { + "dailydevUrl": "https://daily.dev/posts/supastarter-next-js-setup-done-right-all-the-essentials-zero-clutter-check-out-this-game-changin-47pxrfnos", + "externalId": "47pxrfnOS", + "syndicatedAt": "2025-02-09T03:56:19.220Z" + }, + "6c788031-cace-4c70-bcf5-66d0dcda085f": { + "dailydevUrl": "https://daily.dev/posts/join-us-as-we-explore-cloudinary-s-new-components-for-next-js-learn-how-to-optimize-images-integra-snhuw28rs", + "externalId": "sNhUw28RS", + "syndicatedAt": "2026-10-24T04:14:44.407Z" + }, + "c091d38b-4812-4922-a6f9-2e8ea7aa1dd1": { + "dailydevUrl": "https://daily.dev/posts/onoskpiyl", + "externalId": "ONOSKpIYl", + "syndicatedAt": "2024-10-17T11:58:06.945Z" + }, + "94893625-c4bc-4adc-b59b-391fa13f2b5e": { + "dailydevUrl": "https://daily.dev/posts/zoulzswqc", + "externalId": "ZOULzSWqc", + "syndicatedAt": "2024-10-17T11:57:42.572Z" + }, + "c49ed931-7b57-4d27-a659-026d0c5c0e93": { + "dailydevUrl": "https://daily.dev/posts/jqwnnrao4", + "externalId": "jQWNNRao4", + "syndicatedAt": "2024-09-25T21:32:56.208Z" + }, + "09d69095-4287-4b69-9f02-3d2e873f358e": { + "dailydevUrl": "https://daily.dev/posts/zedz8ywdw", + "externalId": "ZeDZ8YwDw", + "syndicatedAt": "2024-09-25T21:08:56.676Z" + }, + "4edb915d-6b6c-4d9e-9692-0127da16ca1b": { + "dailydevUrl": "https://daily.dev/posts/qchcizrfl", + "externalId": "qchciZrfl", + "syndicatedAt": "2024-09-25T21:08:51.861Z" + }, + "120b48ee-cd22-429e-b36f-d0a4704565e4": { + "dailydevUrl": "https://daily.dev/posts/qd9o8nbyx", + "externalId": "QD9O8nByx", + "syndicatedAt": "2024-09-25T21:08:42.427Z" + }, + "2461c03e-63b1-4a26-95fb-6ef905af4b0e": { + "dailydevUrl": "https://daily.dev/posts/for-those-using-build-workflows-stick-with-github-workflows-if-you-want-to-try-out-vercel-s-cron-jo-itcfixxbl", + "externalId": "ItcFIxxbL", + "syndicatedAt": "2024-07-17T00:05:10.399Z" + }, + "30be7590-3e6f-412f-bcd9-a5421ed57997": { + "dailydevUrl": "https://daily.dev/posts/does-anyone-like-the-direct-video-links-for-daily-dev--jpfpouczo", + "externalId": "jpfpoUczO", + "syndicatedAt": "2024-07-17T00:06:10.671Z" + }, + "796783b3-91df-4657-9db0-316a77cf4cc1": { + "dailydevUrl": "https://daily.dev/posts/catch-the-latest-updates-on-flutter-and-firebase-from-google-i-o-2024-hear-roman-jacquez-s-insights-oprhhtylq", + "externalId": "OPrHhTYlQ", + "syndicatedAt": "2024-07-05T18:55:38.404Z" + }, + "ca69fea5-608a-4cee-9eba-8a2d915d7c84": { + "dailydevUrl": "https://daily.dev/posts/explore-how-to-integrate-firebase-with-vonage-apis-in-our-latest-developer-podcast-learn-about-auth-padn54qqj", + "externalId": "Padn54qqj", + "syndicatedAt": "2024-07-05T18:54:54.950Z" + }, + "ee3176c9-76b8-44cf-9a87-0fcac0aa8e53": { + "dailydevUrl": "https://daily.dev/posts/discover-the-latest-from-google-io-and-firebase-with-insights-from-a-devrel-engineer-watch-now-for--znyf4sksw", + "externalId": "Znyf4SKsW", + "syndicatedAt": "2024-07-02T19:16:56.725Z" + }, + "1bb4346d-eba0-41af-aecf-644bc7bcdefe": { + "dailydevUrl": "https://daily.dev/posts/discover-advanced-angular-tips-and-tricks-with-mohammad-from-the-angular-cookbook-2nd-edition-watch-imdd1m19v", + "externalId": "IMdD1m19v", + "syndicatedAt": "2024-07-02T19:16:45.290Z" + }, + "59b06426-2b94-4b31-ab7a-f18659c121e5": { + "dailydevUrl": "https://daily.dev/posts/join-us-as-chris-sev-shares-his-dev-journey-insights-on-building-scotch-io-and-his-exciting-new-ai-lawganvu3", + "externalId": "LaWgANVu3", + "syndicatedAt": "2024-07-02T19:16:33.588Z" + }, + "b61bf55d-ab3d-4164-8a35-f5f7eb770536": { + "dailydevUrl": "https://daily.dev/posts/firebase-security-rules-are-a-powerful-feature-that-allows-you-to-control-access-to-your-app-s-data--upwfu02yi", + "externalId": "uPWfu02yI", + "syndicatedAt": "2024-07-02T19:16:16.143Z" + }, + "ZKdo3vLUqcb4sYreiCPA9U": { + "dailydevUrl": "https://daily.dev/posts/7k6tr7e5m", + "externalId": "7k6TR7e5m", + "syndicatedAt": "2024-03-06T22:20:14.263Z" + }, + "8510028d-dec1-40c7-bdbe-bed50dbc1f3d": { + "dailydevUrl": "https://daily.dev/posts/q3ojktlsp", + "externalId": "q3OjKtLsP", + "syndicatedAt": "2024-07-02T19:15:58.474Z" + }, + "ZKdo3vLUqcb4sYreiCPARs": { + "dailydevUrl": "https://daily.dev/posts/fwqzdl8kf", + "externalId": "fwQzdL8Kf", + "syndicatedAt": "2024-03-06T22:20:05.445Z" + }, + "ZKdo3vLUqcb4sYreiCPAb4": { + "dailydevUrl": "https://daily.dev/posts/jqb6moxkv", + "externalId": "jQB6MOXkV", + "syndicatedAt": "2024-03-01T17:11:02.667Z" + }, + "ZKdo3vLUqcb4sYreiCPAkG": { + "dailydevUrl": "https://daily.dev/posts/mimvvxzqk", + "externalId": "mimvvxzqK", + "syndicatedAt": "2024-03-01T17:10:58.813Z" + }, + "ZKdo3vLUqcb4sYreiCPAIg": { + "dailydevUrl": "https://daily.dev/posts/ifqkzfqkp", + "externalId": "IFqKZfqKp", + "syndicatedAt": "2024-02-08T16:34:08.104Z" + }, + "ZKdo3vLUqcb4sYreiCP9GK": { + "dailydevUrl": "https://daily.dev/posts/breaking-down-svelte-view-transitions-4xxa7vmpx", + "externalId": "4xxA7VMpx", + "syndicatedAt": "2024-02-08T16:32:53.918Z" + }, + "ZKdo3vLUqcb4sYreiCPAtS": { + "dailydevUrl": "https://daily.dev/posts/great-chat-about-how-to-use-raycast-d-1qrh6lh7a", + "externalId": "1Qrh6Lh7A", + "syndicatedAt": "2024-02-08T16:32:21.023Z" + }, + "ZKdo3vLUqcb4sYreiCPB2e": { + "dailydevUrl": "https://daily.dev/posts/sauwtypcy", + "externalId": "sAUwtYpcY", + "syndicatedAt": "2024-01-31T17:44:54.885Z" + }, + "LzgcvqoNSwz5R8loykbNx1": { + "dailydevUrl": "https://daily.dev/posts/bgdvi0vur", + "externalId": "BGdvI0VuR", + "syndicatedAt": "2024-01-31T17:44:44.267Z" + }, + "ZKdo3vLUqcb4sYreiCPBBq": { + "dailydevUrl": "https://daily.dev/posts/i-had-a-lot-of-fun-chatting-with-richard-bray-make-sure-to-check-out-his-awesome-tiktok-channel-htt-evh7eyq1v", + "externalId": "evh7EyQ1V", + "syndicatedAt": "2024-01-30T19:18:18.836Z" + }, + "ZKdo3vLUqcb4sYreiCMcm4": { + "dailydevUrl": "https://daily.dev/posts/zschrfyhf", + "externalId": "zSCHRfyHF", + "syndicatedAt": "2023-12-26T17:08:15.180Z" + }, + "ZKdo3vLUqcb4sYreiCMQiK": { + "dailydevUrl": "https://daily.dev/posts/it-is-usually-pretty-hard-to-build-up-a-podcast-here-is-a-great-breakdown-of-it--esdnxx972", + "externalId": "esDnxx972", + "syndicatedAt": "2023-12-18T16:21:49.848Z" + }, + "ZKdo3vLUqcb4sYreiCMdMq": { + "dailydevUrl": "https://daily.dev/posts/vglkr70j0", + "externalId": "VGlkR70J0", + "syndicatedAt": "2023-12-26T17:08:09.473Z" + }, + "ZKdo3vLUqcb4sYreiCMVku": { + "dailydevUrl": "https://daily.dev/posts/qyvexqhqs", + "externalId": "qyVEXQhqS", + "syndicatedAt": "2023-12-26T17:08:04.029Z" + }, + "LzgcvqoNSwz5R8loyka1hp": { + "dailydevUrl": "https://daily.dev/posts/kfwmdqjfw", + "externalId": "kFWmDQJFw", + "syndicatedAt": "2023-12-26T17:07:44.874Z" + }, + "ZKdo3vLUqcb4sYreiCMcNY": { + "dailydevUrl": "https://daily.dev/posts/uvgino2s9", + "externalId": "uVGiNO2s9", + "syndicatedAt": "2023-12-26T17:07:28.823Z" + }, + "ZKdo3vLUqcb4sYreiCMbUO": { + "dailydevUrl": "https://daily.dev/posts/tdrpxvwyl", + "externalId": "tDrPxVWYL", + "syndicatedAt": "2023-12-26T17:07:23.288Z" + }, + "ZKdo3vLUqcb4sYreiCMYWW": { + "dailydevUrl": "https://daily.dev/posts/08wheozyb", + "externalId": "08wHEoZYB", + "syndicatedAt": "2023-12-26T17:06:41.809Z" + }, + "ZKdo3vLUqcb4sYreiCMatc": { + "dailydevUrl": "https://daily.dev/posts/bkj7ho5l0", + "externalId": "BKj7hO5l0", + "syndicatedAt": "2023-12-26T17:06:35.729Z" + }, + "ZKdo3vLUqcb4sYreiCP9hu": { + "dailydevUrl": "https://daily.dev/posts/tqanojw6v", + "externalId": "tqAnoJw6V", + "syndicatedAt": "2023-12-26T17:06:29.555Z" + }, + "ZKdo3vLUqcb4sYreiCMWCU": { + "dailydevUrl": "https://daily.dev/posts/vkagn8lrh", + "externalId": "vkAgN8LrH", + "syndicatedAt": "2023-12-26T17:06:23.758Z" + }, + "ZKdo3vLUqcb4sYreiCPA0I": { + "dailydevUrl": "https://daily.dev/posts/rm0v1faf5", + "externalId": "rM0v1FaF5", + "syndicatedAt": "2023-12-26T17:06:16.240Z" + }, + "a5bec743-f096-484a-974d-02f493ecae7d": { + "dailydevUrl": "https://daily.dev/posts/mtwfpzf9n", + "externalId": "MtWFPzF9n", + "syndicatedAt": "2023-12-26T17:06:09.988Z" + }, + "ZKdo3vLUqcb4sYreiCMRkg": { + "dailydevUrl": "https://daily.dev/posts/mtwfpzf9n", + "externalId": "MtWFPzF9n", + "syndicatedAt": "2023-12-26T17:06:09.988Z" + }, + "ZKdo3vLUqcb4sYreiCMabE": { + "dailydevUrl": "https://daily.dev/posts/w0sk2xsga", + "externalId": "w0sK2xSGA", + "syndicatedAt": "2023-12-26T17:06:04.307Z" + }, + "ZKdo3vLUqcb4sYreiCMYfi": { + "dailydevUrl": "https://daily.dev/posts/qp5hmdxnr", + "externalId": "QP5hmdxNR", + "syndicatedAt": "2023-12-26T17:05:58.317Z" + }, + "ZKdo3vLUqcb4sYreiCMd4S": { + "dailydevUrl": "https://daily.dev/posts/a32lfpepl", + "externalId": "A32lFpEPL", + "syndicatedAt": "2023-12-26T17:05:51.265Z" + }, + "ZKdo3vLUqcb4sYreiCMWe4": { + "dailydevUrl": "https://daily.dev/posts/xabijcief", + "externalId": "XAbijCiEf", + "syndicatedAt": "2023-12-26T17:05:45.187Z" + }, + "LzgcvqoNSwz5R8loyka0MD": { + "dailydevUrl": "https://daily.dev/posts/bxmgu3dc1", + "externalId": "bxmGu3dc1", + "syndicatedAt": "2023-12-26T17:05:39.467Z" + }, + "ZKdo3vLUqcb4sYreiCMU4i": { + "dailydevUrl": "https://daily.dev/posts/lvjm9lats", + "externalId": "LvJM9lATs", + "syndicatedAt": "2023-12-26T17:05:33.874Z" + }, + "LzgcvqoNSwz5R8loykZzj1": { + "dailydevUrl": "https://daily.dev/posts/ygra60ywt", + "externalId": "yGra60YwT", + "syndicatedAt": "2023-12-26T17:05:28.056Z" + }, + "ZKdo3vLUqcb4sYreiCMa0S": { + "dailydevUrl": "https://daily.dev/posts/rejskzf4m", + "externalId": "REJSkzF4M", + "syndicatedAt": "2023-12-26T17:05:20.974Z" + }, + "LzgcvqoNSwz5R8loyka0bt": { + "dailydevUrl": "https://daily.dev/posts/f6deajc6m", + "externalId": "F6deaJc6M", + "syndicatedAt": "2023-12-26T17:05:14.559Z" + }, + "ZKdo3vLUqcb4sYreiCMSOW": { + "dailydevUrl": "https://daily.dev/posts/fda6qwt2z", + "externalId": "fda6qwT2z", + "syndicatedAt": "2023-12-26T17:05:08.022Z" + }, + "ZKdo3vLUqcb4sYreiCMbmm": { + "dailydevUrl": "https://daily.dev/posts/isdwz4auv", + "externalId": "iSDwZ4AUv", + "syndicatedAt": "2023-12-26T17:04:50.516Z" + }, + "ZKdo3vLUqcb4sYreiCMUrk": { + "dailydevUrl": "https://daily.dev/posts/qdml1wruu", + "externalId": "QdML1WRuu", + "syndicatedAt": "2023-12-26T17:04:42.505Z" + }, + "ZKdo3vLUqcb4sYreiCMVA8": { + "dailydevUrl": "https://daily.dev/posts/7a0pgpebf", + "externalId": "7A0pGpebF", + "syndicatedAt": "2023-12-26T17:04:37.280Z" + }, + "ZKdo3vLUqcb4sYreiCMb2o": { + "dailydevUrl": "https://daily.dev/posts/dtfsjrhmu", + "externalId": "dTfSjrhmU", + "syndicatedAt": "2023-12-26T17:04:31.790Z" + }, + "ZKdo3vLUqcb4sYreiCMYou": { + "dailydevUrl": "https://daily.dev/posts/dmhb6fttl", + "externalId": "dmHb6Fttl", + "syndicatedAt": "2023-12-26T17:04:25.113Z" + }, + "ZKdo3vLUqcb4sYreiCMebS": { + "dailydevUrl": "https://daily.dev/posts/dmhb6fttl", + "externalId": "dmHb6Fttl", + "syndicatedAt": "2023-12-26T17:04:25.113Z" + }, + "ZKdo3vLUqcb4sYreiCMZ7I": { + "dailydevUrl": "https://daily.dev/posts/3ptvzqcfn", + "externalId": "3PTvZqcfN", + "syndicatedAt": "2023-12-26T17:04:11.371Z" + }, + "ZKdo3vLUqcb4sYreiCMWwS": { + "dailydevUrl": "https://daily.dev/posts/ytfbjurzs", + "externalId": "yTFBJurZs", + "syndicatedAt": "2023-12-26T17:04:04.144Z" + }, + "ZKdo3vLUqcb4sYreiCMcEM": { + "dailydevUrl": "https://daily.dev/posts/n7occgwbe", + "externalId": "n7OcCGwbE", + "syndicatedAt": "2023-12-26T17:03:58.607Z" + }, + "ZKdo3vLUqcb4sYreiCMdDe": { + "dailydevUrl": "https://daily.dev/posts/at94ffogf", + "externalId": "At94FfOgF", + "syndicatedAt": "2023-12-26T17:03:53.079Z" + }, + "ZKdo3vLUqcb4sYreiCMQua": { + "dailydevUrl": "https://daily.dev/posts/hknqvxozy", + "externalId": "HKnqVxozy", + "syndicatedAt": "2023-12-26T17:03:43.821Z" + }, + "ZKdo3vLUqcb4sYreiCMVSW": { + "dailydevUrl": "https://daily.dev/posts/e1auwswy8", + "externalId": "E1aUwswy8", + "syndicatedAt": "2023-12-26T17:03:37.897Z" + }, + "ZKdo3vLUqcb4sYreiCMW3I": { + "dailydevUrl": "https://daily.dev/posts/jbibir1ua", + "externalId": "jBIBir1Ua", + "syndicatedAt": "2023-12-26T17:03:31.977Z" + }, + "ZKdo3vLUqcb4sYreiCMWUs": { + "dailydevUrl": "https://daily.dev/posts/pobj2qnzj", + "externalId": "poBJ2qNzj", + "syndicatedAt": "2023-12-26T17:03:25.124Z" + }, + "ZKdo3vLUqcb4sYreiCPSUQ": { + "dailydevUrl": "https://daily.dev/posts/c0fw9wlx7", + "externalId": "c0FW9WlX7", + "syndicatedAt": "2023-12-26T17:02:34.539Z" + }, + "ZKdo3vLUqcb4sYreiCMRG2": { + "dailydevUrl": "https://daily.dev/posts/zdai9rkk6", + "externalId": "zDAi9rkK6", + "syndicatedAt": "2023-12-26T17:03:17.732Z" + }, + "LzgcvqoNSwz5R8loyka1S9": { + "dailydevUrl": "https://daily.dev/posts/1rz4y7fsu", + "externalId": "1rz4y7FSu", + "syndicatedAt": "2023-12-26T17:02:47.405Z" + }, + "ZKdo3vLUqcb4sYreiCMbda": { + "dailydevUrl": "https://daily.dev/posts/f9bweajqc", + "externalId": "f9bweaJQC", + "syndicatedAt": "2023-12-26T17:02:40.897Z" + }, + "ZKdo3vLUqcb4sYreiCMWLg": { + "dailydevUrl": "https://daily.dev/posts/c0fw9wlx7", + "externalId": "c0FW9WlX7", + "syndicatedAt": "2023-12-26T17:02:34.539Z" + }, + "LzgcvqoNSwz5R8loyka06X": { + "dailydevUrl": "https://daily.dev/posts/qscnp2fuu", + "externalId": "QsCNp2FUu", + "syndicatedAt": "2023-12-26T17:02:28.891Z" + }, + "ZKdo3vLUqcb4sYreiCMZPg": { + "dailydevUrl": "https://daily.dev/posts/ug9tileae", + "externalId": "UG9TilEaE", + "syndicatedAt": "2023-12-26T17:02:22.733Z" + }, + "ZKdo3vLUqcb4sYreiCMc5A": { + "dailydevUrl": "https://daily.dev/posts/42wdyl7wo", + "externalId": "42wdYl7Wo", + "syndicatedAt": "2023-12-26T17:02:17.051Z" + }, + "ZKdo3vLUqcb4sYreiCPX8U": { + "dailydevUrl": "https://daily.dev/posts/join-us-as-we-explore-cloudinary-s-new-components-for-next-js-learn-how-to-optimize-images-integra-snhuw28rs", + "externalId": "sNhUw28RS", + "syndicatedAt": "2026-10-24T04:14:44.407Z" + }, + "ZKdo3vLUqcb4sYreiCMUN6": { + "dailydevUrl": "https://daily.dev/posts/5cjkupzg2", + "externalId": "5CjkupzG2", + "syndicatedAt": "2023-12-26T17:02:10.648Z" + }, + "LzgcvqoNSwz5R8loykZyvN": { + "dailydevUrl": "https://daily.dev/posts/rejskzf4m", + "externalId": "REJSkzF4M", + "syndicatedAt": "2023-12-26T17:05:20.974Z" + }, + "ZKdo3vLUqcb4sYreiCMYNK": { + "dailydevUrl": "https://daily.dev/posts/o73qybxqw", + "externalId": "O73QYBxQw", + "syndicatedAt": "2023-12-26T17:02:04.512Z" + }, + "ZKdo3vLUqcb4sYreiCMdW2": { + "dailydevUrl": "https://daily.dev/posts/tfhe6wjte", + "externalId": "TfHe6WJTe", + "syndicatedAt": "2023-12-26T17:01:58.769Z" + }, + "ZKdo3vLUqcb4sYreiCMh1c": { + "dailydevUrl": "https://daily.dev/posts/a3vkjhktj", + "externalId": "a3VKJHKTJ", + "syndicatedAt": "2023-12-26T17:01:52.406Z" + }, + "ZKdo3vLUqcb4sYreiCMeke": { + "dailydevUrl": "https://daily.dev/posts/s8o3qnsbr", + "externalId": "S8O3QnsbR", + "syndicatedAt": "2023-12-26T17:01:45.368Z" + }, + "ZKdo3vLUqcb4sYreiCMbLC": { + "dailydevUrl": "https://daily.dev/posts/neojs55oj", + "externalId": "neOJs55OJ", + "syndicatedAt": "2023-12-26T17:01:38.600Z" + }, + "LzgcvqoNSwz5R8loykZzyh": { + "dailydevUrl": "https://daily.dev/posts/edlzgd2dq", + "externalId": "EDLzgD2dQ", + "syndicatedAt": "2023-12-26T17:01:32.594Z" + }, + "LzgcvqoNSwz5R8loykZzVx": { + "dailydevUrl": "https://daily.dev/posts/yzokrgbd6", + "externalId": "yZOKRGBD6", + "syndicatedAt": "2023-12-26T17:01:25.870Z" + }, + "LzgcvqoNSwz5R8loykZyIB": { + "dailydevUrl": "https://daily.dev/posts/nnvhqiks1", + "externalId": "NnVHQIkS1", + "syndicatedAt": "2023-12-26T17:01:19.149Z" + }, + "ZKdo3vLUqcb4sYreiCMcWk": { + "dailydevUrl": "https://daily.dev/posts/al0tcmge2", + "externalId": "AL0Tcmge2", + "syndicatedAt": "2023-12-26T17:01:13.843Z" + }, + "LzgcvqoNSwz5R8loykZxzt": { + "dailydevUrl": "https://daily.dev/posts/onmeybjcx", + "externalId": "OnmEYbjCX", + "syndicatedAt": "2023-12-26T17:01:06.263Z" + }, + "LzgcvqoNSwz5R8loykZyQ1": { + "dailydevUrl": "https://daily.dev/posts/rgnrgpdab", + "externalId": "rGNRGPdAB", + "syndicatedAt": "2023-12-26T17:00:49.764Z" + }, + "LzgcvqoNSwz5R8loykZxhb": { + "dailydevUrl": "https://daily.dev/posts/2fbw7y9jo", + "externalId": "2fBW7Y9jo", + "syndicatedAt": "2023-12-26T17:00:42.479Z" + }, + "ZKdo3vLUqcb4sYreiCMY4w": { + "dailydevUrl": "https://daily.dev/posts/xvr26ppsu", + "externalId": "XVR26Ppsu", + "syndicatedAt": "2023-12-26T17:00:33.769Z" + }, + "ZKdo3vLUqcb4sYreiCMcvG": { + "dailydevUrl": "https://daily.dev/posts/gdvhk9ruo", + "externalId": "gDVhK9Ruo", + "syndicatedAt": "2023-12-26T17:00:10.364Z" + } +} \ No newline at end of file diff --git a/scripts/run-syndicate-post.mjs b/scripts/run-syndicate-post.mjs index 38f7acf4..819ba60d 100644 --- a/scripts/run-syndicate-post.mjs +++ b/scripts/run-syndicate-post.mjs @@ -143,7 +143,37 @@ async function run() { return imgBuilder.image(source).auto("format").fit("max"); }; - const serializeContent = (content) => { + const extractYouTubeId = (url) => { + if (!url || typeof url !== "string") return null; + const trimmed = url.trim(); + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/, + /^([A-Za-z0-9_-]{11})$/, + ]; + for (const re of patterns) { + const m = trimmed.match(re); + if (m) return m[1]; + } + return null; + }; + + const formatYouTubeBlock = (url, targetPlatform = "devto") => { + const ytId = extractYouTubeId(url); + if (!ytId && !url) return ""; + const watchUrl = ytId ? `https://www.youtube.com/watch?v=${ytId}` : url; + if (targetPlatform === "devto") { + return `{% youtube ${ytId || url.replace("live", "embed")} %}`; + } + if (targetPlatform === "hashnode") { + return `%[${watchUrl}]`; + } + const thumbUrl = ytId ? `https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg` : ""; + return thumbUrl + ? `[![Watch on YouTube](${thumbUrl})](${watchUrl})\n\n**[▶ Watch the video on YouTube](${watchUrl})**` + : `**[▶ Watch on YouTube](${watchUrl})**`; + }; + + const serializeContent = (content, targetPlatform = "devto") => { if (!content || !Array.isArray(content) || content.length === 0) { return ""; } @@ -155,13 +185,18 @@ async function run() { const url = urlForImage(value)?.url(); return url ? `![](${url})` : ""; }, + youtube: ({ value }) => formatYouTubeBlock(value?.youtube || value?.url || "", targetPlatform), + youtubeShorts: ({ value }) => + Array.isArray(value?.shorts) + ? value.shorts.map((s) => formatYouTubeBlock(s, targetPlatform)).join("\n\n") + : "", codepen: ({ value }) => `{% codepen ${value?.url || ""} %}`, codesandbox: ({ value }) => { const id = value?.url?.split("https://codesandbox.io/p/sandbox/")?.at(-1); return `{% codesandbox ${id || value?.url || ""} %}`; }, twitter: ({ value }) => `{% twitter ${value?.id || ""} %}`, - quote: ({ value }) => `> ${serializeContent(value?.content)}`, + quote: ({ value }) => `> ${serializeContent(value?.content, targetPlatform)}`, }, }); } catch (err) { @@ -170,9 +205,29 @@ async function run() { } }; - const contentMarkdown = serializeContent(doc.content); + const contentMarkdown = serializeContent(doc.content, "devto"); + const hashnodeMarkdown = serializeContent(doc.content, "hashnode"); + const awsMarkdown = serializeContent(doc.content, "awsbuilder"); + const topYouTubeId = extractYouTubeId(doc.youtube); + const topYouTubeWatchUrl = topYouTubeId + ? `https://www.youtube.com/watch?v=${topYouTubeId}` + : doc.youtube || ""; console.log(`Content serialized to Markdown: ${contentMarkdown.length} characters`); + if (doc.youtube) { + await client.createOrReplace({ + _id: `syndication.${doc._id}.youtube`, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: "youtube", + status: "published", + canonicalUrl, + externalUrl: topYouTubeWatchUrl, + externalId: topYouTubeId || doc.youtube, + syndicatedAt: doc.date || new Date().toISOString(), + }); + } + const docTags = Array.isArray(doc.tags) ? doc.tags : []; const hasAwsTag = docTags.some( (t) => t?.value?.toLowerCase() === "aws" || t?.label?.toLowerCase() === "aws" @@ -200,7 +255,9 @@ async function run() { ).slice(0, 4); const mainImageUrl = urlForImage(doc.coverImage)?.width(1280).url() || ""; - const youtubeEmbed = doc.youtube ? `{% youtube ${doc.youtube.replace("live", "embed")} %}\n\n` : ""; + const youtubeEmbed = doc.youtube + ? `{% youtube ${topYouTubeId || doc.youtube.replace("live", "embed")} %}\n\n**[▶ Watch on YouTube & Subscribe to CodingCat.dev](${topYouTubeWatchUrl}${topYouTubeWatchUrl.includes("?") ? "&" : "?"}sub_confirmation=1)**\n\n` + : ""; const devtoArticlePayload = { article: { @@ -336,7 +393,9 @@ async function run() { ]; const hashnodeCoverImageUrl = urlForImage(doc.coverImage)?.width(1600).url() || ""; - const youtubeEmbed = doc.youtube ? `%[${doc.youtube.replace("live", "embed")}]\n\n` : ""; + const youtubeEmbed = doc.youtube + ? `%[${topYouTubeWatchUrl}]\n\n**[▶ Watch on YouTube & Subscribe to CodingCat.dev](${topYouTubeWatchUrl}${topYouTubeWatchUrl.includes("?") ? "&" : "?"}sub_confirmation=1)**\n\n` + : ""; const articleInput = { title: doc.title || "Untitled", @@ -346,7 +405,7 @@ async function run() { tags: hashnodeTags, ...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}), originalArticleURL: canonicalUrl, - contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, + contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${hashnodeMarkdown}`, }; try { @@ -497,13 +556,15 @@ async function run() { ); if (awsTags.length === 0) awsTags.push("aws"); - const youtubeMd = doc.youtube ? `Watch on YouTube: [${doc.youtube}](${doc.youtube})\n\n` : ""; + const youtubeMd = doc.youtube + ? `${formatYouTubeBlock(doc.youtube, "awsbuilder")}\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}`, + contentBody: `*Originally published at [${canonicalUrl}](${canonicalUrl})*\n\n${youtubeMd}${awsMarkdown}`, canonicalUrl, preferredUrlSlug: slug, tags: awsTags, @@ -598,8 +659,18 @@ async function run() { ? "lmBG2IYsS" : null; - const commentary = (doc.excerpt || doc.title || "New on CodingCat.dev!").slice(0, 240); - const coverImageUrl = urlForImage(doc.coverImage)?.width(1280).format("jpg").url() || ""; + const squadSuffix = topYouTubeWatchUrl ? `\nFull guide: ${canonicalUrl}` : ""; + const maxExcerptLen = Math.max(40, 240 - squadSuffix.length); + const rawCommentary = (doc.excerpt || doc.title || "New on CodingCat.dev!").trim(); + const trimmedCommentary = + rawCommentary.length > maxExcerptLen + ? `${rawCommentary.slice(0, maxExcerptLen - 3).trim()}...` + : rawCommentary; + const commentary = `${trimmedCommentary}${squadSuffix}`.slice(0, 240); + const coverImageUrl = + urlForImage(doc.coverImage)?.width(1280).format("jpg").url() || + (topYouTubeId ? `https://i.ytimg.com/vi/${topYouTubeId}/maxresdefault.jpg` : ""); + const squadExternalLink = topYouTubeWatchUrl || canonicalUrl; if (!postId && doc.dailydev) { const match = doc.dailydev.match(/-([A-Za-z0-9]{9})$/); @@ -656,11 +727,51 @@ async function run() { title: doc.title || "Untitled", commentary, ...(coverImageUrl ? { imageUrl: coverImageUrl } : {}), - externalLink: canonicalUrl, + externalLink: squadExternalLink, }, }), }); - const createData = await createRes.json(); + let createData = await createRes.json(); + if (createData?.errors && squadExternalLink !== canonicalUrl) { + console.warn( + "daily.dev Squad YouTube link warning, retrying with canonicalUrl:", + createData.errors + ); + const retryRes = await fetch("https://api.daily.dev/graphql", { + method: "POST", + headers: gqlHeaders, + body: JSON.stringify({ + query: `mutation CreatePostInMultipleSources( + $sourceIds: [ID!]! + $title: String + $commentary: String + $imageUrl: String + $externalLink: String + ) { + createPostInMultipleSources( + sourceIds: $sourceIds + title: $title + commentary: $commentary + imageUrl: $imageUrl + externalLink: $externalLink + ) { + id + sourceId + type + slug + } + }`, + variables: { + sourceIds: [squadSourceId], + title: doc.title || "Untitled", + commentary: rawCommentary.slice(0, 240), + ...(coverImageUrl ? { imageUrl: coverImageUrl } : {}), + externalLink: canonicalUrl, + }, + }), + }); + createData = await retryRes.json(); + } if (createData?.errors) { throw new Error( `daily.dev createPostInMultipleSources error: ${JSON.stringify(createData.errors)}` @@ -714,7 +825,15 @@ async function run() { // --- LINK SYNDICATION REFERENCES ON PARENT --- try { - const platforms = ["devto", "hashnode", "dailydev", ...(shouldSyndicateAws ? ["awsbuilder"] : [])]; + const platforms = [ + "devto", + "hashnode", + "dailydev", + "youtube", + "medium", + "reddit", + ...(shouldSyndicateAws ? ["awsbuilder"] : []), + ]; const candidateRefs = platforms.map((p) => `syndication.${doc._id}.${p}`); const existingSynDocs = await Promise.all( candidateRefs.map((ref) => client.getDocument(ref).catch(() => null))