Skip to content

Commit b8db67d

Browse files
authored
Merge pull request #741 from CodingCatDev/refactor_sanity_schema_structure
refactor(sanity): clean up schemas, organize Studio structure, and optimize GROQ queries
2 parents 9d2f200 + bad84af commit b8db67d

55 files changed

Lines changed: 3859 additions & 11914 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
name: sanity-editorial-workflow
3+
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.
4+
---
5+
6+
# Sanity Editorial Workflow (MCP, CLI, Gemini Writing, Photos & Content Releases)
7+
8+
Use this skill whenever the user asks to:
9+
- Check the status of drafts, upcoming episodes/posts, or **Sanity Content Releases**
10+
- Write or update blog posts (`post`) or podcast show notes (`podcast`) using Gemini (from a topic or a YouTube `transcript`)
11+
- Generate or upload cover images (`coverImage`) to Sanity and attach them to documents
12+
- Stage or audit documents in a Sanity Content Release before publishing
13+
14+
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 ...`).
15+
16+
---
17+
18+
## 1. Checking Content Releases & Publishing Readiness
19+
20+
Sanity stores documents in three ID namespaces depending on their lifecycle state:
21+
- **Published**: `<docId>`
22+
- **Drafts**: `drafts.<docId>`
23+
- **Content Releases**: `versions.<releaseId>.<docId>` (paired with a release metadata document of `_type == "system.release"`)
24+
25+
### A. Inspect Active Content Releases & Versioned Documents
26+
Run via Sanity CLI (`--with-user-token` reads `drafts.**` and `versions.**` using the logged-in Sanity CLI session):
27+
28+
```bash
29+
pnpm --filter @codingcatdev/sanity exec sanity documents query \
30+
'{
31+
"releases": *[_type == "system.release"] | order(_updatedAt desc) {
32+
_id,
33+
name,
34+
state,
35+
publishAt,
36+
"metadata": metadata { title, description, releaseType }
37+
},
38+
"releaseDocs": *[_id in path("versions.**") && _type in ["post", "podcast"]] | order(_updatedAt desc) {
39+
_id,
40+
_type,
41+
title,
42+
"slug": slug.current,
43+
date,
44+
"hasCoverImage": defined(coverImage.asset._ref),
45+
"hasExcerpt": defined(excerpt) && length(excerpt) > 10,
46+
"blockCount": count(content),
47+
"hasYoutube": defined(youtube),
48+
"hasSpotify": defined(spotify),
49+
"hasTranscript": defined(transcript)
50+
},
51+
"drafts": *[_id in path("drafts.**") && _type in ["post", "podcast"]] | order(_updatedAt desc) {
52+
_id,
53+
_type,
54+
title,
55+
"slug": slug.current,
56+
date,
57+
"hasCoverImage": defined(coverImage.asset._ref),
58+
"hasExcerpt": defined(excerpt) && length(excerpt) > 10,
59+
"blockCount": count(content),
60+
"hasYoutube": defined(youtube),
61+
"hasSpotify": defined(spotify),
62+
"hasTranscript": defined(transcript)
63+
}
64+
}' --dataset production --api-version v2025-09-30
65+
```
66+
67+
### B. Publishing Readiness Checklist
68+
Before a `post` or `podcast` is ready to publish (or ready inside a Content Release), verify:
69+
1. **Shared Required Fields (`post` & `podcast`)**:
70+
- `title` (non-empty string)
71+
- `slug.current` (lowercase hyphenated slug)
72+
- `date` (ISO 8601 datetime)
73+
- `coverImage.asset._ref` (16:9 image uploaded to Sanity)
74+
- `excerpt` (1–2 sentence summary, <= 160 chars recommended)
75+
- `content` (non-empty Portable Text array)
76+
- `author` (defaults to Alex Patterson `_ref: "RIgC5r4TJ0hCGOzxHszD1w"`)
77+
2. **Podcast-Specific Fields (`podcast`)**:
78+
- `season` (integer) & `episode` (integer)
79+
- `recordingDate` (ISO 8601 datetime)
80+
- `guest` (array of references to `guest` documents)
81+
- `youtube` (YouTube URL — triggers `sync-youtube-transcript` on publish)
82+
- `spotify` & `listenLinks` (auto-synced by `sync-podcast-links` once published on Anchor/Spotify RSS)
83+
84+
---
85+
86+
## 2. Writing Posts & Podcast Show Notes via Gemini
87+
88+
### Step 1: Retrieve Source Context (Transcript or Existing Draft)
89+
- 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[]->`:
90+
```bash
91+
pnpm --filter @codingcatdev/sanity exec sanity documents query \
92+
'*[_type in ["post", "podcast"] && (slug.current == $slug || _id == $id)][0]{
93+
_id, _type, title, "slug": slug.current, excerpt, youtube,
94+
"transcript": transcript->{ fullText, summary, chapters },
95+
"guests": guest[]->{ title, excerpt, socials, websites }
96+
}' --dataset production
97+
```
98+
99+
### Step 2: Draft Content & Convert Markdown to Portable Text
100+
- Write the article or episode show notes in clean Markdown:
101+
- Use `##` (`h2`) and `###` (`h3`) headings to structure sections.
102+
- Include bulleted takeaways, links mentioned, and fenced code blocks where relevant.
103+
- Do **not** repeat the document `title` as an `# h1` at the top of `content`.
104+
- Write a crisp 1–2 sentence `excerpt` that complements the title without repeating it.
105+
- Convert the Markdown to Sanity Portable Text blocks using `@portabletext/markdown` (`markdownToPortableText`) — see `.agents/skills/portable-text-conversion/SKILL.md`.
106+
107+
### Step 3: Save to Draft (`drafts.<id>`) or Content Release (`versions.<releaseId>.<id>`)
108+
- Target `drafts.<docId>` (or `versions.<releaseId>.<docId>` if staging for a Content Release) so changes can be reviewed in Sanity Studio before going live:
109+
```bash
110+
pnpm --filter @codingcatdev/sanity exec sanity documents create <json-file> --replace --dataset production
111+
```
112+
113+
---
114+
115+
## 3. Creating & Uploading Photos (`coverImage`)
116+
117+
### Step 1: Generate a 16:9 Cover Image (When Needed)
118+
- 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).
119+
120+
### Step 2: Upload the Image Asset & Attach `coverImage`
121+
- 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`:
122+
```ts
123+
import { createReadStream } from "node:fs";
124+
import { basename } from "node:path";
125+
import { getCliClient } from "sanity/cli";
126+
127+
const client = getCliClient({ apiVersion: "2025-09-30" });
128+
const asset = await client.assets.upload("image", createReadStream(filePath), {
129+
filename: basename(filePath),
130+
});
131+
132+
await client
133+
.patch(targetDocumentId) // e.g. "drafts.<id>" or "versions.<releaseId>.<id>"
134+
.set({
135+
coverImage: {
136+
_type: "image",
137+
asset: {
138+
_type: "reference",
139+
_ref: asset._id,
140+
},
141+
},
142+
})
143+
.commit();
144+
```

‎.github/workflows/deploy.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ jobs:
2929
- name: Install dependencies
3030
run: pnpm install --frozen-lockfile
3131

32+
- name: Export Sanity production dataset backup before deploy
33+
run: |
34+
curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz
35+
36+
- name: Upload Sanity production dataset backup artifact
37+
uses: actions/upload-artifact@v4
38+
with:
39+
name: sanity-production-predeploy-backup-dev-${{ github.sha }}
40+
path: /tmp/sanity-production-backup.ndjson.gz
41+
retention-days: 30
42+
3243
- name: Deploy Sanity Studio
3344
run: npx sanity deploy -y
3445
working-directory: apps/sanity
@@ -90,6 +101,19 @@ jobs:
90101
- name: Install dependencies
91102
run: pnpm install --frozen-lockfile
92103

104+
- name: Export Sanity production dataset backup before deploy
105+
run: |
106+
curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz
107+
env:
108+
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
109+
110+
- name: Upload Sanity production dataset backup artifact
111+
uses: actions/upload-artifact@v4
112+
with:
113+
name: sanity-production-predeploy-backup-${{ github.sha }}
114+
path: /tmp/sanity-production-backup.ndjson.gz
115+
retention-days: 30
116+
93117
- name: Deploy Sanity Studio
94118
run: npx sanity deploy -y
95119
working-directory: apps/sanity

‎.github/workflows/nightly-dataset-sync.yml‎

Lines changed: 128 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,133 @@ jobs:
1111
environment: production
1212

1313
steps:
14-
- name: Reset dev dataset from production
14+
- uses: actions/checkout@v4
15+
16+
- uses: pnpm/action-setup@v4
17+
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version-file: ".nvmrc"
21+
cache: "pnpm"
22+
23+
- name: Install dependencies
24+
run: pnpm install --frozen-lockfile
25+
26+
- name: Sync production dataset to dev
27+
env:
28+
SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }}
29+
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
30+
SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }}
1531
run: |
16-
PROJECT_ID="${{ vars.SANITY_STUDIO_PROJECT_ID }}"
17-
[ -z "$PROJECT_ID" ] && PROJECT_ID="hfh83o0w"
18-
TOKEN="${{ secrets.SANITY_AUTH_TOKEN }}"
32+
PROJECT_ID="${SANITY_STUDIO_PROJECT_ID:-hfh83o0w}"
1933
API_HOST="https://api.sanity.io"
20-
API_VERSION="v2025-09-30"
21-
22-
echo "Deleting existing 'dev' dataset..."
23-
curl -s -X DELETE "$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/dev" \
24-
-H "Authorization: Bearer $TOKEN" || true
25-
26-
echo "Cloning 'production' dataset to 'dev'..."
27-
RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X PUT \
28-
"$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/production/copy" \
29-
-H "Authorization: Bearer $TOKEN" \
30-
-H "Content-Type: application/json" \
31-
-d '{"targetDataset":"dev","skipHistory":true}')
32-
33-
HTTP_STATUS=$(echo "$RESPONSE" | grep "HTTP_STATUS:" | cut -d: -f2)
34-
BODY=$(echo "$RESPONSE" | grep -v "HTTP_STATUS:")
35-
36-
echo "Status: $HTTP_STATUS"
37-
echo "Response: $BODY"
38-
39-
if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then
40-
echo "Dataset sync initiated successfully!"
41-
else
42-
echo "Dataset sync failed with status $HTTP_STATUS"
43-
exit 1
44-
fi
34+
API_VERSION="v2021-06-07"
35+
36+
echo "1. Ensuring 'dev' dataset exists (public ACL)..."
37+
for T in "$SANITY_AUTH_TOKEN" "$SANITY_API_WRITE_TOKEN"; do
38+
[ -z "$T" ] && continue
39+
RESP=$(curl -s -X PUT "$API_HOST/$API_VERSION/projects/$PROJECT_ID/datasets/dev" \
40+
-H "Authorization: Bearer $T" \
41+
-H "Content-Type: application/json" \
42+
-d '{"aclMode":"public"}' || true)
43+
echo "Dataset create response: $RESP"
44+
if echo "$RESP" | grep -q '"datasetName":"dev"'; then
45+
break
46+
fi
47+
done
48+
49+
echo "2. Exporting 'production' dataset to /tmp/production.ndjson..."
50+
curl -f -sS "https://$PROJECT_ID.api.sanity.io/$API_VERSION/data/export/production" -o /tmp/production.ndjson
51+
gzip -k -f /tmp/production.ndjson
52+
wc -l /tmp/production.ndjson
53+
54+
echo "3. Importing documents into 'dev' dataset via Sanity Mutate API..."
55+
node -e '
56+
const fs = require("fs");
57+
const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w";
58+
const tokens = [process.env.SANITY_API_WRITE_TOKEN, process.env.SANITY_AUTH_TOKEN].filter(Boolean);
59+
const lines = fs.readFileSync("/tmp/production.ndjson", "utf8").trim().split("\n").filter(Boolean);
60+
const allDocs = lines.map((l) => JSON.parse(l));
61+
// Exclude sanity.imageAsset and sanity.fileAsset if mutate API rejects system asset docs without binary upload,
62+
// or include content docs and weaken references to assets/missing docs so all content imports cleanly.
63+
const contentDocs = allDocs.filter((d) => d._type !== "sanity.imageAsset" && d._type !== "sanity.fileAsset");
64+
const contentIds = new Set(contentDocs.map((d) => d._id));
65+
66+
function sanitizeDoc(obj) {
67+
if (!obj || typeof obj !== "object") return obj;
68+
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
69+
const copy = {};
70+
for (const [k, v] of Object.entries(obj)) {
71+
if (k === "_rev") continue;
72+
copy[k] = sanitizeDoc(v);
73+
}
74+
if (typeof copy._ref === "string" && !contentIds.has(copy._ref)) {
75+
copy._weak = true;
76+
}
77+
return copy;
78+
}
79+
80+
const sanitized = contentDocs.map(sanitizeDoc);
81+
console.log(`Prepared ${sanitized.length} content documents (from ${allDocs.length} total lines) for dataset "dev"...`);
82+
83+
async function mutateBatch(mutations) {
84+
let lastErr = "";
85+
for (const token of tokens) {
86+
const res = await fetch(`https://${projectId}.api.sanity.io/v2021-06-07/data/mutate/dev?visibility=async&autoGenerateArrayKeys=true`, {
87+
method: "POST",
88+
headers: {
89+
Authorization: `Bearer ${token}`,
90+
"Content-Type": "application/json",
91+
},
92+
body: JSON.stringify({ mutations }),
93+
});
94+
if (res.ok) return;
95+
lastErr = `HTTP ${res.status}: ${await res.text()}`;
96+
}
97+
throw new Error(`Mutate batch failed: ${lastErr}`);
98+
}
99+
100+
(async () => {
101+
// Pass 1: Create stubs so all internal document references resolve
102+
const stubs = sanitized.map((d) => ({ createOrReplace: { _id: d._id, _type: d._type } }));
103+
for (let i = 0; i < stubs.length; i += 250) {
104+
await mutateBatch(stubs.slice(i, i + 250));
105+
}
106+
console.log("Pass 1 (document stubs) complete.");
107+
108+
// Pass 2: Populate full document bodies
109+
for (let i = 0; i < sanitized.length; i += 100) {
110+
const batch = sanitized.slice(i, i + 100).map((d) => ({ createOrReplace: d }));
111+
await mutateBatch(batch);
112+
if ((i / 100) % 5 === 0) {
113+
console.log(`Imported ${Math.min(i + 100, sanitized.length)} / ${sanitized.length} documents...`);
114+
}
115+
}
116+
117+
// Pass 3 (best-effort): Import asset metadata documents
118+
const assetDocs = allDocs
119+
.filter((d) => d._type === "sanity.imageAsset" || d._type === "sanity.fileAsset")
120+
.map(({ _rev, ...rest }) => rest);
121+
try {
122+
for (let i = 0; i < assetDocs.length; i += 250) {
123+
const batch = assetDocs.slice(i, i + 250).map((d) => ({ createOrReplace: d }));
124+
await mutateBatch(batch);
125+
}
126+
console.log(`Imported ${assetDocs.length} asset metadata documents.`);
127+
} catch (assetErr) {
128+
console.warn("Note: Asset metadata docs skipped (served via CDN):", assetErr.message);
129+
}
130+
131+
console.log("Dataset sync completed successfully!");
132+
})().catch((err) => {
133+
console.error(err);
134+
process.exit(1);
135+
});
136+
'
137+
138+
- name: Upload Sanity production dataset backup artifact
139+
uses: actions/upload-artifact@v4
140+
with:
141+
name: sanity-production-backup-${{ github.run_id }}
142+
path: /tmp/production.ndjson.gz
143+
retention-days: 30

0 commit comments

Comments
 (0)