Loading...
Loading...
Publishes blog content to Sanity CMS with dual-mode support (markdown output or API publishing)
npx skill4agent add zura1555/agents sanity-publisher1. Query authors: *[_type == "person"]{_id, name}
2. Query categories: *[_type == "category"]{_id, title}
3. Store the actual _id values for use in document creationmcp__sanity__create_document1. Query the created document to verify all fields
2. Patch any missing fields individually:
- date, publishedAt (ISO timestamps)
- author (reference object with _ref and _type)
- categories (array of reference objects with _key, _ref, _type)
- seo.title, seo.description, seo.keywords
- seo.openGraph (complete object)
- seo.twitter (complete object)| Field | Min | Max | Notes |
|---|---|---|---|
| Meta Title | 50 | 60 | SEO title for search results |
| Meta Description | 150 | 160 | Description for search results |
| OG Title | - | 60 | Open Graph title for social sharing |
| OG Description | 90 | 120 | Social card description |
| Twitter Description | 150 | 160 | Twitter card description |
// Author reference
{
"_type": "reference",
"_ref": "e22e28ca-0e7c-4b9f-bc4f-ec9dbf070e4a"
}
// Categories array
[
{"_key": "cat1", "_type": "reference", "_ref": "0973c166-b3cf-412a-a832-c783aba0b780"},
{"_key": "cat2", "_type": "reference", "_ref": "43f1a785-9f80-4458-abe5-0ee7795fe6bc"}
]image-manifest.json{workspacePath}/image-manifest.jsonconst manifestPath = `${workspacePath}/image-manifest.json`;
const hasManifest = fs.existsSync(manifestPath);
const imageManifest = hasManifest ? JSON.parse(fs.readFileSync(manifestPath)) : null;// Upload cover image as asset
if (imageManifest?.cover?.path) {
const coverAsset = await client.assets.upload('image',
fs.createReadStream(`${workspacePath}/${imageManifest.cover.path}`),
{ filename: 'cover.png' }
);
// Store asset ID for document reference
imageAssets.cover = coverAsset._id;
}// Set coverImage field with uploaded asset reference
coverImage: imageManifest?.cover?.path ? {
_type: 'image',
asset: {
_type: 'reference',
_ref: imageAssets.cover
},
alt: imageManifest.cover.alt || 'Blog post cover image'
} : undefined// Get the CDN URL for the uploaded image
const coverImageUrl = `https://cdn.sanity.io/images/${projectId}/${dataset}/${imageAssets.cover.split('-').slice(1).join('-')}`;
// Set in SEO metadata
seo: {
// ...other fields
metaImage: {
url: coverImageUrl,
alt: imageManifest.cover.alt
},
openGraph: {
// ...other fields
image: {
url: coverImageUrl,
width: 1200,
height: 675,
alt: imageManifest.cover.alt
}
},
twitter: {
// ...other fields
image: {
url: coverImageUrl,
alt: imageManifest.cover.alt
}
}
}// Upload each section image and store references
const sectionAssets = [];
for (const section of imageManifest.sections || []) {
if (section.path) {
const sectionAsset = await client.assets.upload('image',
fs.createReadStream(`${workspacePath}/${section.path}`),
{ filename: `section-${section.index}.png` }
);
sectionAssets.push({
index: section.index,
assetId: sectionAsset._id,
alt: section.alt
});
}
}// Convert markdown image syntax to Sanity image block
// From: 
// To: Sanity image block with asset reference
function convertMarkdownToPortableText(content, sectionAssets) {
// Parse markdown and find image references
const imageRegex = /!\[(.*?)\]\((images\/section-(\d+)\.png)\)/g;
// Replace with Sanity image block structure
// This creates inline images in the content array
}publish-result.json{
"projectId": "proj-2025-12-24-001",
"publishingMode": "api",
"status": "success",
"sanityResponse": {
"documentId": "post-abc123",
"publishedId": "post-abc123",
"url": "https://zura.id.vn/blog/my-post"
},
"imageAssets": {
"cover": {
"assetId": "image-abc123def456",
"url": "https://cdn.sanity.io/images/projectId/dataset/abc123def456.png",
"alt": "Cover image alt text"
},
"sections": [
{
"index": 1,
"assetId": "image-ghi789jkl012",
"alt": "Section 1 alt text"
}
]
}
}image-manifest.json{
"warnings": [
{
"type": "missing_images",
"message": "No image manifest found - publishing without cover image",
"impact": "Post will have no featured image",
"recommendation": "Manually add cover image in Sanity Studio"
}
]
}// Handle image upload failures gracefully
try {
const coverAsset = await client.assets.upload('image', ...);
} catch (error) {
console.warn(`Cover image upload failed: ${error.message}`);
// Continue without cover image
warnings.push({
type: 'image_upload_failed',
message: `Could not upload cover image: ${error.message}`,
severity: 'warning',
recommendation: 'Manually upload cover image in Sanity Studio'
});
}{
// Core Content Fields
title: string, // Post title
slug: { // URL slug
_type: "slug",
current: string
},
content: array, // Block content (array of block objects)
excerpt: string, // Short description (max 200 chars)
coverImage: { // Main image with alt text
_type: "image",
asset: { _ref: string },
alt: string
},
// Metadata Fields
publishedAt: datetime, // Publication date (ISO format)
date: datetime, // Date field (ISO format)
status: "published", // Publication status
readingTime: string, // Calculated reading time
wordCount: number, // Word count
// Reference Fields
author: { // Author reference (REQUIRED)
_type: "reference",
_ref: string // Must be valid author ID
},
categories: [{ // Category references (REQUIRED, min 1)
_type: "reference",
_ref: string // Must be valid category ID
}],
// Free-form Tags
tags: array, // Array of tag strings
// SEO Fields (seoFields object)
seo: {
title: string, // Meta title (50-60 chars)
description: string, // Meta description (150-160 chars)
keywords: array, // Array of keyword strings
canonicalUrl: string, // Canonical URL
robots: { // Robots meta directives
noFollow: boolean,
noIndex: boolean
},
metaImage: { // Meta image for SEO
url: string,
alt: string
},
metaAttributes: array // Additional meta attributes
// Open Graph Fields
openGraph: {
title: string, // OG title (max 60 chars)
description: string, // OG description (90-120 chars)
type: "article", // OG type
url: string, // OG URL
siteName: string, // OG site name
locale: string, // OG locale (e.g., "en_US")
image: { // OG image
url: string,
width: number,
height: number,
alt: string
},
article: { // Article metadata
publishedTime: string, // ISO timestamp
modifiedTime: string, // ISO timestamp
author: string, // Author name
section: string, // Category/section
tags: array // Array of tag strings
}
},
// Twitter Fields
twitter: {
card: "summary_large_image", // Twitter card type
site: string, // Twitter site handle (@username)
creator: string, // Twitter creator handle (@username)
title: string, // Twitter title
description: string, // Twitter description (150-160 chars)
image: { // Twitter image
url: string,
alt: string
}
}
}
}{
"projectId": "proj-YYYY-MM-DD-XXX",
"workspacePath": "/d/project/tuan/blog-workspace/active-projects/{projectId}/",
"contentFile": "polished-draft.md",
"seoMetadataFile": "seo-metadata.json",
"styleReportFile": "style-report.md",
"publishingMode": "markdown|api|ask-user",
"sanityConfig": {
"projectId": "your-project-id",
"dataset": "production",
"token": "your-api-token"
}
}polished-draft.mdseo-metadata.jsonstyle-report.mdsanity-config.json---
title: "{Post Title}"
slug: "{url-friendly-slug}"
excerpt: "{Compelling description (max 200 chars)}"
author: "Thuong-Tuan Tran"
publishedAt: "{ISO timestamp}"
status: "published"
categories:
- "{Category 1}"
- "{Category 2}" (optional)
tags:
- "{Tag 1}"
- "{Tag 2}"
- "{Tag 3}"
seo:
metaTitle: "{SEO-optimized title}"
metaDescription: "{SEO meta description}"
keywords: "{comma,separated,keywords}"
score: {seoScore}/100
readingTime: "{X} minutes"
wordCount: {wordCount}
style:
score: {styleScore}/100
type: "{tech|personal-dev}"
---
# {H1 Title}
> {Engaging excerpt or quote}
{Complete content formatted for Sanity}
## Metadata Summary
- **SEO Score**: {seoScore}/100
- **Style Score**: {styleScore}/100
- **Word Count**: {wordCount} words
- **Reading Time**: {X} minutes
- **Content Type**: {tech|personal-dev}
- **Categories**: {List}
- **Tags**: {List}
## Sanity Import Instructions
### Option 1: Manual Import (Recommended for Review)
1. Open Sanity Studio for your project
2. Navigate to "Posts" collection
3. Click "Create new post"
4. Fill in fields from YAML frontmatter above
5. Paste content in "Content" field (after removing YAML)
6. Set cover image (if not in content)
7. Select categories from existing list
8. Add tags
9. Review and publish
### Option 2: Using Sanity CLI
```bash
sanity create post --id {projectId} --title "{title}"
### Mode 2: API Publishing
#### Publishing Response Structure
```json
{
"projectId": "proj-YYYY-MM-DD-XXX",
"publishingMode": "api",
"status": "success|partial-success|failed",
"timestamp": "ISO timestamp",
"sanityResponse": {
"documentId": "post-{id}",
"publishedId": "{published-id}",
"url": "https://your-site.com/posts/{slug}",
"revision": "number"
},
"processingDetails": {
"contentConverted": true,
"metadataApplied": true,
"seoDataSaved": true,
"categoriesAssigned": true,
"tagsAdded": true,
"authorReferenced": true
},
"validation": {
"schemaCompliance": true,
"requiredFieldsPresent": true,
"dataTypesCorrect": true,
"referencesValid": true
},
"errors": [
{
"field": "field name",
"message": "Error description",
"severity": "warning|critical",
"suggestion": "How to fix"
}
],
"warnings": [
{
"message": "Warning description",
"impact": "Impact on publishing",
"recommendation": "Recommended action"
}
]
}// Sanity API Publishing Template - MUST populate ALL schema fields
import { createClient } from '@sanity/client';
const publishToSanity = async (content, metadata, config) => {
const client = createClient({
projectId: config.projectId,
dataset: config.dataset,
token: config.token,
useCdn: false,
apiVersion: '2024-12-02'
});
// CRITICAL: Validate all schema fields BEFORE publishing
const validationErrors = validateSchemaFields(metadata);
if (validationErrors.length > 0) {
throw new Error(`Schema validation failed: ${validationErrors.join(', ')}`);
}
// Prepare COMPLETE document with ALL fields
const document = {
_type: 'post',
// Core Content
title: metadata.title,
slug: {
_type: 'slug',
current: metadata.slug
},
content: convertMarkdownToPortableText(content),
excerpt: metadata.excerpt,
publishedAt: new Date().toISOString(),
date: new Date().toISOString(),
status: 'published',
wordCount: metadata.wordCount,
readingTime: metadata.readingTime,
// References (MUST be valid IDs)
author: {
_type: 'reference',
_ref: await getAuthorId(client, 'Thuong-Tuan Tran') // Use existing author ID
},
categories: await getCategoryReferences(client, metadata.categories), // At least 1 required
// Tags
tags: metadata.tags,
// Cover Image with Alt Text
coverImage: metadata.coverImage ? {
_type: 'image',
asset: { _ref: metadata.coverImage.assetId },
alt: metadata.coverImage.alt
} : undefined,
// Complete SEO Fields Structure
seo: {
// Basic SEO
title: metadata.seo.metaTitle, // 50-60 chars
description: metadata.seo.metaDescription, // 150-160 chars
keywords: metadata.seo.keywords, // Array of strings
canonicalUrl: metadata.seo.canonicalUrl, // Full URL
robots: {
noFollow: false,
noIndex: false
},
metaImage: {
url: metadata.seo.metaImageUrl,
alt: metadata.seo.metaImageAlt
},
metaAttributes: [],
// Open Graph
openGraph: {
title: metadata.openGraph.title,
description: metadata.openGraph.description, // 100-120 chars
type: 'article',
url: metadata.openGraph.url,
siteName: metadata.openGraph.siteName,
locale: 'en_US',
image: {
url: metadata.openGraph.imageUrl,
width: 1200,
height: 630,
alt: metadata.openGraph.imageAlt
},
article: {
publishedTime: metadata.publishedAt,
modifiedTime: metadata.publishedAt,
author: 'Thuong-Tuan Tran',
section: metadata.categories[0],
tags: metadata.tags
}
},
// Twitter
twitter: {
card: 'summary_large_image',
site: '@zura_id_vn',
creator: '@zura_id_vn',
title: metadata.twitter.title,
description: metadata.twitter.description, // 150-160 chars
image: {
url: metadata.twitter.imageUrl,
alt: metadata.twitter.imageAlt
}
}
}
};
// Create document
const created = await client.create(document);
// Publish document
const published = await client
.patch(created._id)
.set({ status: 'published' })
.commit();
return {
documentId: created._id,
publishedId: published._id,
url: `https://zura.id.vn/blog/${metadata.slug}`,
validationStatus: 'passed',
fieldsPopulated: Object.keys(document).length
};
};
// Schema validation function - CRITICAL
function validateSchemaFields(metadata) {
const errors = [];
// Validate character limits (UPDATED 2025-12-24)
if (metadata.seo.metaTitle.length < 50 || metadata.seo.metaTitle.length > 60) {
errors.push(`Meta Title must be 50-60 characters (currently ${metadata.seo.metaTitle.length})`);
}
if (metadata.seo.metaDescription.length < 150 || metadata.seo.metaDescription.length > 160) {
errors.push(`Meta Description must be 150-160 characters (currently ${metadata.seo.metaDescription.length})`);
}
// OG Title: max 60 characters
if (metadata.openGraph.title.length > 60) {
errors.push(`OG Title must be max 60 characters (currently ${metadata.openGraph.title.length})`);
}
// OG Description: 90-120 characters (min 90 for best engagement)
if (metadata.openGraph.description.length < 90 || metadata.openGraph.description.length > 120) {
errors.push(`OG Description must be 90-120 characters (currently ${metadata.openGraph.description.length})`);
}
// Validate required fields
if (!metadata.categories || metadata.categories.length === 0) {
errors.push('At least 1 category required');
}
if (!metadata.seo.canonicalUrl) {
errors.push('Canonical URL required');
}
if (!metadata.openGraph.siteName) {
errors.push('OG Site Name required');
}
return errors;
}{
"tech": "Technology",
"personal-dev": "Personal Development"
}{
"tech": ["technology", "programming", "development", "coding"],
"personal-dev": ["self-improvement", "growth", "productivity", "mindset"]
}