Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .agents/skills/sanity-editorial-workflow/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
name: sanity-editorial-workflow
description: End-to-end editorial workflow for CodingCat.dev using Sanity MCP, Sanity CLI, Gemini writing, cover photo generation/upload, and Sanity Content Releases readiness checks. Use when drafting blog posts or podcast show notes, creating or uploading cover images, checking draft/release status, or preparing Sanity Content Releases.
---

# Sanity Editorial Workflow (MCP, CLI, Gemini Writing, Photos & Content Releases)

Use this skill whenever the user asks to:
- Check the status of drafts, upcoming episodes/posts, or **Sanity Content Releases**
- Write or update blog posts (`post`) or podcast show notes (`podcast`) using Gemini (from a topic or a YouTube `transcript`)
- Generate or upload cover images (`coverImage`) to Sanity and attach them to documents
- Stage or audit documents in a Sanity Content Release before publishing

Do **not** create custom wrapper scripts when you can execute operations directly via **Sanity MCP** (`codingcat-dev` / Sanity Context MCP) and **Sanity CLI** (`pnpm --filter @codingcatdev/sanity exec sanity ...`).

---

## 1. Checking Content Releases & Publishing Readiness

Sanity stores documents in three ID namespaces depending on their lifecycle state:
- **Published**: `<docId>`
- **Drafts**: `drafts.<docId>`
- **Content Releases**: `versions.<releaseId>.<docId>` (paired with a release metadata document of `_type == "system.release"`)

### A. Inspect Active Content Releases & Versioned Documents
Run via Sanity CLI (`--with-user-token` reads `drafts.**` and `versions.**` using the logged-in Sanity CLI session):

```bash
pnpm --filter @codingcatdev/sanity exec sanity documents query \
'{
"releases": *[_type == "system.release"] | order(_updatedAt desc) {
_id,
name,
state,
publishAt,
"metadata": metadata { title, description, releaseType }
},
"releaseDocs": *[_id in path("versions.**") && _type in ["post", "podcast"]] | order(_updatedAt desc) {
_id,
_type,
title,
"slug": slug.current,
date,
"hasCoverImage": defined(coverImage.asset._ref),
"hasExcerpt": defined(excerpt) && length(excerpt) > 10,
"blockCount": count(content),
"hasYoutube": defined(youtube),
"hasSpotify": defined(spotify),
"hasTranscript": defined(transcript)
},
"drafts": *[_id in path("drafts.**") && _type in ["post", "podcast"]] | order(_updatedAt desc) {
_id,
_type,
title,
"slug": slug.current,
date,
"hasCoverImage": defined(coverImage.asset._ref),
"hasExcerpt": defined(excerpt) && length(excerpt) > 10,
"blockCount": count(content),
"hasYoutube": defined(youtube),
"hasSpotify": defined(spotify),
"hasTranscript": defined(transcript)
}
}' --dataset production --api-version v2025-09-30
```

### B. Publishing Readiness Checklist
Before a `post` or `podcast` is ready to publish (or ready inside a Content Release), verify:
1. **Shared Required Fields (`post` & `podcast`)**:
- `title` (non-empty string)
- `slug.current` (lowercase hyphenated slug)
- `date` (ISO 8601 datetime)
- `coverImage.asset._ref` (16:9 image uploaded to Sanity)
- `excerpt` (1–2 sentence summary, <= 160 chars recommended)
- `content` (non-empty Portable Text array)
- `author` (defaults to Alex Patterson `_ref: "RIgC5r4TJ0hCGOzxHszD1w"`)
2. **Podcast-Specific Fields (`podcast`)**:
- `season` (integer) & `episode` (integer)
- `recordingDate` (ISO 8601 datetime)
- `guest` (array of references to `guest` documents)
- `youtube` (YouTube URL — triggers `sync-youtube-transcript` on publish)
- `spotify` & `listenLinks` (auto-synced by `sync-podcast-links` once published on Anchor/Spotify RSS)

---

## 2. Writing Posts & Podcast Show Notes via Gemini

### Step 1: Retrieve Source Context (Transcript or Existing Draft)
- Use the `codingcat-dev` MCP tool `get_content_by_slug` or `query_content`, or run a Sanity CLI query to fetch the document and its `transcript->fullText`, `transcript->chapters`, and `guest[]->`:
```bash
pnpm --filter @codingcatdev/sanity exec sanity documents query \
'*[_type in ["post", "podcast"] && (slug.current == $slug || _id == $id)][0]{
_id, _type, title, "slug": slug.current, excerpt, youtube,
"transcript": transcript->{ fullText, summary, chapters },
"guests": guest[]->{ title, excerpt, socials, websites }
}' --dataset production
```

### Step 2: Draft Content & Convert Markdown to Portable Text
- Write the article or episode show notes in clean Markdown:
- Use `##` (`h2`) and `###` (`h3`) headings to structure sections.
- Include bulleted takeaways, links mentioned, and fenced code blocks where relevant.
- Do **not** repeat the document `title` as an `# h1` at the top of `content`.
- Write a crisp 1–2 sentence `excerpt` that complements the title without repeating it.
- Convert the Markdown to Sanity Portable Text blocks using `@portabletext/markdown` (`markdownToPortableText`) — see `.agents/skills/portable-text-conversion/SKILL.md`.

### Step 3: Save to Draft (`drafts.<id>`) or Content Release (`versions.<releaseId>.<id>`)
- Target `drafts.<docId>` (or `versions.<releaseId>.<docId>` if staging for a Content Release) so changes can be reviewed in Sanity Studio before going live:
```bash
pnpm --filter @codingcatdev/sanity exec sanity documents create <json-file> --replace --dataset production
```

---

## 3. Creating & Uploading Photos (`coverImage`)

### Step 1: Generate a 16:9 Cover Image (When Needed)
- Invoke the `image-generator` subagent with a clear 16:9 visual brief tailored to the post/podcast topic and CodingCat.dev's modern developer aesthetic (avoiding cluttered text overlays if the title is already rendered on cards).

### Step 2: Upload the Image Asset & Attach `coverImage`
- Upload the local image file to Sanity's Asset API and patch the target draft/release document using `@sanity/client` via `pnpm --filter @codingcatdev/sanity exec sanity exec <script> --with-user-token`:
```ts
import { createReadStream } from "node:fs";
import { basename } from "node:path";
import { getCliClient } from "sanity/cli";

const client = getCliClient({ apiVersion: "2025-09-30" });
const asset = await client.assets.upload("image", createReadStream(filePath), {
filename: basename(filePath),
});

await client
.patch(targetDocumentId) // e.g. "drafts.<id>" or "versions.<releaseId>.<id>"
.set({
coverImage: {
_type: "image",
asset: {
_type: "reference",
_ref: asset._id,
},
},
})
.commit();
```
24 changes: 24 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Export Sanity production dataset backup before deploy
run: |
curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz

- name: Upload Sanity production dataset backup artifact
uses: actions/upload-artifact@v4
with:
name: sanity-production-predeploy-backup-dev-${{ github.sha }}
path: /tmp/sanity-production-backup.ndjson.gz
retention-days: 30

- name: Deploy Sanity Studio
run: npx sanity deploy -y
working-directory: apps/sanity
Expand Down Expand Up @@ -90,6 +101,19 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Export Sanity production dataset backup before deploy
run: |
curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz
env:
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}

- name: Upload Sanity production dataset backup artifact
uses: actions/upload-artifact@v4
with:
name: sanity-production-predeploy-backup-${{ github.sha }}
path: /tmp/sanity-production-backup.ndjson.gz
retention-days: 30

- name: Deploy Sanity Studio
run: npx sanity deploy -y
working-directory: apps/sanity
Expand Down
157 changes: 128 additions & 29 deletions .github/workflows/nightly-dataset-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,133 @@ jobs:
environment: production

steps:
- name: Reset dev dataset from production
- 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 production dataset to dev
env:
SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }}
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }}
run: |
PROJECT_ID="${{ vars.SANITY_STUDIO_PROJECT_ID }}"
[ -z "$PROJECT_ID" ] && PROJECT_ID="hfh83o0w"
TOKEN="${{ secrets.SANITY_AUTH_TOKEN }}"
PROJECT_ID="${SANITY_STUDIO_PROJECT_ID:-hfh83o0w}"
API_HOST="https://api.sanity.io"
API_VERSION="v2025-09-30"

echo "Deleting existing 'dev' dataset..."
curl -s -X DELETE "$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/dev" \
-H "Authorization: Bearer $TOKEN" || true

echo "Cloning 'production' dataset to 'dev'..."
RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X PUT \
"$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/production/copy" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"targetDataset":"dev","skipHistory":true}')

HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2)
BODY=$(echo "$RESPONSE" | grep -v "HTTP_STATUS:")

echo "Status: $HTTP_STATUS"
echo "Response: $BODY"

if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then
echo "Dataset sync initiated successfully!"
else
echo "Dataset sync failed with status $HTTP_STATUS"
exit 1
fi
API_VERSION="v2021-06-07"

echo "1. Ensuring 'dev' dataset exists (public ACL)..."
for T in "$SANITY_AUTH_TOKEN" "$SANITY_API_WRITE_TOKEN"; do
[ -z "$T" ] && continue
RESP=$(curl -s -X PUT "$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/dev" \
-H "Authorization: Bearer $T" \
-H "Content-Type: application/json" \
-d '{"aclMode":"public"}' || true)
echo "Dataset create response: $RESP"
if echo "$RESP" | grep -q '"datasetName":"dev"'; then
break
fi
done

echo "2. Exporting 'production' dataset to /tmp/production.ndjson..."
curl -f -sS "https://$PROJECT_ID.api.sanity.io/$API_VERSION/data/export/production" -o /tmp/production.ndjson
gzip -k -f /tmp/production.ndjson
wc -l /tmp/production.ndjson

echo "3. Importing documents into 'dev' dataset via Sanity Mutate API..."
node -e '
const fs = require("fs");
const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w";
const tokens = [process.env.SANITY_API_WRITE_TOKEN, process.env.SANITY_AUTH_TOKEN].filter(Boolean);
const lines = fs.readFileSync("/tmp/production.ndjson", "utf8").trim().split("\n").filter(Boolean);
const allDocs = lines.map((l) => JSON.parse(l));
// Exclude sanity.imageAsset and sanity.fileAsset if mutate API rejects system asset docs without binary upload,
// or include content docs and weaken references to assets/missing docs so all content imports cleanly.
const contentDocs = allDocs.filter((d) => d._type !== "sanity.imageAsset" && d._type !== "sanity.fileAsset");
const contentIds = new Set(contentDocs.map((d) => d._id));

function sanitizeDoc(obj) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
const copy = {};
for (const [k, v] of Object.entries(obj)) {
if (k === "_rev") continue;
copy[k] = sanitizeDoc(v);
}
if (typeof copy._ref === "string" && !contentIds.has(copy._ref)) {
copy._weak = true;
}
return copy;
}

const sanitized = contentDocs.map(sanitizeDoc);
console.log(`Prepared ${sanitized.length} content documents (from ${allDocs.length} total lines) for dataset "dev"...`);

async function mutateBatch(mutations) {
let lastErr = "";
for (const token of tokens) {
const res = await fetch(`https://${projectId}.api.sanity.io/v2021-06-07/data/mutate/dev?visibility=async&autoGenerateArrayKeys=true`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ mutations }),
});
if (res.ok) return;
lastErr = `HTTP ${res.status}: ${await res.text()}`;
}
throw new Error(`Mutate batch failed: ${lastErr}`);
}

(async () => {
// Pass 1: Create stubs so all internal document references resolve
const stubs = sanitized.map((d) => ({ createOrReplace: { _id: d._id, _type: d._type } }));
for (let i = 0; i < stubs.length; i += 250) {
await mutateBatch(stubs.slice(i, i + 250));
}
console.log("Pass 1 (document stubs) complete.");

// Pass 2: Populate full document bodies
for (let i = 0; i < sanitized.length; i += 100) {
const batch = sanitized.slice(i, i + 100).map((d) => ({ createOrReplace: d }));
await mutateBatch(batch);
if ((i / 100) % 5 === 0) {
console.log(`Imported ${Math.min(i + 100, sanitized.length)} / ${sanitized.length} documents...`);
}
}

// Pass 3 (best-effort): Import asset metadata documents
const assetDocs = allDocs
.filter((d) => d._type === "sanity.imageAsset" || d._type === "sanity.fileAsset")
.map(({ _rev, ...rest }) => rest);
try {
for (let i = 0; i < assetDocs.length; i += 250) {
const batch = assetDocs.slice(i, i + 250).map((d) => ({ createOrReplace: d }));
await mutateBatch(batch);
}
console.log(`Imported ${assetDocs.length} asset metadata documents.`);
} catch (assetErr) {
console.warn("Note: Asset metadata docs skipped (served via CDN):", assetErr.message);
}

console.log("Dataset sync completed successfully!");
})().catch((err) => {
console.error(err);
process.exit(1);
});
'

- name: Upload Sanity production dataset backup artifact
uses: actions/upload-artifact@v4
with:
name: sanity-production-backup-${{ github.run_id }}
path: /tmp/production.ndjson.gz
retention-days: 30
Loading
Loading