From fc2c85b415c617a089c7a8d2cb68d767cf6dffd2 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 17:06:01 +0100 Subject: [PATCH 1/7] feat(syndication): add syndicate-post workflow and dispatch runner (#708) --- .github/workflows/syndicate-post.yml | 50 +++++ scripts/run-syndicate-post.mjs | 287 +++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 .github/workflows/syndicate-post.yml create mode 100644 scripts/run-syndicate-post.mjs diff --git a/.github/workflows/syndicate-post.yml b/.github/workflows/syndicate-post.yml new file mode 100644 index 00000000..bf52c4f7 --- /dev/null +++ b/.github/workflows/syndicate-post.yml @@ -0,0 +1,50 @@ +name: Syndicate Post + +on: + workflow_dispatch: + inputs: + document_id: + description: "Sanity Document ID to syndicate" + required: true + default: "05eda048-efff-4e0f-8b76-83a2f482fa13" + type: string + dataset: + description: "Target Sanity Dataset" + required: true + default: "production" + type: choice + options: + - production + - dev + +jobs: + syndicate: + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Syndicate Post + run: | + node scripts/run-syndicate-post.mjs + env: + TARGET_DOCUMENT_ID: ${{ inputs.document_id }} + SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} + SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }} + SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }} + PRIVATE_DEVTO: ${{ secrets.PRIVATE_DEVTO }} + PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }} + DEVTO_API_KEY: ${{ secrets.PRIVATE_DEVTO }} + HASHNODE_ACCESS_TOKEN: ${{ secrets.PRIVATE_HASHNODE }} diff --git a/scripts/run-syndicate-post.mjs b/scripts/run-syndicate-post.mjs new file mode 100644 index 00000000..716d8ba6 --- /dev/null +++ b/scripts/run-syndicate-post.mjs @@ -0,0 +1,287 @@ +import { createClient } from "@sanity/client"; +import { createImageUrlBuilder } from "@sanity/image-url"; +import { portableTextToMarkdown } from "@portabletext/markdown"; + +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 devtoApiKey = process.env.DEVTO_API_KEY || process.env.PRIVATE_DEVTO; +const hashnodeToken = process.env.HASHNODE_ACCESS_TOKEN || process.env.PRIVATE_HASHNODE; +const docId = process.env.TARGET_DOCUMENT_ID || "05eda048-efff-4e0f-8b76-83a2f482fa13"; + +console.log("Syndication script starting with config:"); +console.log("- Document ID:", docId); +console.log("- Project ID:", projectId); +console.log("- Dataset:", dataset); +console.log("- Has Write Token:", Boolean(token)); +console.log("- Has Dev.to Key:", Boolean(devtoApiKey)); +console.log("- Has Hashnode Key:", Boolean(hashnodeToken)); + +if (!token) { + console.error("Missing SANITY_API_WRITE_TOKEN or SANITY_AUTH_TOKEN."); + process.exit(1); +} + +const client = createClient({ + projectId, + dataset, + token, + apiVersion: "2025-09-30", + useCdn: false, +}); + +async function run() { + const doc = await client.getDocument(docId); + if (!doc) { + console.error(`Document not found: ${docId}`); + process.exit(1); + } + + console.log(`Loaded document: "${doc.title}" (type: ${doc._type})`); + const slug = typeof doc.slug === "object" ? doc.slug?.current : (doc.slug || docId); + const canonicalUrl = `https://codingcat.dev/${doc._type}/${slug}`; + + const imgBuilder = createImageUrlBuilder({ projectId, dataset }); + const urlForImage = (source) => { + if (!source || !source.asset) return undefined; + return imgBuilder.image(source).auto("format").fit("max"); + }; + + const serializeContent = (content) => { + if (!content || !Array.isArray(content) || content.length === 0) { + return ""; + } + try { + return portableTextToMarkdown(content, { + types: { + code: ({ value }) => "```" + (value?.language || "") + "\n" + (value?.code || "") + "\n```", + image: ({ value }) => { + const url = urlForImage(value)?.url(); + return url ? `![](${url})` : ""; + }, + 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)}`, + }, + }); + } catch (err) { + console.warn("Error serializing content:", err); + return doc.excerpt || ""; + } + }; + + const contentMarkdown = serializeContent(doc.content); + console.log(`Content serialized to Markdown: ${contentMarkdown.length} characters`); + + // --- 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 mainImageUrl = urlForImage(doc.coverImage)?.width(1000).height(420).url() || ""; + const youtubeEmbed = doc.youtube ? `{% youtube ${doc.youtube.replace("live", "embed")} %}\n\n` : ""; + + const devtoArticlePayload = { + article: { + title: doc.title || "Untitled", + published: true, + tags: devtoTags, + main_image: mainImageUrl, + canonical_url: canonicalUrl, + description: doc.excerpt || "", + organization_id: "1009", + body_markdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, + }, + }; + + if (doc._type === "podcast") { + devtoArticlePayload.article.series = `codingcatdev_podcast_${doc.season || 4}`; + } + + try { + // First attempt with organization_id: 1009 + console.log("Sending POST to Dev.to API with organization_id: 1009..."); + let res = await fetch("https://dev.to/api/articles", { + method: "POST", + headers: { + "api-key": devtoApiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(devtoArticlePayload), + }); + + let resText = await res.text(); + console.log("Dev.to initial response status:", res.status); + console.log("Dev.to initial response body:", resText); + + // If org 1009 fails (e.g. 401 or 422 unauthorized org), retry without organization_id + if (!res.ok && resText.toLowerCase().includes("organization")) { + console.warn("Dev.to rejected organization_id 1009. Retrying under personal account without organization_id..."); + delete devtoArticlePayload.article.organization_id; + res = await fetch("https://dev.to/api/articles", { + method: "POST", + headers: { + "api-key": devtoApiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(devtoArticlePayload), + }); + resText = await res.text(); + console.log("Dev.to retry response status:", res.status); + console.log("Dev.to retry response body:", resText); + } + + if (res.ok) { + const resJson = JSON.parse(resText); + console.log(`✓ Dev.to published: ${resJson.url} (ID: ${resJson.id})`); + + await client.createOrReplace({ + _id: syndicationDocId, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: "devto", + status: "published", + canonicalUrl, + externalUrl: resJson.url, + externalId: String(resJson.id ?? ""), + syndicatedAt: new Date().toISOString(), + }); + + await client.patch(doc._id).set({ devto: resJson.url }).commit(); + console.log(`✓ Updated Sanity doc ${doc._id} devto URL`); + } else { + console.error("✗ Dev.to publication failed:", resText); + } + } catch (err) { + console.error("✗ Dev.to request error:", err); + } + } else { + console.log("Dev.to key missing, skipping Dev.to"); + } + + // --- HASHNODE SYNDICATION --- + if (hashnodeToken) { + console.log("\n--- Syndicating to Hashnode ---"); + const syndicationDocId = `syndication.${doc._id}.hashnode`; + const publicationId = process.env.HASHNODE_PUBLICATION_ID || "60242f8180da6c44eadf775b"; + + const subtitle = doc.excerpt && doc.excerpt.length > 250 + ? doc.excerpt.substring(0, 247) + "..." + : (doc.excerpt || ""); + + const hashnodeTags = [ + { id: "56744721958ef13879b94cad", name: "JavaScript", slug: "javascript" }, + { id: "56744722958ef13879b94f1b", name: "Web Development", slug: "web-development" }, + { id: "56744723958ef13879b955a9", name: "Beginner Developers", slug: "beginners" }, + ]; + + const hashnodeCoverImageUrl = urlForImage(doc.coverImage)?.width(1600).height(840).url() || ""; + const youtubeEmbed = doc.youtube ? `%[${doc.youtube.replace("live", "embed")}]\n\n` : ""; + + const articleInput = { + title: doc.title || "Untitled", + subtitle, + publicationId, + slug: `${doc._type}-${slug}`, + tags: hashnodeTags, + coverImageOptions: { + coverImageURL: hashnodeCoverImageUrl, + }, + originalArticleURL: canonicalUrl, + contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, + }; + + try { + const publishRes = await fetch("https://gql-beta.hashnode.com", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: hashnodeToken, + }, + body: JSON.stringify({ + operationName: "publishPost", + query: `mutation publishPost($input: PublishPostInput!) { + publishPost(input: $input) { + post { + id + title + slug + url + } + } + }`, + variables: { input: articleInput }, + }), + }); + + const publishData = await publishRes.json(); + console.log("Hashnode response:", JSON.stringify(publishData, null, 2)); + + const publishedPost = publishData?.data?.publishPost?.post; + if (publishedPost) { + const externalUrl = publishedPost.url || `https://hashnode.codingcat.dev/${publishedPost.slug}`; + console.log(`✓ Hashnode published: ${externalUrl}`); + + await client.createOrReplace({ + _id: syndicationDocId, + _type: "syndication", + target: { _type: "reference", _ref: doc._id }, + platform: "hashnode", + status: "published", + canonicalUrl, + externalUrl, + externalId: publishedPost.slug || publishedPost.id, + syndicatedAt: new Date().toISOString(), + }); + + const postSlug = publishedPost.slug || publishedPost.id; + await client.patch(doc._id).set({ hashnode: postSlug }).commit(); + console.log(`✓ Updated Sanity doc ${doc._id} hashnode slug`); + } else { + console.error("✗ Hashnode publication failed:", publishData?.errors); + } + } catch (err) { + console.error("✗ Hashnode request error:", err); + } + } + + // --- LINK SYNDICATION REFERENCES ON PARENT --- + try { + const platforms = ["devto", "hashnode"]; + const expectedRefs = platforms.map((p) => `syndication.${doc._id}.${p}`); + const latestDoc = await client.getDocument(doc._id); + const existingRefs = (latestDoc?.syndications || []).map((s) => s._ref); + + const newRefs = expectedRefs.filter((ref) => !existingRefs.includes(ref)); + if (newRefs.length > 0) { + const referencesToAppend = newRefs.map((ref) => ({ + _key: `syn_${ref.replace(/[^a-zA-Z0-9]/g, "_")}`, + _type: "reference", + _ref: ref, + })); + + await client + .patch(doc._id) + .setIfMissing({ syndications: [] }) + .append("syndications", referencesToAppend) + .commit(); + + console.log(`✓ Linked ${newRefs.length} syndications to parent document ${doc._id}`); + } + } catch (err) { + console.warn("Could not append syndications array to parent:", err); + } + + console.log("\nSyndication run completed successfully!"); +} + +run().catch((err) => { + console.error("Run error:", err); + process.exit(1); +}); From 6b80878cefb5da094cdd026ea21eb9a03dbeb8fd Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 17:12:44 +0100 Subject: [PATCH 2/7] fix(syndication): update Hashnode tag shape to PublishPostTagInput (#710) --- apps/sanity/functions/syndicate-content/index.ts | 9 ++++----- scripts/run-syndicate-post.mjs | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/sanity/functions/syndicate-content/index.ts b/apps/sanity/functions/syndicate-content/index.ts index 6d80f440..a7d13884 100644 --- a/apps/sanity/functions/syndicate-content/index.ts +++ b/apps/sanity/functions/syndicate-content/index.ts @@ -309,15 +309,14 @@ export const handler = documentEventHandler( ? data.excerpt.substring(0, 247) + "..." : data.excerpt || ""; - const hashnodeTags: Array<{ id: string; name: string; slug: string }> = [ - { id: "56744721958ef13879b94cad", name: "JavaScript", slug: "javascript" }, - { id: "56744722958ef13879b94f1b", name: "Web Development", slug: "web-development" }, - { id: "56744723958ef13879b955a9", name: "Beginner Developers", slug: "beginners" }, + 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") { hashnodeTags.push({ - id: "56744722958ef13879b950d3", name: "podcast", slug: "podcast", }); diff --git a/scripts/run-syndicate-post.mjs b/scripts/run-syndicate-post.mjs index 716d8ba6..f08f8ce9 100644 --- a/scripts/run-syndicate-post.mjs +++ b/scripts/run-syndicate-post.mjs @@ -176,9 +176,9 @@ async function run() { : (doc.excerpt || ""); const hashnodeTags = [ - { id: "56744721958ef13879b94cad", name: "JavaScript", slug: "javascript" }, - { id: "56744722958ef13879b94f1b", name: "Web Development", slug: "web-development" }, - { id: "56744723958ef13879b955a9", name: "Beginner Developers", slug: "beginners" }, + { name: "JavaScript", slug: "javascript" }, + { name: "Web Development", slug: "web-development" }, + { name: "Beginner Developers", slug: "beginners" }, ]; const hashnodeCoverImageUrl = urlForImage(doc.coverImage)?.width(1600).height(840).url() || ""; From 9f3e1d9c0676a0029618dc7cfea8ed98685014da Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 17:17:41 +0100 Subject: [PATCH 3/7] fix(syndication): update Hashnode coverImage input to string URL (#713) --- apps/sanity/functions/syndicate-content/index.ts | 4 +--- scripts/run-syndicate-post.mjs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/sanity/functions/syndicate-content/index.ts b/apps/sanity/functions/syndicate-content/index.ts index a7d13884..0ed5001c 100644 --- a/apps/sanity/functions/syndicate-content/index.ts +++ b/apps/sanity/functions/syndicate-content/index.ts @@ -335,9 +335,7 @@ export const handler = documentEventHandler( publicationId, slug: `${data._type}-${slug}`, tags: hashnodeTags, - coverImageOptions: { - coverImageURL: hashnodeCoverImageUrl, - }, + ...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}), originalArticleURL: canonicalUrl, contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, }; diff --git a/scripts/run-syndicate-post.mjs b/scripts/run-syndicate-post.mjs index f08f8ce9..84f45514 100644 --- a/scripts/run-syndicate-post.mjs +++ b/scripts/run-syndicate-post.mjs @@ -190,9 +190,7 @@ async function run() { publicationId, slug: `${doc._type}-${slug}`, tags: hashnodeTags, - coverImageOptions: { - coverImageURL: hashnodeCoverImageUrl, - }, + ...(hashnodeCoverImageUrl ? { coverImage: hashnodeCoverImageUrl } : {}), originalArticleURL: canonicalUrl, contentMarkdown: `Original: ${canonicalUrl}\n\n${youtubeEmbed}${contentMarkdown}`, }; From 3c95e63f9749d34109001bc653b17052578bc477 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 18:02:11 +0100 Subject: [PATCH 4/7] feat(seo): add VideoObject structured data and Gemini video storyboard pipeline (#715) --- apps/site/src/lib/structured-data.ts | 61 ++++++++++++ apps/site/src/pages/podcast/[slug].astro | 16 ++++ apps/site/src/pages/post/[slug].astro | 17 +++- scripts/generate-video-storyboard.mjs | 114 +++++++++++++++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100755 scripts/generate-video-storyboard.mjs diff --git a/apps/site/src/lib/structured-data.ts b/apps/site/src/lib/structured-data.ts index c098266e..23c1bd41 100644 --- a/apps/site/src/lib/structured-data.ts +++ b/apps/site/src/lib/structured-data.ts @@ -36,6 +36,8 @@ export function organizationSchema(origin: string, logoUrl?: string): Node { "https://www.youtube.com/@CodingCatDev", "https://twitter.com/CodingCatDev", "https://github.com/codingcatdev", + "https://www.linkedin.com/company/codingcatdev", + "https://bsky.app/profile/codingcat.dev", ], }; } @@ -160,6 +162,65 @@ export function personSchema( }; } +export function videoObjectSchema( + origin: string, + video: { + title?: string | null; + description?: string | null; + uploadDate?: string | null; + thumbnailUrl?: string; + youtubeUrl?: string | null; + durationSeconds?: number | null; + }, + path: string, +): Node | null { + if (!video.youtubeUrl) return null; + + const url = absoluteUrl(path, origin); + // Format ISO 8601 duration: PT#M#S or PT#S + let durationIso: string | undefined; + if (video.durationSeconds && video.durationSeconds > 0) { + const hours = Math.floor(video.durationSeconds / 3600); + const minutes = Math.floor((video.durationSeconds % 3600) / 60); + const seconds = Math.floor(video.durationSeconds % 60); + let d = "PT"; + if (hours > 0) d += `${hours}H`; + if (minutes > 0) d += `${minutes}M`; + if (seconds > 0 || d === "PT") d += `${seconds}S`; + durationIso = d; + } + + return { + "@type": "VideoObject", + "@id": `${url}#video`, + name: video.title ?? undefined, + description: video.description ?? undefined, + ...(video.thumbnailUrl ? { thumbnailUrl: [video.thumbnailUrl] } : {}), + ...(video.uploadDate ? { uploadDate: video.uploadDate } : {}), + ...(durationIso ? { duration: durationIso } : {}), + contentUrl: video.youtubeUrl, + embedUrl: video.youtubeUrl.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/"), + }; +} + +export function faqSchema( + faqs: Array<{ question: string; answer: string }>, +): Node | null { + if (!faqs || faqs.length === 0) return null; + + return { + "@type": "FAQPage", + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), + }; +} + interface PodcastEpisodeInput { title?: string | null; excerpt?: string | null; diff --git a/apps/site/src/pages/podcast/[slug].astro b/apps/site/src/pages/podcast/[slug].astro index f1ce7f82..f29158ae 100644 --- a/apps/site/src/pages/podcast/[slug].astro +++ b/apps/site/src/pages/podcast/[slug].astro @@ -18,6 +18,7 @@ import { articleSchema, breadcrumbSchema, podcastEpisodeSchema, + videoObjectSchema, } from "@/lib/structured-data"; const { sanity } = Astro.locals; @@ -40,6 +41,20 @@ const ogImage = sanity.resolveOpenGraphImage(podcast.coverImage ?? undefined); const authors = podcast.author ?? []; const guests = podcast.guest ?? []; const audioUrl = podcast.spotify?.enclosures?.at(0)?.url; +const videoSchema = podcast.youtube + ? videoObjectSchema( + origin, + { + title: podcast.title, + description: podcast.excerpt, + uploadDate: podcast.date, + thumbnailUrl: ogImage?.url, + youtubeUrl: podcast.youtube, + durationSeconds: podcast.transcript?.durationSeconds, + }, + path, + ) + : null; --- diff --git a/scripts/generate-video-storyboard.mjs b/scripts/generate-video-storyboard.mjs new file mode 100755 index 00000000..98efa2e7 --- /dev/null +++ b/scripts/generate-video-storyboard.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * CodingCat.dev AI Video Storyboard & Script Generator + * + * Inspired by: + * - Better Stack: High-touch, high-pain developer problem solving (costs, downtime, benchmarks). + * - Cleo Abram: Kinetic visual metaphors, 3-beat narrative arc, fast-paced animations, 10s curiosity hook. + * + * Powered by Google Gemini 1.5 Pro (via Google AI / OpenRouter). + */ + +const topic = process.argv.slice(2).join(" "); + +if (!topic) { + console.log(` +Usage: node scripts/generate-video-storyboard.mjs "" + +Example: + node scripts/generate-video-storyboard.mjs "Dockerless Serverless GPUs: Cutting AI inference cost by 90% with RunPod Flash" +`); + process.exit(1); +} + +const openRouterKey = process.env.OPENROUTER_API_KEY; + +if (!openRouterKey) { + console.error("Error: OPENROUTER_API_KEY environment variable is required."); + process.exit(1); +} + +const SYSTEM_PROMPT = `You are the Lead Creative Director & Tech Scriptwriter for CodingCat.dev, producing top-tier developer educational videos. + +Your mission is to blend two elite creator playbooks: +1. THE BETTER STACK CONTENT STRATEGY: + - Target genuine developer pain, extreme cost optimization, performance benchmarks, and production post-mortems. + - Ground everything in real metrics, architecture trade-offs, and actionable code. +2. THE CLEO ABRAM (Huge If True) VISUAL STYLE: + - THE 10-SECOND HOOK: Start with a surprising contradiction, visual mystery, or bold curiosity question. Never start with "Hey guys, welcome back". + - 3-BEAT NARRATIVE ARC: + * Beat 1: The Frustrating Status Quo (Why the standard way is broken/expensive/slow). + * Beat 2: The Secret Mechanism (Under the hood breakdown using visual metaphors). + * Beat 3: The Proof & Benchmark ("Huge If True" - The live test, savings, or live demo). + - DENSE VISUAL CHOREOGRAPHY: Never stay on talking head or a static screen for more than 4-5 seconds. Alternate with: + * Kinetic isometric server/network diagrams + * Code punch-ins (highlighting 2-3 key lines, not full files) + * Side-by-side racing clocks or dollar meters + * Tactile UI sound effects (foley pops, whooshes, mechanical keyboard clicks) + +OUTPUT FORMAT: +Generate a complete, ready-to-produce production document in Markdown with the following sections: + +# [Video Title: 3 High-CTR Options] + +## Video Metadata +- **Target Audience:** +- **Core Frustration Solved:** +- **The "North Star" Metric:** (e.g. "$0.03 -> $0.0003 per image", "150ms -> 12ms TTFB") +- **YouTube Description & Key Moments:** + +## Act 1: The 10-Second Hook (0:00 - 0:30) +Format as a 2-column table: +| Timestamp | Visual Director & Animation Notes (Cleo Style) | Spoken Audio / Voiceover | Sound Design (SFX) | + +## Act 2: The Hidden Mechanism & Architecture (0:30 - 3:30) +Format as a 2-column table with precise animation prompts (e.g., Motion Canvas / Remotion code prompts, isometric SVG descriptions). + +## Act 3: Live Benchmark & The Code (3:30 - 6:00) +Format as a 2-column table showing code highlights and race/cost comparison. + +## Act 4: The Takeaway & Call to Action (6:00 - 7:00) +Direct punchy conclusion pointing to the canonical CodingCat.dev article and GitHub repo. + +## Reusable Animation Prompts (For Motion Canvas / Remotion / After Effects) +Provide 2-3 exact TypeScript or motion graphic prompt specifications that can be fed into Motion Canvas or Remotion to generate the key graphics. +`; + +async function run() { + console.log(`\n🚀 Generating Cleo-style / Better Stack Storyboard for: "${topic}"\nUsing Gemini 1.5 Pro...\n`); + + try { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${openRouterKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://codingcat.dev", + "X-Title": "CodingCat Video Storyboard Generator", + }, + body: JSON.stringify({ + model: "google/gemini-pro-1.5", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: `Create a complete video storyboard and script for: ${topic}` }, + ], + temperature: 0.7, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenRouter API error (${response.status}): ${err}`); + } + + const data = await response.json(); + const result = data.choices?.[0]?.message?.content; + + console.log(result); + } catch (err) { + console.error("Generation failed:", err.message); + process.exit(1); + } +} + +run(); From bf33f14ec5f13fd742b7e3c98fdeba9a8a5a6d1c Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 18:03:25 +0100 Subject: [PATCH 5/7] fix(video): upgrade to Gemini 2.5 Pro with native Google AI and OpenRouter support (#717) --- scripts/generate-video-storyboard.mjs | 79 ++++++++++++++++++--------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/scripts/generate-video-storyboard.mjs b/scripts/generate-video-storyboard.mjs index 98efa2e7..a7f8723d 100755 --- a/scripts/generate-video-storyboard.mjs +++ b/scripts/generate-video-storyboard.mjs @@ -21,10 +21,11 @@ Example: process.exit(1); } +const geminiApiKey = process.env.GEMINI_API_KEY; const openRouterKey = process.env.OPENROUTER_API_KEY; -if (!openRouterKey) { - console.error("Error: OPENROUTER_API_KEY environment variable is required."); +if (!geminiApiKey && !openRouterKey) { + console.error("Error: Either GEMINI_API_KEY or OPENROUTER_API_KEY environment variable is required."); process.exit(1); } @@ -75,35 +76,59 @@ Provide 2-3 exact TypeScript or motion graphic prompt specifications that can be `; async function run() { - console.log(`\n🚀 Generating Cleo-style / Better Stack Storyboard for: "${topic}"\nUsing Gemini 1.5 Pro...\n`); + console.log(`\n🚀 Generating Cleo-style / Better Stack Storyboard for: "${topic}"\nUsing Gemini 2.5 Pro (Google AI)...\n`); try { - const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { - method: "POST", - headers: { - "Authorization": `Bearer ${openRouterKey}`, - "Content-Type": "application/json", - "HTTP-Referer": "https://codingcat.dev", - "X-Title": "CodingCat Video Storyboard Generator", - }, - body: JSON.stringify({ - model: "google/gemini-pro-1.5", - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: `Create a complete video storyboard and script for: ${topic}` }, - ], - temperature: 0.7, - }), - }); - - if (!response.ok) { - const err = await response.text(); - throw new Error(`OpenRouter API error (${response.status}): ${err}`); + let result = ""; + + if (geminiApiKey) { + // Use official Google Gemini 2.5 Pro via Google AI + const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=${geminiApiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ parts: [{ text: `Create a complete video storyboard and script for: ${topic}` }] }], + generationConfig: { temperature: 0.7 } + }) + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Google Gemini API error (${res.status}): ${err}`); + } + + const data = await res.json(); + result = data.candidates?.[0]?.content?.parts?.[0]?.text; + } else { + // Use OpenRouter with gemini-2.5-pro + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${openRouterKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://codingcat.dev", + "X-Title": "CodingCat Video Storyboard Generator", + }, + body: JSON.stringify({ + model: "google/gemini-2.5-pro", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: `Create a complete video storyboard and script for: ${topic}` }, + ], + temperature: 0.7, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenRouter API error (${response.status}): ${err}`); + } + + const data = await response.json(); + result = data.choices?.[0]?.message?.content; } - const data = await response.json(); - const result = data.choices?.[0]?.message?.content; - console.log(result); } catch (err) { console.error("Generation failed:", err.message); From 2b95c86c45cca288f2fd6cdfa2a1299a4532ca63 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 18:22:16 +0100 Subject: [PATCH 6/7] feat: complete Shorts story (/shorts shelf + kinetic captions + sync), Gemini 2.5/TTS upgrades, and TechArticle/Auto-FAQ AEO pack (#720) --- .github/workflows/sync-shorts.yml | 42 ++++ apps/sanity/extract.json | 128 ++++++++--- .../functions/sync-youtube-analytics/index.ts | 60 ++++- apps/sanity/schemas/documents/short.ts | 38 ++++ .../sanity/schemas/singletons/engineConfig.ts | 4 +- .../schemas/singletons/pipelineConfig.ts | 4 +- apps/site/src/components/Header.astro | 8 +- apps/site/src/components/ShortsShelf.astro | 214 ++++++++++++++++++ apps/site/src/lib/sanity/queries/index.ts | 1 + apps/site/src/lib/sanity/queries/pages.ts | 28 +++ apps/site/src/lib/sanity/queries/shorts.ts | 86 +++++++ apps/site/src/lib/structured-data.ts | 63 +++++- apps/site/src/pages/index.astro | 11 +- apps/site/src/pages/podcast/[slug].astro | 4 + apps/site/src/pages/post/[slug].astro | 10 +- apps/site/src/pages/shorts/index.astro | 56 +++++ apps/site/src/pages/sitemap.xml.ts | 6 + .../dashboard/config/config-form.tsx | 4 +- apps/web/lib/gemini.ts | 2 +- apps/web/lib/services/elevenlabs.ts | 167 ++++++++++++-- apps/web/lib/sponsor/gemini-intent.ts | 2 +- apps/web/lib/sponsor/gemini-outreach.ts | 2 +- .../remotion/components/InfographicScene.tsx | 11 + .../remotion/components/KineticCaptions.tsx | 157 +++++++++++++ apps/web/remotion/components/Scene.tsx | 15 +- apps/web/remotion/components/SceneRouter.tsx | 3 +- apps/web/remotion/types.ts | 1 + package.json | 5 +- scripts/generate-aeo-distribution-pack.mjs | 181 +++++++++++++++ scripts/generate-video-storyboard.mjs | 1 + scripts/sync-shorts-from-automated-videos.mjs | 130 +++++++++++ 31 files changed, 1378 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/sync-shorts.yml create mode 100644 apps/site/src/components/ShortsShelf.astro create mode 100644 apps/site/src/lib/sanity/queries/shorts.ts create mode 100644 apps/site/src/pages/shorts/index.astro create mode 100644 apps/web/remotion/components/KineticCaptions.tsx create mode 100644 scripts/generate-aeo-distribution-pack.mjs create mode 100644 scripts/sync-shorts-from-automated-videos.mjs diff --git a/.github/workflows/sync-shorts.yml b/.github/workflows/sync-shorts.yml new file mode 100644 index 00000000..4b958176 --- /dev/null +++ b/.github/workflows/sync-shorts.yml @@ -0,0 +1,42 @@ +name: Sync YouTube Shorts to Sanity + +on: + workflow_dispatch: + inputs: + dataset: + description: "Target Sanity Dataset" + required: true + default: "production" + type: choice + options: + - production + - dev + schedule: + - cron: "30 6 * * *" + +jobs: + sync-shorts: + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Sync Shorts from Automated Videos + run: node scripts/sync-shorts-from-automated-videos.mjs + env: + SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} + SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }} + SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }} + YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} diff --git a/apps/sanity/extract.json b/apps/sanity/extract.json index d3376aa8..b18ae439 100644 --- a/apps/sanity/extract.json +++ b/apps/sanity/extract.json @@ -702,6 +702,36 @@ } } }, + { + "type": "type", + "name": "automatedVideo.reference", + "value": { + "type": "object", + "attributes": { + "_ref": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "reference" + } + }, + "_weak": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + } + }, + "dereferencesTo": "automatedVideo" + } + }, { "type": "type", "name": "category.reference", @@ -782,6 +812,13 @@ }, "optional": true }, + "description": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "youtube": { "type": "objectAttribute", "value": { @@ -789,6 +826,13 @@ }, "optional": true }, + "shortVideoUrl": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "thumbnail": { "type": "objectAttribute", "value": { @@ -844,6 +888,14 @@ }, "optional": true }, + "sourceAutomatedVideo": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "automatedVideo.reference" + }, + "optional": true + }, "publishedAt": { "type": "objectAttribute", "value": { @@ -858,6 +910,52 @@ }, "optional": true }, + "statistics": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "youtube": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "viewCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "likeCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "commentCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "favoriteCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + } + } + }, + "optional": true + } + } + }, + "optional": true + }, "categories": { "type": "objectAttribute", "value": { @@ -1183,36 +1281,6 @@ } } }, - { - "type": "type", - "name": "automatedVideo.reference", - "value": { - "type": "object", - "attributes": { - "_ref": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "reference" - } - }, - "_weak": { - "type": "objectAttribute", - "value": { - "type": "boolean" - }, - "optional": true - } - }, - "dereferencesTo": "automatedVideo" - } - }, { "name": "videoAnalytics", "type": "document", diff --git a/apps/sanity/functions/sync-youtube-analytics/index.ts b/apps/sanity/functions/sync-youtube-analytics/index.ts index 04d73730..897580c7 100644 --- a/apps/sanity/functions/sync-youtube-analytics/index.ts +++ b/apps/sanity/functions/sync-youtube-analytics/index.ts @@ -70,13 +70,69 @@ export const handler = scheduledEventHandler(async ({ context }) => { useCdn: false, }); - // Query all posts and podcasts with YouTube URLs + // Step 0: Sync any published automatedVideo Shorts into standalone `_type == "short"` documents + const autoShortDocs = await client.fetch< + Array<{ + _id: string; + _createdAt: string; + title: string; + slug?: { current?: string }; + youtubeShortId: string; + shortUrl?: string; + hook?: string; + }> + >( + `*[_type == "automatedVideo" && defined(youtubeShortId) && !(_id in path("drafts.**"))]{ + _id, + _createdAt, + title, + slug, + youtubeShortId, + shortUrl, + "hook": script.hook + }`, + ); + + for (const autoShort of autoShortDocs) { + const shortId = `short-${autoShort.youtubeShortId}`; + const slugCurrent = + autoShort.slug?.current || + autoShort.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 90); + await client + .createIfNotExists({ + _id: shortId, + _type: "short", + title: autoShort.title, + slug: { _type: "slug", current: slugCurrent }, + description: autoShort.hook || undefined, + youtube: `https://www.youtube.com/shorts/${autoShort.youtubeShortId}`, + shortVideoUrl: autoShort.shortUrl || undefined, + sourceAutomatedVideo: { + _type: "reference", + _ref: autoShort._id, + }, + publishedAt: autoShort._createdAt, + duration: 60, + }) + .catch((err) => { + console.warn( + `[Sync YouTube Analytics] Could not createIfNotExists short ${shortId}:`, + err, + ); + }); + } + + // Query all posts, podcasts, and shorts with YouTube URLs console.log( `[Sync YouTube Analytics] Fetching documents with YouTube URLs in project ${projectId}, dataset ${dataset}...`, ); const docs = await client.fetch( - `*[_type in ["post", "podcast"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{ + `*[_type in ["post", "podcast", "short"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{ _id, _type, title, diff --git a/apps/sanity/schemas/documents/short.ts b/apps/sanity/schemas/documents/short.ts index e7a29484..ee25af8b 100644 --- a/apps/sanity/schemas/documents/short.ts +++ b/apps/sanity/schemas/documents/short.ts @@ -27,12 +27,25 @@ export default defineType({ }, validation: (rule) => rule.required(), }), + defineField({ + name: "description", + title: "Description / Hook", + type: "text", + rows: 3, + description: "Short hook or summary for SEO/AEO and card previews", + }), defineField({ name: "youtube", title: "YouTube", type: "string", description: "YouTube Shorts video ID or URL", }), + defineField({ + name: "shortVideoUrl", + title: "Direct MP4 Video URL", + type: "url", + description: "Direct Sanity CDN or R2 MP4 URL for native vertical video playback", + }), defineField({ name: "thumbnail", title: "Thumbnail", @@ -49,6 +62,13 @@ export default defineType({ to: [{ type: "podcast" }], description: "Long-form episode this Short was clipped from", }), + defineField({ + name: "sourceAutomatedVideo", + title: "Source Automated Video", + type: "reference", + to: [{ type: "automatedVideo" }], + description: "Automated video pipeline run that produced this Short", + }), defineField({ name: "publishedAt", title: "Published At", @@ -60,6 +80,24 @@ export default defineType({ type: "number", description: "Duration in seconds", }), + defineField({ + name: "statistics", + title: "Statistics", + type: "object", + fields: [ + defineField({ + name: "youtube", + title: "YouTube Statistics", + type: "object", + fields: [ + defineField({ name: "viewCount", title: "Views", type: "number" }), + defineField({ name: "likeCount", title: "Likes", type: "number" }), + defineField({ name: "commentCount", title: "Comments", type: "number" }), + defineField({ name: "favoriteCount", title: "Favorites", type: "number" }), + ], + }), + ], + }), defineField({ name: "categories", title: "Categories", diff --git a/apps/sanity/schemas/singletons/engineConfig.ts b/apps/sanity/schemas/singletons/engineConfig.ts index 9c570a9f..ad1f2c9a 100644 --- a/apps/sanity/schemas/singletons/engineConfig.ts +++ b/apps/sanity/schemas/singletons/engineConfig.ts @@ -327,8 +327,8 @@ export default defineType({ type: 'string', fieldset: 'aiGeneration', description: - 'The Google Gemini model used for script generation and content analysis', - initialValue: 'gemini-2.0-flash', + 'The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)', + initialValue: 'gemini-2.5-flash', }), defineField({ name: 'infographicModel', diff --git a/apps/sanity/schemas/singletons/pipelineConfig.ts b/apps/sanity/schemas/singletons/pipelineConfig.ts index 2d0ab77f..cea163ae 100644 --- a/apps/sanity/schemas/singletons/pipelineConfig.ts +++ b/apps/sanity/schemas/singletons/pipelineConfig.ts @@ -10,8 +10,8 @@ export default defineType({ name: "geminiModel", title: "Gemini Model", type: "string", - description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.0-flash, gemini-2.5-pro)", - initialValue: "gemini-2.0-flash", + description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)", + initialValue: "gemini-2.5-flash", }), defineField({ name: "elevenLabsVoiceId", diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro index 008031a3..7adc0738 100644 --- a/apps/site/src/components/Header.astro +++ b/apps/site/src/components/Header.astro @@ -5,7 +5,13 @@ import ThemeToggle from "./ThemeToggle.astro"; import SearchModal from "./SearchModal.astro"; const settings = await Astro.locals.sanity.loadQuery(settingsQuery); -const navLinks = settings?.navLinks ?? []; +const rawNavLinks = settings?.navLinks ?? []; +const navLinks = rawNavLinks.some((link) => link?.path === "/shorts") + ? rawNavLinks + : [ + ...rawNavLinks, + { _key: "shorts-nav", path: "/shorts", title: "Shorts", sideOnly: false }, + ]; // The Next header split these across a desktop