Use it when prompts, readings, notes, and citation requirements should shape the revision. Verla is relevant in that scenario because its documented workflow accepts assignment materials and supports guided revision; use its [essay development workflow](https://verla.io/essay-extender) when you need to build the reasoning before refining wording. --- sanity pilot page --- import {UseCasePageContent} from '@/features/cms/storyblok-use-case-pages'; import {BypassDetectorPage} from '@/features/landing/bypass/bypass-detector-page'; import {adaptStoryblokPublicUseCaseStory,adaptStoryblokPublicBypassStory} from '@/lib/cms/storyblok/adapters'; /** Local pilot renderer. Reuses page templates but never changes production sources. */ import type { Metadata } from "next"; import type { ComponentProps, ReactNode } from "react"; import { notFound } from "next/navigation"; import { StoryblokServerRichText } from "@storyblok/react/rsc"; import { APP_LAYER_CLASS_NAMES } from "@/components/layout/layering"; import { BlogLayout } from "@/components/blog/BlogLayout"; import { BLOG_RICH_TEXT_COMPONENTS } from "@/components/blog/blog-rich-text-components"; import { RegisteredSeoHeroTool } from "@/features/landing/tools/shared/seo-hero-tool-registry"; import { SeoPageRenderer } from "@/features/landing/tools/shared/seo-page-renderer"; import { QuestionDetailPage } from "@/features/landing/questions/question-detail-page"; import { KnowledgeDetailPage } from "@/features/landing/knowledge/knowledge-detail-page"; import { adaptStoryblokPublicBlogStory, adaptStoryblokPublicToolStory } from "@/lib/cms/storyblok/adapters"; import { getStoryblokPreviewDiagnostics } from "@/lib/cms/storyblok/preview-diagnostics"; import { PILOT_TYPES, toTemplateStory, type ObjectValue, type PilotKind } from "@/lib/cms/sanity/bridge"; import { queryPilot, readPilotDocument, requireSanityPreview } from "@/lib/cms/sanity/client"; import {studyPairsFromDocuments,StudyValidationError} from "@/lib/cms/sanity/study-package"; import {isContentKind} from "@/lib/cms/sanity/content-identity"; import {buildSanityContentPreviewMetadata,buildSanityStudyPreviewMetadata,SANITY_PREVIEW_FALLBACK_METADATA} from "@/lib/cms/sanity/preview-metadata"; import { BlogAuthoringError, blogIssues, normalizedBlog } from "@/lib/cms/sanity/authoring"; import { PreviewReady } from "../../preview-ready"; type Props = { params: Promise<{kind: string; slug: string}> }; export async function generateMetadata({params}:Props):Promise<Metadata>{ await requireSanityPreview(); const {kind,slug}=await params; if(!Object.hasOwn(PILOT_TYPES,kind)||!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))notFound(); let document:ObjectValue|null; try{document=await readPilotDocument(kind as PilotKind,slug);} catch{return SANITY_PREVIEW_FALLBACK_METADATA;} if(!document)notFound(); try{ if(isContentKind(kind))return buildSanityContentPreviewMetadata(kind,document); if(kind!=="questions"&&kind!=="knowledge")return SANITY_PREVIEW_FALLBACK_METADATA; const pairs=studyPairsFromDocuments(await queryPilot('*[_type in ["pilotQuestion","pilotKnowledge"]]'),true); const pair=pairs.find(value=>(kind==='questions'?value.question.slug:value.knowledge.slug)===slug); if(!pair)throw new StudyValidationError(slug,'缺少完整配对'); return buildSanityStudyPreviewMetadata(kind,pair); }catch{return SANITY_PREVIEW_FALLBACK_METADATA;} } export default async function SanityPilotPage({ params }: Props) { await requireSanityPreview(); const { kind, slug } = await params; if (!Object.hasOwn(PILOT_TYPES, kind) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) notFound(); let document: ObjectValue | null; try { document = await readPilotDocument(kind as PilotKind, slug); } catch (error) { return <main lang="zh-CN" className="mx-auto max-w-3xl px-6 py-32"><h1>暂时无法预览</h1><p>{error instanceof BlogAuthoringError ? `页面名称:${error.message}` : "请检查正式地址查询及网络连接,再刷新重试。"}</p></main>; } if (!document) notFound(); const ready=(invalid=false)=><PreviewReady revision={String(document._rev ?? "")} invalid={invalid} origins={(process.env.SANITY_STUDIO_ORIGINS ?? "http://127.0.0.1:3333,http://localhost:3333").split(",").map(value=>value.trim())} />; let page: ReactNode; try { if (kind === "blog") { const issues=blogIssues(document).filter(issue=>issue.level==='error'); if(issues.length)return <>{ready(true)}<main lang="zh-CN" className="mx-auto max-w-3xl px-6 py-32"><h1>草稿需要修复</h1><ul>{issues.map(issue=><li key={issue.path}>{issue.path}:{issue.message}</li>)}</ul></main></>; const blog = adaptStoryblokPublicBlogStory(toTemplateStory(normalizedBlog(document), "blog"), {allowDecorativeImages:true,relaxedEditorialRules:true}); page = <BlogLayout author={blog.author} postOverride={blog.post} sharePath={blog.canonicalPath} slug={slug} tableOfContents={blog.tableOfContents}> <StoryblokServerRichText components={BLOG_RICH_TEXT_COMPONENTS} data={{routeLocale:"en"}} document={blog.body as ComponentProps<typeof StoryblokServerRichText>["document"]} wrapper={false} /> </BlogLayout>; } else if (kind === "tools") { const { spec } = adaptStoryblokPublicToolStory(toTemplateStory(document, "tools")); page = <main><SeoPageRenderer heroTool={<RegisteredSeoHeroTool spec={spec} />} spec={spec} /></main>; } else if (kind === 'use-cases') { page = <UseCasePageContent spec={adaptStoryblokPublicUseCaseStory(toTemplateStory(document,'use-cases')).spec} />; } else if (kind === 'bypass') { page = <BypassDetectorPage relatedItems={[]} spec={adaptStoryblokPublicBypassStory(toTemplateStory(document,'bypass')).spec} />; } else { const pairs=studyPairsFromDocuments(await queryPilot('*[_type in ["pilotQuestion","pilotKnowledge"]]'),true); const pair=pairs.find(p=>(kind==='questions'?p.question.slug:p.knowledge.slug)===slug); if(!pair)throw new StudyValidationError(slug,'缺少完整配对'); const {question,knowledge,supportResources}=pair; page=kind==='questions'?<QuestionDetailPage question={question} knowledgeOverride={knowledge} previewLinks relatedQuestions={pairs.map(p=>p.question).filter(q=>question.relatedQuestionSlugs.includes(q.slug))} supportResources={supportResources}/>:<KnowledgeDetailPage question={question} knowledge={knowledge} previewLinks relatedKnowledgeEntries={pairs.map(p=>p.knowledge).filter(k=>knowledge.relatedKnowledgeSlugs.includes(k.slug))}/>; } } catch (error) { const diagnostics = getStoryblokPreviewDiagnostics(error); return <>{ready(true)}<main lang="zh-CN" className="mx-auto max-w-3xl px-6 py-32"><h1 className="text-3xl font-semibold">草稿需要修复</h1><p className="my-4">请回到 Studio 修复对应字段,保存后刷新预览。</p> {error instanceof StudyValidationError ? <p>{error.message}</p> : diagnostics ? <ul>{diagnostics.map((issue) => <li className="my-4" key={issue.path}><strong>{issue.title}</strong><p>{issue.field}:{issue.action}</p></li>)}</ul> : <p>内容结构或关联不完整,请核对字段及关联内容;此错误没有影响正式站。</p>} </main></>; } return <div lang="en">{ready()}<div lang="zh-CN" className={`${APP_LAYER_CLASS_NAMES.globalNotification} fixed bottom-3 left-3 rounded bg-amber-100 px-4 py-2 text-sm text-black`}>Sanity 草稿预览 · 保存后刷新</div>{page}</div>; } --- session route --- import {NextRequest,NextResponse} from 'next/server'; import {PREVIEW_COOKIE,PREVIEW_TTL,previewPath,studioOrigins,validateStudioGrant} from '@/lib/cms/sanity/preview-access'; /** Studio submits a grant in a form body, then navigates to a clean preview URL. */ export async function POST(request:NextRequest){ const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer','X-Robots-Tag':'noindex, nofollow'}; // Studio may use no-referrer, making cross-origin form navigation serialize Origin as null. // A null/missing Origin never grants access: the expiring Studio grant is still mandatory. const origin=request.headers.get('origin'); if(origin&&origin!=='null'&&!studioOrigins().includes(origin))return new NextResponse('请从 Sanity 打开预览',{status:403,headers}); let form:FormData;try{form=await request.formData();}catch{return new NextResponse('预览请求无效',{status:400,headers});} const path=previewPath(form.get('path'));const secret=form.get('secret'); if(!path||typeof secret!=='string'||!await validateStudioGrant(secret))return new NextResponse('预览授权无效或已过期,请返回 Sanity 重试',{status:403,headers}); // Production is HTTPS behind the existing reverse proxy; never issue an insecure production cookie. const secure=process.env.NODE_ENV==='production'||request.nextUrl.protocol==='https:'; const response=new NextResponse(null,{status:303,headers:{...headers,Location:path}}); response.cookies.set(PREVIEW_COOKIE,secret,{httpOnly:true,secure,sameSite:secure?'none':'lax',path:'/sanity-pilot',maxAge:PREVIEW_TTL,partitioned:secure&&request.headers.get('sec-fetch-dest')==='iframe'}); return response; } --- sanity adapter files --- src/lib/cms/sanity/authoring.ts src/lib/cms/sanity/bridge.ts src/lib/cms/sanity/client.ts src/lib/cms/sanity/content-identity.ts src/lib/cms/sanity/landing-publication.ts src/lib/cms/sanity/migration.ts src/lib/cms/sanity/preview-access.ts src/lib/cms/sanity/preview-metadata.ts src/lib/cms/sanity/publication.ts src/lib/cms/sanity/published-client.ts src/lib/cms/sanity/published-landing-client.ts src/lib/cms/sanity/published-tool-client.ts src/lib/cms/sanity/study-content.ts src/lib/cms/sanity/study-import.ts src/lib/cms/sanity/study-package.ts src/lib/cms/sanity/study-publication.ts src/lib/cms/sanity/tool-publication.ts src/lib/cms/sanity/values.ts ---src/lib/cms/sanity/authoring.ts import {isContentDocumentId,isNativeContentId} from './content-identity'; /** Pure shared authoring rules used by Studio, server previews and migration tests. */ import { object, type ObjectValue, type Value } from './values'; export const BLOG_PREFIX = 'verla-pilot.blog.'; export const validBlogSlug = (slug: unknown): slug is string => typeof slug === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug); export const baseDocumentId = (id: string) => id.replace(/^drafts\./, ''); export const isMigratedBlog = (id: string, slug: unknown) => validBlogSlug(slug) && baseDocumentId(id) === `${BLOG_PREFIX}${slug}`; export const suggestBlogSlug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); export const BLOG_DEFAULTS: ObjectValue = { _type:'sb_blog_post', category:'Academic Writing', toc_mode:'auto', read_time_minutes:'5', author_name:'Verla Team', author_title:'Academic Writing Experts', author_bio:'Helping students master academic writing with AI-powered tools.', faq_intro:'Frequently asked questions', faq_items:[], table_of_contents:[], body:[], final_cta_href:'/academic-assistant', final_cta_label:'Start with Verla', final_cta_title:'Turn your assignment brief into a strong first draft', final_cta_supporting_copy:'Upload your instructions, sources, and notes. Verla organizes them into a structured draft you can review and refine.', newsletter_title:'Get better at academic writing, one email at a time.', newsletter_supporting_copy:'Practical tips on research, writing, and AI tools for students. No spam — unsubscribe anytime.', }; export function newBlogDraft(randomId: string): ObjectValue { if (!/^[a-z0-9-]+$/.test(randomId)) throw new Error('文档标识不正确'); return {_id:`drafts.${BLOG_PREFIX}new-${randomId}`, _type:'pilotBlog', title:'未命名文章', content:structuredClone(BLOG_DEFAULTS)}; } export function copyBlogDraft(source: ObjectValue, randomId: string): ObjectValue { const copy = newBlogDraft(randomId); if(isNativeContentId(randomId))copy._id=`drafts.${randomId}`; const content = structuredClone(object(source.content)); delete content.canonical_path; delete content.publish_date; copy.title = `${typeof source.title === 'string' ? source.title : '文章'}(副本)`; copy.content = content; return copy; } export class BlogAuthoringError extends Error { constructor(readonly path:string,message:string){super(message);this.name='BlogAuthoringError';} } export type AuthoringIssue = {path:string;message:string;level:'error'|'warning'}; export function blogIssues(document: ObjectValue): AuthoringIssue[] { const issues: AuthoringIssue[]=[]; const add=(path:string,message:string)=>issues.push({path,message,level:'error'}); const content=document.content && typeof document.content==='object' && !Array.isArray(document.content) ? document.content : {}; if(!validBlogSlug(document.slug))add('slug','请填写 URL 名称,仅使用小写英文字母、数字和中划线'); for(const [key,label] of [['title','文章标题'],['excerpt','文章摘要']])if(typeof content[key]!=='string'||!String(content[key]).trim())add(`content.${key}`,`请填写${label}`); if(!Array.isArray(content.body)||!content.body.length)add('content.body','请添加正文段落'); if(!content.cover_image_asset && !content.cover_image_path)add('content.cover_image_asset','请选择封面图片'); const walk=(value:Value,path:string)=>{ if(Array.isArray(value)){value.forEach((v,i)=>walk(v,`${path}.${i}`));return;} if(!value||typeof value!=='object')return; const containers:Record<string,string>={pilotTable:'请添加表格行',pilotTableRow:'请添加单元格',pilotBulletList:'请添加列表项',pilotOrderedList:'请添加列表项'}; const hint=containers[String(value._type)]; if(hint&&(!Array.isArray(value.content)||!value.content.length))add(`${path}.content`,hint); if(value._type==='pilotImage'||value._type==='pilotBodyImage') { const decorative=path.startsWith('content.body.')&&value.decorative===true; if(!decorative && (typeof value.alt!=='string'||!value.alt.trim()))add(`${path}.alt`,'请填写图片说明;纯装饰正文图可勾选“装饰图片”'); if(!value.asset&&!value.sourceUrl)add(path,'请选择图片'); } Object.entries(value).forEach(([key,v])=>walk(v,`${path}.${key}`)); }; walk(content,'content'); for(const [key,max] of [['seo_title',100],['meta_description',200]] as const)if(typeof content[key]==='string'&&content[key].length>max)issues.push({path:`content.${key}`,message:`建议控制在 ${max} 字符以内,当前仍可预览`,level:'warning'}); return issues; } /** Missing draft date is display-only; never persist a fabricated publication event. */ export function normalizedBlog(document: ObjectValue): ObjectValue { const content={...structuredClone(BLOG_DEFAULTS),...object(document.content)}; content.seo_title=content.seo_title||content.title; content.meta_description=content.meta_description||content.excerpt; content.publish_date=content.publish_date||document._createdAt||new Date().toISOString(); return {...document,content}; } /** Draft wins only within one ID; ambiguous URLs fail instead of selecting arbitrary content. */ export function logicalBlogs(documents: ObjectValue[]): ObjectValue[] { const byId=new Map<string,ObjectValue>(); for(const doc of documents){ const id=String(doc._id);const base=baseDocumentId(id); if(doc._type!=='pilotBlog'||!isContentDocumentId('blog',base))continue; if(!byId.has(base)||id.startsWith('drafts.'))byId.set(base,doc); } return [...byId.values()]; } export function selectBlog(documents: ObjectValue[], slug: string): ObjectValue|null { const matching=logicalBlogs(documents).filter(doc=>doc.slug===slug); if(matching.length>1)throw new BlogAuthoringError('slug','这个 URL 名称被多篇草稿使用,请回到 Studio 修改后重试'); return matching[0]??null; } ---src/lib/cms/sanity/bridge.ts import {isNativeContentId,documentBaseId,contentPath} from './content-identity'; /** * Pilot compatibility boundary: native Sanity fields/Portable Text -> existing * page contracts. This module performs no IO and never calls Storyblok. * Unknown rich-text shapes fail explicitly rather than losing migrated copy. */ import type { StoryblokRichTextNode, StoryblokStory } from "../storyblok/types"; import { object, objects, string, type Value, type ObjectValue } from './values'; export { object, objects, string, type Value, type ObjectValue } from './values'; export type PilotKind = "blog" | "tools" | "questions" | "knowledge" | "use-cases" | "bypass"; export const PILOT_TYPES: Record<PilotKind, string> = { blog: "pilotBlog", tools: "pilotTool", "use-cases":"pilotUseCase", bypass:"pilotBypass", questions: "pilotQuestion", knowledge: "pilotKnowledge", }; export function pilotId(kind: PilotKind, slug: string): string { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error("页面名称只允许小写字母、数字和连字符"); return `verla-pilot.${kind}.${slug}`; } function key(index: number) { return `item${index}`; } /** Converts the allowlisted Storyblok rich text to editable Portable Text. */ export function toPortableText(nodes: StoryblokRichTextNode[]): ObjectValue[] { return nodes.flatMap((node, index): ObjectValue[] => { const _key = node.type === "heading" && node.attrs?.id ? `heading-${node.attrs.id}` : key(index); if (node.type === "paragraph" || node.type === "heading") { const markDefs: ObjectValue[] = []; const children = (node.content ?? []).map((child, childIndex): ObjectValue => { if (child.type === "image") return {...toPortableText([child])[0],_key:key(childIndex)}; if (child.type === "hard_break") return { _type: "span", _key: key(childIndex), text: "\n", marks: [] }; if (child.type !== "text") throw new Error(`不支持的段落内容:${child.type}`); const marks = (child.marks ?? []).map((mark) => { const simple: Record<string, string> = { bold: "strong", italic: "em", strike: "strike-through", underline: "underline", code: "code" }; if (simple[mark.type]) return simple[mark.type]; if (mark.type !== "link" && mark.type !== "anchor") throw new Error(`不支持的文字标记:${mark.type}`); const markKey = `mark${markDefs.length}`; markDefs.push({ _type: mark.type, _key: markKey, ...object(mark.attrs) }); return markKey; }); return { _type: "span", _key: key(childIndex), text: child.text ?? "", marks }; }); return [{ _type: "block", _key, style: node.type === "heading" ? `h${node.attrs?.level}` : "normal", markDefs, children }]; } if (node.type === "image") return [{ _type: "pilotImage", _key, sourceUrl: String(node.attrs?.src ?? ""), alt: String(node.attrs?.alt ?? "") }]; if (node.type === "horizontal_rule") return [{ _type: "pilotDivider", _key }]; if (node.type === "blok") return objects(node.attrs?.body).map((block, i) => ({ ...toSanityFields(block), _key: `${_key}-${i}` })); const richTypes: Record<string, string> = { blockquote: "pilotQuote", bullet_list: "pilotBulletList", ordered_list: "pilotOrderedList", list_item: "pilotListItem", table: "pilotTable", tableRow: "pilotTableRow", tableCell: "pilotTableCell", tableHeader: "pilotTableHeader" }; if (!richTypes[node.type]) throw new Error(`尚未支持迁移的正文节点:${node.type}`); return [{ _type: richTypes[node.type], _key, content: toPortableText(node.content ?? []), ...(node.attrs ? { attributes: JSON.stringify(node.attrs) } : {}) }]; }); } /** Restores template-compatible rich text; unsafe marks/URLs are validated by the page adapter. */ export function fromPortableText(value: unknown): StoryblokRichTextNode[] { const output: StoryblokRichTextNode[] = []; const stack: {level:number; kind:string; list:StoryblokRichTextNode}[] = []; for (const node of objects(value)) { if (node._type === 'block' && node.listItem !== undefined) { const kind = string(node.listItem); const level = node.level ?? 1; if (!['bullet','number'].includes(kind) || typeof level !== 'number' || !Number.isInteger(level) || level < 1) throw new Error('列表类型或层级无效'); while (stack.length && (stack[stack.length-1].level > level || (stack[stack.length-1].level === level && stack[stack.length-1].kind !== kind))) stack.pop(); if (!stack.length || stack[stack.length-1].level < level) { const list: StoryblokRichTextNode = {type:kind === 'bullet'?'bullet_list':'ordered_list',content:[]}; const parent = stack[stack.length-1]?.list.content?.at(-1); if (parent) parent.content!.push(list); else output.push(list); stack.push({level,kind,list}); } const plain = {...node}; delete plain.listItem; delete plain.level; stack[stack.length-1].list.content!.push({type:'list_item',content:fromPortableText([plain])}); } else { stack.length = 0; output.push(fromPortableBlock(node)); } } return output; } /** A native list run is grouped above; legacy blocks remain recursively compatible. */ function fromPortableBlock(node: ObjectValue): StoryblokRichTextNode { const type = string(node._type); if (type === "block") { const style = string(node.style ?? "normal"); if (!["normal", "h2", "h3", "h4"].includes(style)) throw new Error("正文只能使用 H2、H3、H4 或普通段落"); const definitions = objects(node.markDefs ?? []); return { type: style === "normal" ? "paragraph" : "heading", ...(style !== "normal" ? { attrs: { level: Number(style.slice(1)), ...(typeof node._key === "string" && node._key.startsWith("heading-") ? { id: node._key.slice(8) } : {}) } } : {}), content: objects(node.children).flatMap((span): StoryblokRichTextNode[] => { if (span._type === "pilotImage") return fromPortableText([span]); if (span._type !== "span") throw new Error("不支持的内联内容"); const marks = (span.marks as string[] ?? []).map((mark) => { const simple: Record<string, string> = { strong: "bold", em: "italic", "strike-through": "strike", underline: "underline", code: "code" }; if (simple[mark]) return { type: simple[mark] }; const definition = definitions.find((entry) => entry._key === mark); if (!definition || !["link", "anchor"].includes(String(definition._type))) throw new Error("文字链接或锚点无效"); const attrs = Object.fromEntries(Object.entries(definition).filter(([key]) => !["_type", "_key"].includes(key))); return { type: string(definition._type), attrs }; }); return string(span.text).split("\n").flatMap((text, i) => [...(i ? [{ type: "hard_break" }] : []), { type: "text", text, marks }]); }), }; } if (type === 'table') { const rows = objects(node.rows); const headerRows = node.headerRows ?? 0; if (!rows.length || typeof headerRows !== 'number' || !Number.isInteger(headerRows) || headerRows < 0 || headerRows > rows.length) throw new Error('表格行或表头设置无效'); const width = objects(rows[0].cells).length; if (!width) throw new Error('表格至少需要一列'); return {type:'table',content:rows.map((row,index)=>{ const cells = objects(row.cells); if (row._type !== 'row' || cells.length !== width) throw new Error('表格各行的列数必须一致'); return {type:'tableRow',content:cells.map(cell=>{ if (cell._type !== 'cell') throw new Error('表格单元格格式无效'); return {type:index < headerRows?'tableHeader':'tableCell',content:fromPortableText(cell.value ?? [])}; })}; })}; } if ((type === "pilotImage" || type === "pilotBodyImage")) return { type: "image", attrs: { src: imageUrl(node), alt: node.decorative === true ? "" : node.alt ?? "", ...(node.decorative === true ? { decorative: true } : {}) } }; if (type === "pilotDivider") return { type: "horizontal_rule" }; if (type.startsWith("sb_")) return { type: "blok", attrs: { body: [fromSanityFields(node)] } }; const types: Record<string, string> = { pilotQuote: "blockquote", pilotBulletList: "bullet_list", pilotOrderedList: "ordered_list", pilotListItem: "list_item", pilotTable: "table", pilotTableRow: "tableRow", pilotTableCell: "tableCell", pilotTableHeader: "tableHeader" }; if (!types[type]) throw new Error(`不支持的正文组件:${type}`); return { type: types[type], content: fromPortableText(node.content), ...(node.attributes ? { attrs: object(JSON.parse(string(node.attributes))) } : {}) }; } function imageUrl(node: ObjectValue): string { if (node.asset) { const asset = object(node.asset); if (typeof asset.url === "string") return asset.url; // The client resolves asset references in one query before conversion. throw new Error("图片尚未解析,请检查素材是否已经上传"); } return string(node.sourceUrl ?? ""); } export function toSanityFields(input: ObjectValue): ObjectValue { const result: ObjectValue = {}; for (const [name, value] of Object.entries(input)) { if (name === "_editable") continue; if (name === "_uid") { result._key = value; continue; } if (name === "component") { result._type = `sb_${value}`; continue; } if (value && typeof value === "object" && !Array.isArray(value)) { if (value.type === "doc") result[name] = toPortableText(value.content as unknown as StoryblokRichTextNode[]); else if (value.fieldtype === "asset") result[name] = { _type: "pilotImage", sourceUrl: value.filename ?? "", alt: value.alt ?? "" }; else result[name] = toSanityFields(value); } else if (Array.isArray(value)) result[name] = value.map((entry, i) => typeof entry === "object" && entry !== null && !Array.isArray(entry) ? { ...toSanityFields(entry), _key: entry._uid ?? key(i) } : entry); else result[name] = value; } return result; } export function fromSanityFields(input: ObjectValue): ObjectValue { const result: ObjectValue = {}; for (const [name, value] of Object.entries(input)) { if (name === "_key") { result._uid = value; continue; } if (name === "_type") { if (typeof value === "string" && value.startsWith("sb_")) result.component = value.slice(3); continue; } if (name === "body" && Array.isArray(value)) result[name] = { type: "doc", content: fromPortableText(value) as unknown as Value }; else if (Array.isArray(value)) result[name] = value.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? fromSanityFields(entry) : entry); else if (value && typeof value === "object") { if (value._type === "pilotImage") result[name] = { fieldtype: "asset", filename: imageUrl(value), alt: value.alt ?? "" }; else result[name] = fromSanityFields(value); } else result[name] = value; } return result; } /** Creates an in-memory legacy envelope only to reuse the existing validated page adapters. */ export function toTemplateStory(document: ObjectValue, kind: "blog" | "tools" | "use-cases" | "bypass"): StoryblokStory { const slug = string(document.slug); pilotId(kind, slug); const content=fromSanityFields(object(document.content)); if(kind!=="blog"&&isNativeContentId(documentBaseId(string(document._id)))){content.page_id=slug;content.canonical_path=contentPath(kind,slug);} return { id: 0, uuid: string(document._id), name: string(document.title??""), slug, full_slug: `${kind}/${slug}`, content }; } ---src/lib/cms/sanity/client.ts import {CONTENT_TYPES,isNativeContentId,isContentKind,isContentDocumentId,documentBaseId,selectContentDocument,contentPath,type ContentKind} from './content-identity'; import {PUBLIC_SEO_ROUTES} from '@/lib/seo/public-seo-routes'; import {getBlogContentSource} from '../blog-source'; import {getToolContentSource} from '../tool-source'; import {getLandingContentSource} from '../landing-source'; import "server-only"; /** Read-only Sanity draft access for registered content identities; errors never serialize upstream payloads. */ import { resolveSanityImages } from "./publication"; import { cache } from "react"; import { headers, cookies } from "next/headers"; import {PREVIEW_COOKIE,PREVIEW_HEADER,validateStudioGrant} from "./preview-access"; import { getStoryblokPublishedStories } from "../storyblok/client"; import { getStoryblokRuntimeConfig } from "../storyblok/config"; import {validBlogSlug} from "./authoring"; import { notFound } from "next/navigation"; import { object, objects, pilotId, PILOT_TYPES, type ObjectValue, type PilotKind } from "./bridge"; export const SANITY_PROJECT = "j10sd6fh"; export const SANITY_DATASET = "production"; export const requireSanityPreview=cache(async()=>{ const secret=(await headers()).get(PREVIEW_HEADER)??(await cookies()).get(PREVIEW_COOKIE)?.value; if(!await validateStudioGrant(secret))notFound(); }); /** All queries remain scoped to the fixed project and caller-provided safe query parameters. */ export async function queryPilot(query: string, params: Record<string,string> = {}): Promise<ObjectValue[]> { await requireSanityPreview(); const token=process.env.SANITY_API_READ_TOKEN; if(!token)throw new Error("Sanity 只读凭据尚未配置"); const url=new URL(`https://${SANITY_PROJECT}.api.sanity.io/v2025-02-19/data/query/${SANITY_DATASET}`); url.searchParams.set("query",query);url.searchParams.set("perspective","raw"); for(const [key,value] of Object.entries(params))url.searchParams.set(`$${key}`,JSON.stringify(value)); let response:Response; try{response=await fetch(url,{headers:{Authorization:`Bearer ${token}`},cache:"no-store",signal:AbortSignal.timeout(8000)});} catch{throw new Error("暂时无法连接 Sanity,请稍后重试");} if(!response.ok)throw new Error(`Sanity 内容读取失败(${response.status}),请检查项目权限`); return objects(object(await response.json()).result).map(doc=>object(resolveSanityImages(doc))); } /** Checks saved Sanity versions, reserved code routes and the active legacy source without leaking records. */ export async function assertContentUrl(document:ObjectValue,kind:ContentKind,knownDocuments?:ObjectValue[]) { const id=documentBaseId(String(document._id));const slug=document.slug; if(!isContentDocumentId(kind,id)||!validBlogSlug(slug))throw new Error('请填写合法的 URL 名称'); const docs=knownDocuments??await queryPilot('*[_type == $type]',{type:CONTENT_TYPES[kind]}); if(docs.some(d=>typeof d._id==='string'&&isContentDocumentId(kind,documentBaseId(d._id))&&documentBaseId(d._id)!==id&&d.slug===slug))throw new Error('这个 URL 已被其他内容使用'); const published=docs.find(d=>d._id===id); if(published&&published.slug!==slug)throw new Error('已发布页面不能直接修改 URL'); if(id.startsWith(`verla-pilot.${kind}.`)&&!id.includes(`.${kind}.new-`)&&id!==`verla-pilot.${kind}.${slug}`)throw new Error('迁移页面不能修改 URL'); const urlId=`verla-url.${kind}.${slug}`; const reservations=await queryPilot('*[_id == $urlId || (_type == "verlaUrlReservation" && documentId == $id)]',{urlId,id}); if(reservations.some(d=>d.documentId===id&&d._id!==urlId))throw new Error('曾发布过的页面不能直接修改 URL'); if(reservations.some(d=>d._id===urlId&&d.documentId!==id))throw new Error('这个 URL 已被使用,请选择新地址'); const urlReservation=isNativeContentId(id)&&!reservations.some(d=>d._id===urlId)?{_id:urlId,_type:'verlaUrlReservation',documentId:id}:null; if(published||id===`verla-pilot.${kind}.${slug}`)return {urlReservation}; if(PUBLIC_SEO_ROUTES.some(route=>route.path===contentPath(kind,slug)))throw new Error('这个 URL 是已有网站地址'); const source=kind==='blog'?getBlogContentSource():kind==='tools'?getToolContentSource():getLandingContentSource(kind); if(source==='storyblok'&&getStoryblokRuntimeConfig().publicContentEnabled){ const legacy=await getStoryblokPublishedStories(kind==='tools'?'tool':kind==='use-cases'?'use-case':kind); if(!legacy)throw new Error('无法检查旧站地址,请稍后重试'); if(legacy.some(doc=>doc.slug===slug))throw new Error('这个 URL 已被旧站内容使用'); } return {urlReservation}; } export const assertBlogUrl=(document:ObjectValue)=>assertContentUrl(document,'blog'); export const readPilotDocument = cache(async (kind: PilotKind, slug: string): Promise<ObjectValue|null> => { pilotId(kind,slug); if(isContentKind(kind)) { const docs=await queryPilot('*[_type == $type]',{type:CONTENT_TYPES[kind]}); const document=selectContentDocument(kind,docs,slug); if(document)await assertContentUrl(document,kind,docs); return document; } const docs=await queryPilot('*[_type == $type && slug == $slug]',{slug,type:PILOT_TYPES[kind]}); const ids=new Set(docs.map(d=>String(d._id).replace(/^drafts\./,''))); if(ids.size>1)throw new Error('Question / Knowledge URL 重复'); return docs.find(d=>String(d._id).startsWith('drafts.'))??docs[0]??null; }); ---src/lib/cms/sanity/content-identity.ts /** Shared identity boundary for native Studio UUIDs and unchanged migration documents. */ export const CONTENT_TYPES = {blog:'pilotBlog',tools:'pilotTool','use-cases':'pilotUseCase',bypass:'pilotBypass'} as const; export type ContentKind = keyof typeof CONTENT_TYPES; export const isContentKind = (value:unknown):value is ContentKind => typeof value==='string'&&Object.hasOwn(CONTENT_TYPES,value); export const documentBaseId = (id:string)=>id.replace(/^drafts\./,''); export const isNativeContentId = (id:unknown):boolean => typeof id==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id); export function isContentDocumentId(kind:ContentKind,id:unknown):id is string { return typeof id==='string'&&(isNativeContentId(id)||(id.startsWith(`verla-pilot.${kind}.`)&&/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id.slice(`verla-pilot.${kind}.`.length)))); } export const contentPath=(kind:ContentKind,slug:string)=>`/${kind==='use-cases'?'study-tools':kind}/${slug}`; export const reservationId=(kind:ContentKind,slug:string)=>`verla-url.${kind}.${slug}`; import type {ObjectValue} from './values'; /** A saved draft replaces only its own published version, including a renamed draft URL. */ export function selectContentDocument(kind:ContentKind,documents:ObjectValue[],slug:string):ObjectValue|null { const logical=new Map<string,ObjectValue>(); for(const doc of documents){ if(typeof doc._id!=='string'||doc._type!==CONTENT_TYPES[kind])continue; const id=documentBaseId(doc._id);if(!isContentDocumentId(kind,id))continue; if(!logical.has(id)||doc._id.startsWith('drafts.'))logical.set(id,doc); } const matches=[...logical.values()].filter(doc=>doc.slug===slug); if(matches.length>1)throw new Error('这个 URL 已被多篇内容使用,请修改后重试'); return matches[0]??null; } ---src/lib/cms/sanity/landing-publication.ts import {isNativeContentId,documentBaseId} from './content-identity'; /** Pure identity and adapter boundary for Use Case and Bypass published snapshots. */ import {adaptStoryblokPublicUseCaseStory,adaptStoryblokPublicBypassStory} from '../storyblok/adapters'; import type {LandingKind} from '../landing-source'; import {pilotId,PILOT_TYPES,toTemplateStory} from './bridge'; import {resolveSanityImages,withoutResolvedAssetUrls} from './publication'; import {object,type ObjectValue} from './values'; function stories(documents:ObjectValue[],kind:LandingKind) { return documents.map(doc=>{ if(doc._type!==PILOT_TYPES[kind] || typeof doc.slug!=='string' || (!isNativeContentId(doc._id)&&doc._id!==pilotId(kind,doc.slug))) throw new Error('落地页发布身份无效或返回了草稿'); return toTemplateStory(object(resolveSanityImages(doc)),kind); }); } function unique<T extends {canonicalPath:string}>(pages:T[]):T[] { const seen=new Set<string>(); for(const page of pages){if(seen.has(page.canonicalPath))throw new Error('落地页正式地址重复');seen.add(page.canonicalPath);} return pages.sort((a,b)=>a.canonicalPath.localeCompare(b.canonicalPath)); } export function publishedUseCasePages(documents:ObjectValue[]) { return unique(stories(documents,'use-cases').map(story=>({story,...adaptStoryblokPublicUseCaseStory(story)}))); } export function publishedBypassPages(documents:ObjectValue[]) { return unique(stories(documents,'bypass').map(story=>({story,...adaptStoryblokPublicBypassStory(story)}))); } /** A revision-guarded transport commits this candidate only after checking both current versions. */ export function prepareLandingPublication(kind:LandingKind,draft:ObjectValue,published:ObjectValue|null):ObjectValue { if(typeof draft.slug!=='string' || (typeof draft._id!=='string'||!draft._id.startsWith('drafts.')||(!isNativeContentId(documentBaseId(draft._id))&&draft._id!==`drafts.${pilotId(kind,draft.slug)}`)))throw new Error('请选择地址未改变的落地页草稿'); const next=object(withoutResolvedAssetUrls(structuredClone(draft)));next._id=String(draft._id).slice(7); if(published&&(published._id!==next._id||published.slug!==next.slug))throw new Error('已发布落地页不能修改 URL'); delete next._rev;delete next._createdAt;delete next._updatedAt; if(kind==='use-cases')publishedUseCasePages([next]);else publishedBypassPages([next]); return next; } ---src/lib/cms/sanity/migration.ts /** Replaces only image fields in the reviewed migration document; no IO or credential access. */ import {type ObjectValue,type Value} from "./values"; export function replaceMigrationAssets(value:Value,mapping:Map<string,ObjectValue>):Value{ if(Array.isArray(value))return value.map(v=>replaceMigrationAssets(v,mapping)); if(!value||typeof value!=='object')return value; const next:ObjectValue=Object.fromEntries(Object.entries(value).map(([k,v])=>[k,replaceMigrationAssets(v,mapping)])); if(next._type==='pilotImage'&&typeof next.sourceUrl==='string'&&next.sourceUrl){const ref=mapping.get(next.sourceUrl);if(!ref)throw new Error('缺少图片映射');next.asset=ref;delete next.sourceUrl;} if(!next.cover_image_asset&&typeof next.cover_image_path==='string'){ const asset=mapping.get(next.cover_image_path);if(!asset)throw new Error('缺少封面图片映射'); next.cover_image_asset={_type:'pilotImage',asset,alt:typeof next.title==='string'?next.title:''}; } if(next.cover_image_asset)delete next.cover_image_path; return next; } ---src/lib/cms/sanity/preview-access.ts /** Short-lived Studio grants, compatible with @sanity/preview-url-secret 4.1.5. * Read-only REST validation deliberately accepts only expiring draft secrets, never share-access. * The secret is checked on each request; cookies alone and website login cannot grant access. */ export const PREVIEW_COOKIE='verla_sanity_preview'; export const PREVIEW_HEADER='x-sanity-preview-secret'; export const PREVIEW_TTL=3600; export function studioOrigins(env:NodeJS.ProcessEnv=process.env):string[]{ return (env.SANITY_STUDIO_ORIGINS??(env.NODE_ENV==='production'?'':'http://127.0.0.1:3333,http://localhost:3333')).split(',').map(s=>s.trim()).filter(s=>{try{const u=new URL(s);return !s.includes('*')&&u.origin===s&&(u.protocol==='https:'||(env.NODE_ENV!=='production'&&u.protocol==='http:'&&['127.0.0.1','localhost'].includes(u.hostname)));}catch{return false;}}); } export function previewPath(value:unknown):string|null{ if(typeof value!=='string'||!/^\/sanity-pilot\/(blog|tools|questions|knowledge|use-cases|bypass)\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))return null; return value; } export async function validateStudioGrant(secret:string|undefined,env:NodeJS.ProcessEnv=process.env,fetcher:typeof fetch=fetch):Promise<boolean>{ if(env.SANITY_PILOT_ENABLED!=='true'||!secret||!/^[A-Za-z0-9_-]{16,256}$/.test(secret)||!env.SANITY_API_READ_TOKEN)return false; // POST keeps preview grants out of upstream URL/access logs. API token remains server-side. const query='*[_type == "sanity.previewUrlSecret" && _id in path("drafts.sanity-preview-url-secret.**") && secret == $secret && dateTime(_updatedAt) > dateTime(now()) - 3600][0]{_id,_type,_updatedAt,studioUrl}'; try{ const response=await fetcher('https://j10sd6fh.api.sanity.io/v2025-02-19/data/query/production?perspective=raw',{method:'POST',headers:{Authorization:`Bearer ${env.SANITY_API_READ_TOKEN}`,'Content-Type':'application/json'},body:JSON.stringify({query,params:{secret}}),cache:'no-store',signal:AbortSignal.timeout(8000)}); if(!response.ok)return false; const {result}=await response.json(); if(!result||result._type!=='sanity.previewUrlSecret'||!String(result._id).startsWith('drafts.sanity-preview-url-secret.'))return false; const age=Date.now()-Date.parse(result._updatedAt);if(!Number.isFinite(age)||age<0||age>=PREVIEW_TTL*1000)return false; return studioOrigins(env).includes(new URL(result.studioUrl).origin); }catch{return false;} } ---src/lib/cms/sanity/preview-metadata.ts import type { Metadata } from "next"; import { buildKnowledgeDetailMetadata } from "@/features/landing/knowledge/knowledge-detail-seo"; import { buildQuestionDetailMetadata } from "@/features/landing/questions/question-detail-seo"; import { adaptStoryblokPublicBlogStory, adaptStoryblokPublicBypassStory, adaptStoryblokPublicToolStory, adaptStoryblokPublicUseCaseStory } from "@/lib/cms/storyblok/adapters"; import { CMS_PREVIEW_ROBOTS } from "@/lib/seo/cms-locale-policy"; import { buildPublicPageMetadata } from "@/lib/seo/metadata"; import { normalizedBlog } from "./authoring"; import { toTemplateStory, type ObjectValue } from "./bridge"; import type { ContentKind } from "./content-identity"; import type { StudyPair } from "./study-package"; export const SANITY_PREVIEW_FALLBACK_METADATA: Metadata = { title: "Sanity 草稿预览", robots: CMS_PREVIEW_ROBOTS, }; function preview(metadata: Metadata): Metadata { return { ...metadata, robots: CMS_PREVIEW_ROBOTS }; } /** Builds final public SEO fields for a Sanity content draft while keeping it non-indexable. */ export function buildSanityContentPreviewMetadata( kind: ContentKind, document: ObjectValue, ): Metadata { if (kind === "blog") { const page = adaptStoryblokPublicBlogStory( toTemplateStory(normalizedBlog(document), "blog"), { allowDecorativeImages: true, relaxedEditorialRules: true }, ); return preview( buildPublicPageMetadata({ description: page.metadataDescription, path: page.canonicalPath, title: page.metadataTitle, }), ); } if (kind === "tools") { const { spec } = adaptStoryblokPublicToolStory( toTemplateStory(document, "tools"), ); return preview( buildPublicPageMetadata({ description: spec.metadata.description, path: spec.slug, title: spec.metadata.title, }), ); } if (kind === "use-cases") { const { spec } = adaptStoryblokPublicUseCaseStory( toTemplateStory(document, "use-cases"), ); return preview( buildPublicPageMetadata({ description: spec.metadata.description, path: spec.canonicalPath, title: spec.metadata.title, }), ); } const { spec } = adaptStoryblokPublicBypassStory( toTemplateStory(document, "bypass"), ); return preview( buildPublicPageMetadata({ description: spec.seo.description, path: spec.canonicalPath, title: spec.seo.title, }), ); } /** Uses the same metadata builders and future canonical as the formal Question/Knowledge route. */ export function buildSanityStudyPreviewMetadata( kind: "questions" | "knowledge", pair: StudyPair, ): Metadata { return preview( kind === "questions" ? buildQuestionDetailMetadata(pair.question) : buildKnowledgeDetailMetadata(pair.knowledge), ); } ---src/lib/cms/sanity/publication.ts import {isContentDocumentId,documentBaseId} from './content-identity'; /** Pure published-content boundary. Draft timestamps and draft versions never become public facts. */ import { adaptStoryblokPublicBlogStory } from '../storyblok/adapters'; import { toTemplateStory } from './bridge'; import { blogIssues, normalizedBlog } from './authoring'; import { object, type ObjectValue, type Value } from './values'; export function resolveSanityImages(value: Value): Value { if (Array.isArray(value)) return value.map(resolveSanityImages); if (!value || typeof value !== 'object') return value; if (typeof value._ref === 'string' && value._ref.startsWith('image-')) { const match = /^image-([a-f0-9]+)-(\d+x\d+)-([a-z0-9]+)$/.exec(value._ref); if (!match) throw new Error('图片引用格式不正确'); return {...value,url:`https://cdn.sanity.io/images/j10sd6fh/production/${match[1]}-${match[2]}.${match[3]}`}; } return Object.fromEntries(Object.entries(value).map(([key, child])=>[key,resolveSanityImages(child)])); } function timestamp(value: unknown, field: string): string { if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(value)||!Number.isFinite(Date.parse(value)))throw new Error(`${field} 必须为有效日期`); if(new Date(`${value.slice(0,10)}T00:00:00Z`).toISOString().slice(0,10)!==value.slice(0,10))throw new Error(`${field} 日历日期无效`); return new Date(value).toISOString(); } /** Validates the entire published collection before any consumer selects an article. */ export function publishedBlogPages(documents: ObjectValue[]) { const seen=new Set<string>(); return documents.map(document=>{ if(document._type!=='pilotBlog'||typeof document._id!=='string'||!isContentDocumentId('blog',document._id))throw new Error('正式 Blog 查询返回了非发布文档'); const issues=blogIssues(document).filter(issue=>issue.level==='error'); if(issues.length)throw new Error(`Blog 内容不完整:${issues[0].path}`); const publishedAt=timestamp(object(document.content).publish_date,'发布日期'); const page=adaptStoryblokPublicBlogStory(toTemplateStory(normalizedBlog(object(resolveSanityImages(document))),'blog'),{allowDecorativeImages:true,relaxedEditorialRules:true}); if(seen.has(page.canonicalPath))throw new Error('Blog 正式地址重复'); seen.add(page.canonicalPath); const modifiedAt=document.contentModifiedAt==null?undefined:timestamp(document.contentModifiedAt,'内容更新时间'); if(modifiedAt && modifiedAt<publishedAt)throw new Error('内容更新时间不能早于发布日期'); const coverAlt=object(object(document.content).cover_image_asset ?? {}).alt; page.post.coverImageAlt=typeof coverAlt==='string'&&coverAlt.trim()?coverAlt:page.post.title; page.post.publishedAt=publishedAt; page.post.contentModifiedAt=modifiedAt; return page; }).sort((a,b)=>Date.parse(b.post.publishedAt!)-Date.parse(a.post.publishedAt!)||a.canonicalPath.localeCompare(b.canonicalPath)); } export function withoutResolvedAssetUrls(value:Value):Value { if(Array.isArray(value))return value.map(withoutResolvedAssetUrls); if(!value||typeof value!=='object')return value; return Object.fromEntries(Object.entries(value).filter(([key])=>!(key==='url'&&typeof value._ref==='string'&&value._ref.startsWith('image-'))).map(([key,child])=>[key,withoutResolvedAssetUrls(child)])); } const publicFields=['title','excerpt','seo_title','meta_description','body','cover_image_asset','cover_image_path','publish_date','author_name','author_title','author_bio','category','read_time_minutes','faq_intro','faq_items','toc_mode','table_of_contents']; function stableJson(value: Value): string { if(Array.isArray(value))return `[${value.map(stableJson).join(',')}]`; if(value&&typeof value==='object')return `{${Object.keys(value).filter(key=>key!=='_editable').sort().map(key=>`${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; return JSON.stringify(value); } function publicFingerprint(document:ObjectValue) { const content=object(normalizedBlog(object(withoutResolvedAssetUrls(document))).content); return stableJson(Object.fromEntries(publicFields.filter(key=>content[key]!==undefined).map(key=>[key,content[key]]))); } /** Builds a validated publish snapshot; transport commits it with both source revision guards. */ export function prepareBlogPublication(draft:ObjectValue,published:ObjectValue|null,now:string):ObjectValue { if(typeof draft._id!=='string'||(!draft._id.startsWith('drafts.')||!isContentDocumentId('blog',documentBaseId(draft._id))))throw new Error('请选择 Blog 草稿'); if(published && (published._id!==draft._id.slice(7)||published.slug!==draft.slug))throw new Error('已发布文章不能直接修改 URL'); const next:ObjectValue=object(withoutResolvedAssetUrls(structuredClone(draft))); next._id=draft._id.slice(7); delete next._rev;delete next._createdAt;delete next._updatedAt;delete next.contentModifiedAt; const content=object(next.content); content.publish_date=published?object(published.content).publish_date:(content.publish_date||timestamp(now,'发布时间')); if(!published||publicFingerprint(next)!==publicFingerprint(published))next.contentModifiedAt=timestamp(now,'内容更新时间'); else if(published.contentModifiedAt)next.contentModifiedAt=published.contentModifiedAt; publishedBlogPages([next]); return next; } ---src/lib/cms/sanity/published-client.ts import 'server-only'; import {cache} from 'react'; import {publishedBlogPages} from './publication'; import {type ObjectValue} from './values'; /** Public queries deliberately do not import the preview guard or request draft perspective. */ export const getSanityPublishedBlogs=cache(async()=>{ const token=process.env.SANITY_API_READ_TOKEN; if(!token)throw new Error('Sanity 正式 Blog 只读凭据未配置'); const url=new URL('https://j10sd6fh.api.sanity.io/v2025-02-19/data/query/production'); url.searchParams.set('perspective','published'); url.searchParams.set('query','*[_type == "pilotBlog"]'); let response:Response; try{response=await fetch(url,{headers:{Authorization:`Bearer ${token}`},cache:'no-store',signal:AbortSignal.timeout(10000)});}catch{throw new Error('Sanity 正式 Blog 暂时不可用');} if(!response.ok)throw new Error(`Sanity 正式 Blog 读取失败 (${response.status})`); const payload=await response.json() as {result?:unknown}; if(!Array.isArray(payload.result)||payload.result.some(doc=>!doc||typeof doc!=='object'||Array.isArray(doc)))throw new Error('Sanity 正式 Blog 响应不完整'); return publishedBlogPages(payload.result as ObjectValue[]); }); ---src/lib/cms/sanity/published-landing-client.ts import 'server-only'; import {cache} from 'react'; import {publishedUseCasePages,publishedBypassPages} from './landing-publication'; import {PILOT_TYPES} from './bridge'; import type {LandingKind} from '../landing-source'; import type {ObjectValue} from './values'; /** Fixed kind/type/ID published queries; only validated collections reach any public consumer. */ async function query(kind:LandingKind):Promise<ObjectValue[]> { const token=process.env.SANITY_API_READ_TOKEN;if(!token)throw new Error('Sanity 落地页只读凭据未配置'); const url=new URL('https://j10sd6fh.api.sanity.io/v2025-02-19/data/query/production'); url.searchParams.set('perspective','published'); url.searchParams.set('query',`*[_type == "${PILOT_TYPES[kind]}"]`); let response:Response; try{response=await fetch(url,{headers:{Authorization:`Bearer ${token}`},cache:'no-store',signal:AbortSignal.timeout(10000)});}catch{throw new Error('Sanity 落地页暂时不可用');} if(!response.ok)throw new Error(`Sanity 落地页读取失败 (${response.status})`); const payload=await response.json() as {result?:unknown}; if(!Array.isArray(payload.result)||payload.result.some(d=>!d||typeof d!=='object'||Array.isArray(d)))throw new Error('Sanity 落地页响应不完整'); return payload.result as ObjectValue[]; } export const getSanityPublishedUseCases=cache(async()=>publishedUseCasePages(await query('use-cases'))); export const getSanityPublishedBypassPages=cache(async()=>publishedBypassPages(await query('bypass'))); ---src/lib/cms/sanity/published-tool-client.ts import 'server-only'; import {cache} from 'react'; import {publishedToolPages} from './tool-publication'; import type {ObjectValue} from './values'; /** Published-only collection shared by detail, catalog, navigation and sitemap; no fallback on failure. */ export const getSanityPublishedTools = cache(async () => { const token = process.env.SANITY_API_READ_TOKEN; if (!token) throw new Error('Sanity 正式 Tool 只读凭据未配置'); const url = new URL('https://j10sd6fh.api.sanity.io/v2025-02-19/data/query/production'); url.searchParams.set('perspective', 'published'); url.searchParams.set('query', '*[_type == "pilotTool"]'); let response: Response; try { response = await fetch(url, {headers:{Authorization:`Bearer ${token}`}, cache:'no-store', signal:AbortSignal.timeout(10000)}); } catch { throw new Error('Sanity 正式 Tool 暂时不可用'); } if (!response.ok) throw new Error(`Sanity 正式 Tool 读取失败 (${response.status})`); const payload = await response.json() as {result?:unknown}; if (!Array.isArray(payload.result) || payload.result.some(doc => !doc || typeof doc !== 'object' || Array.isArray(doc))) throw new Error('Sanity 正式 Tool 响应不完整'); return publishedToolPages(payload.result as ObjectValue[]); }); ---src/lib/cms/sanity/study-content.ts /** Validates CMS study-page inputs before passing them to the existing client templates. */ import type { PublicQuestionEntry } from "@/features/landing/questions/content"; import type { PublicKnowledgeEntry } from "@/features/landing/knowledge/content"; import { safeHref, text } from "../storyblok/validation"; import { fromSanityFields, object, objects, pilotId, string, type ObjectValue } from "./bridge"; function common(document: ObjectValue, kind: "questions" | "knowledge") { const value = fromSanityFields(object(document.content)); value.slug = string(document.slug); pilotId(kind, value.slug); for (const name of ["title", "summary", "subjectSlug", "topicSlug"]) text(value[name], `content.${name}`); const metadata = object(value.metadata); text(metadata.title, "content.metadata.title"); text(metadata.description, "content.metadata.description"); objects(value.sources).forEach((source, index) => { text(source.title, `content.sources.${index}.title`); safeHref(source.url, `content.sources.${index}.url`); }); return value; } export function adaptPilotQuestion(document: ObjectValue): PublicQuestionEntry { const value = common(document, "questions"); text(value.prompt, "content.prompt"); text(value.answerMarkdown, "content.answerMarkdown"); if (!Array.isArray(value.relatedQuestions) || !Array.isArray(value.relatedQuestionSlugs) || value.relatedQuestions.length !== value.relatedQuestionSlugs.length) throw new Error("相关问题标题与路径数量必须一致"); value.relatedQuestions.forEach((entry) => string(entry)); value.relatedQuestionSlugs = value.relatedQuestionSlugs.map(entry => entry === "" ? null : entry); value.relatedQuestionSlugs.forEach((entry) => { if (entry !== null) pilotId("questions", string(entry)); }); // Rich prompt blocks remain repository-only until their Studio model is supported. if (value.promptBlocks) throw new Error("此试点尚不支持题目视觉块,请选择纯文字题目"); return value as unknown as PublicQuestionEntry; } export function adaptPilotKnowledge(document: ObjectValue, questionSlug: string): PublicKnowledgeEntry { const value = common(document, "knowledge"); text(value.bodyMarkdown, "content.bodyMarkdown"); if (!Array.isArray(value.relatedKnowledgeSlugs)) throw new Error("相关知识必须为列表"); value.relatedKnowledgeSlugs.forEach((entry) => pilotId("knowledge", string(entry))); value.questionSlug = questionSlug; return value as unknown as PublicKnowledgeEntry; } ---src/lib/cms/sanity/study-import.ts import { decodeStudy } from "./study-package"; import type { ObjectValue } from "./values"; /** Compare JSON values independent of API property order; array order remains meaningful. */ export function canonicalStudyJson(value: unknown): string { function normalize(v: unknown): unknown { return Array.isArray(v) ? v.map(normalize) : v && typeof v === "object" ? Object.fromEntries( Object.entries(v) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => [k, normalize(v)]), ) : v; } return JSON.stringify(normalize(value)); } export function planStudyDrafts( desired: ObjectValue[], current: ObjectValue[], ) { return desired.map((d) => { const id = String(d._id).replace(/^drafts\./, ""); const matches = current.filter( (c) => (c._type === d._type && c.slug === d.slug) || String(c._id).replace(/^drafts\./, "") === id, ); if (!matches.length) return { id: d._id, action: "create" as const }; const comparable = (v: ObjectValue) => Object.fromEntries( Object.entries(v) .filter(([k]) => !["_rev", "_createdAt", "_updatedAt"].includes(k)) .map(([k, v]) => [ k, ["content", "supportResources", "provenance"].includes(k) ? decodeStudy(v) : v, ]), ); const identical = matches.every( (c) => String(c._id).replace(/^drafts\./, "") === id && canonicalStudyJson({ ...comparable(c), _id: d._id }) === canonicalStudyJson(comparable(d)), ); return { id: d._id, action: identical ? ("skip" as const) : ("conflict" as const), existing: matches.map((c) => c._id), }; }); } ---src/lib/cms/sanity/study-package.ts /** Portable study contract. Validation is shared by offline import, preview and publication. */ import type { PublicQuestionEntry } from "../../../features/landing/questions/types"; import type { PublicKnowledgeEntry } from "../../../features/landing/knowledge/types"; import { QUESTION_CATEGORIES } from "../../../features/landing/questions/taxonomy"; import type { ObjectValue } from "./values"; export const STUDY_RESOURCE_HEADINGS = [ "landing.questions.detail.resourcesHeading", "landing.questions.detail.mathResourcesHeading", "landing.questions.detail.biologyResourcesHeading", "landing.questions.detail.languageResourcesHeading", "landing.questions.detail.chemistryResourcesHeading", "landing.questions.detail.physicsResourcesHeading", ] as const; export interface StudyResources { headingKey: string; heading?: string; guides: readonly { href: string; label: string }[]; useCase: { href: string; title: string; ctaLabel?: string }; } export interface StudyPair { batchNumber: number; position: number; reviewStatus: "needs-review" | "pending-batch-acceptance" | "accepted"; question: PublicQuestionEntry; knowledge: PublicKnowledgeEntry; supportResources: StudyResources | null; provenance?: { sourceCommit?: string; sourceFiles?: string[]; originalQuestionUrl?: string; }; } export interface StudyPackage { packageVersion: 1; pairs: StudyPair[]; } export const STUDY_TYPES = { questions: "pilotQuestion", knowledge: "pilotKnowledge", } as const; export function studyId(kind: keyof typeof STUDY_TYPES, slug: string) { return `verla-pilot.${kind}.${slug}`; } export class StudyValidationError extends Error { constructor(path: string, message: string) { super(`${path}: ${message}`); this.name = "StudyValidationError"; } } function fail(path: string, message: string): never { throw new StudyValidationError(path, message); } function obj(v: unknown, p: string): Record<string, unknown> { if (!v || typeof v !== "object" || Array.isArray(v)) fail(p, "必须是对象"); return v as Record<string, unknown>; } function str(v: unknown, p: string): string { if (typeof v !== "string" || !v.trim()) fail(p, "不能为空"); return v as string; } function arr(v: unknown, p: string, min = 0): unknown[] { if (!Array.isArray(v) || v.length < min) fail(p, `至少需要 ${min} 项`); return v as unknown[]; } function num(v: unknown, p: string, min = -Infinity): number { if (typeof v !== "number" || !Number.isFinite(v) || v < min) fail(p, "数字超出范围"); return v as number; } function slug(v: unknown, p: string): string { const s = str(v, p); if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)) fail(p, "无效的 URL 名称"); return s; } function link(v: unknown, p: string, internal = false) { const s = str(v, p); if (/[\s\\]/.test(s) || !(internal ? /^\/(?!\/)/ : /^https:\/\//).test(s)) fail(p, "链接格式无效"); if (!internal) { try { const u = new URL(s); if (u.username || u.password) fail(p, "链接不能含凭据"); } catch { fail(p, "HTTPS 地址无效"); } } } function strings(v: unknown, p: string, min = 1) { return arr(v, p, min).map((x, i) => str(x, `${p}[${i}]`)); } function related(v: unknown, p: string, self: string) { const s = strings(v, p, 3); if (s.length !== 3 || new Set(s).size !== 3 || s.includes(self)) fail(p, "需要三个不同且不指向自身的关联"); s.forEach((s, i) => slug(s, `${p}[${i}]`)); return s; } function common(v: Record<string, unknown>, p: string) { for (const key of ["title", "summary", "subjectSlug", "topicSlug"]) str(v[key], `${p}.${key}`); slug(v.slug, `${p}.slug`); const subjects = QUESTION_CATEGORIES.flatMap( (c) => c.subjects as readonly { slug: string; topics: readonly { slug: string }[]; }[], ); if ( !subjects.some( (s) => s.slug === v.subjectSlug && s.topics.some((t) => t.slug === v.topicSlug), ) ) fail(p, "学科/主题组合不在现有分类中"); const m = obj(v.metadata, `${p}.metadata`); str(m.title, `${p}.metadata.title`); str(m.description, `${p}.metadata.description`); arr(v.sources, `${p}.sources`, 1).forEach((x, i) => { const s = obj(x, `${p}.sources[${i}]`); str(s.title, `${p}.sources[${i}].title`); link(s.url, `${p}.sources[${i}].url`); }); } function blocks(value: unknown, p: string, answer: boolean) { if (value === undefined) return; const ids = new Set<string>(); arr(value, p, 1).forEach((value, i) => { const path = `${p}[${i}]`, b = obj(value, path), type = str(b.type, `${path}.type`); if (answer || !["text", "markdown"].includes(type)) { const id = str(b.id, `${path}.id`); if (ids.has(id)) fail(path, "区块 ID 重复"); ids.add(id); } if (type === "markdown" || (!answer && type === "text")) { str(b.content, `${path}.content`); return; } if (!answer && type === "table") { str(b.label, `${path}.label`); let width: number | undefined; arr(b.rows, `${path}.rows`, 1).forEach((v, j) => { const r = obj(v, `${path}.rows[${j}]`); str(r.header, `${path}.rows[${j}].header`); const cells = arr(r.cells, `${path}.rows[${j}].cells`, 1); if (cells.some((c) => typeof c !== "string")) fail(path, "单元格必须是文字"); if (width !== undefined && width !== cells.length) fail(path, "表格列数不一致"); width = cells.length; }); return; } if (!answer && type === "visual-aid") { if (b.variant !== "comparison-grid") fail(path, "不支持的辅助图类型"); str(b.heading, `${path}.heading`); str(b.prompt, `${path}.prompt`); strings(b.columns, `${path}.columns`); arr(b.rows, `${path}.rows`, 1).forEach((v, j) => { const r = obj(v, `${path}.rows[${j}]`); str(r.label, `${path}.rows[${j}].label`); if ( !["answer-line", "magnification", "sketch"].includes(String(r.type)) ) fail(path, "不支持的辅助图行类型"); }); return; } if (answer && type === "coordinate-grid") { str(b.heading, `${path}.heading`); if (b.columns !== undefined && b.columns !== 1 && b.columns !== 2) fail(path, "图形列数必须为 1 或 2"); arr(b.plots, `${path}.plots`, 1).forEach((v, j) => { const pp = `${path}.plots[${j}]`, plot = obj(v, pp); for (const k of ["id", "description", "title"]) str(plot[k], `${pp}.${k}`); for (const k of ["xRange", "yRange"]) { const r = arr(plot[k], `${pp}.${k}`, 2); if (r.length !== 2 || num(r[0], pp) >= num(r[1], pp)) fail(pp, "坐标范围必须递增"); } for (const k of ["gridStep", "labelStep"]) { const step = num(plot[k], `${pp}.${k}`, Number.EPSILON); for (const axis of ["xRange", "yRange"]) { const [lo, hi] = plot[axis] as number[]; if ((hi - lo) / step > 500) fail(pp, "刻度过密(最多 500 格)"); } } arr(plot.figures, `${pp}.figures`, 1).forEach((v, k) => { const fp = `${pp}.figures[${k}]`, f = obj(v, fp); str(f.id, `${fp}.id`); str(f.label, `${fp}.label`); if ( typeof f.closed !== "boolean" || ![ "image", "intermediate-one", "intermediate-two", "preimage", ].includes(String(f.role)) ) fail(fp, "图形属性无效"); arr(f.vertices, `${fp}.vertices`, 1).forEach((v, n) => { const vp = `${fp}.vertices[${n}]`, vertex = obj(v, vp); str(vertex.label, `${vp}.label`); num(vertex.x, `${vp}.x`); num(vertex.y, `${vp}.y`); if (vertex.labelOffset !== undefined) { const offset = obj(vertex.labelOffset, `${vp}.labelOffset`); num(offset.x, `${vp}.labelOffset.x`); num(offset.y, `${vp}.labelOffset.y`); } }); }); }); return; } fail(`${path}.type`, "不支持的区块类型"); }); } /** Validates shape independently of collection closure so a batch can reference already-published content. */ export function validateStudyPair(input: unknown, path = "pair"): StudyPair { const pair = obj(input, path), q = obj(pair.question, `${path}.question`), k = obj(pair.knowledge, `${path}.knowledge`); for (const key of ["batchNumber", "position"]) { const n = num(pair[key], `${path}.${key}`, key === "batchNumber" ? 1 : 0); if (!Number.isInteger(n)) fail(path, "批次和位置必须是整数"); } if ( !["needs-review", "pending-batch-acceptance", "accepted"].includes( String(pair.reviewStatus), ) ) fail(path, "缺少审核状态"); common(q, `${path}.question`); common(k, `${path}.knowledge`); for (const key of ["prompt", "answerMarkdown", "evidenceBoundary"]) str(q[key], `${path}.question.${key}`); str(k.bodyMarkdown, `${path}.knowledge.bodyMarkdown`); if (k.questionSlug !== q.slug) fail(path, "Question / Knowledge 配对不一致"); if (q.subjectSlug !== k.subjectSlug || q.topicSlug !== k.topicSlug) fail(path, "配对分类不一致"); related( q.relatedQuestionSlugs, `${path}.question.relatedQuestionSlugs`, String(q.slug), ); related( k.relatedKnowledgeSlugs, `${path}.knowledge.relatedKnowledgeSlugs`, String(k.slug), ); if ( strings(q.relatedQuestions, `${path}.question.relatedQuestions`, 3) .length !== 3 ) fail(path, "相关问题标题必须与三个链接对应"); blocks(q.promptBlocks, `${path}.question.promptBlocks`, false); blocks(q.answerBlocks, `${path}.question.answerBlocks`, true); if (pair.supportResources !== null) { const r = obj(pair.supportResources, `${path}.supportResources`); if ( !STUDY_RESOURCE_HEADINGS.includes( r.headingKey as (typeof STUDY_RESOURCE_HEADINGS)[number], ) ) fail(`${path}.supportResources.headingKey`, "未知的资源卡标题"); if (r.heading !== undefined) str(r.heading, `${path}.supportResources.heading`); const uc = obj(r.useCase, `${path}.supportResources.useCase`); str(uc.title, `${path}.supportResources.useCase.title`); if (uc.ctaLabel !== undefined) ---src/lib/cms/sanity/study-publication.ts /** Pure batch preflight: selected drafts + existing published content, never an implicit draft overlay. */ import { StudyValidationError, STUDY_TYPES, studyPairsFromDocuments, } from "./study-package"; import type { ObjectValue } from "./values"; const base = (id: unknown) => String(id).replace(/^drafts\./, ""); const error = (p: string, m: string): never => { throw new StudyValidationError(p, m); }; export interface StudyPublicationPlan { documents: ObjectValue[]; guards: { id: string; revision: string; title: string }[]; reservations: ObjectValue[]; deleteDrafts: string[]; } export function prepareStudyPublication( all: ObjectValue[], ids: string[], reservations: ObjectValue[] = [], ): StudyPublicationPlan { if (!ids.length || ids.length > 200 || new Set(ids).size !== ids.length) error("selection", "请选择 1~200 个不同的已保存草稿"); const docs = all.filter((d) => Object.values(STUDY_TYPES).includes(d._type as "pilotQuestion"), ); const selected: ObjectValue[] = ids.map((id) => { if (id !== base(id)) error(id, "选择必须使用正式文档 ID"); const d = docs.find((d) => d._id === `drafts.${id}`); if (!d) throw new StudyValidationError(id, "草稿不存在"); if (d.reviewStatus !== "accepted") error(id, "请先完成人工验收并将配对双方标为已验收"); const old = docs.find((d) => d._id === id); if ( (old && old.slug !== d.slug) || (d.lockedSlug && d.lockedSlug !== d.slug) ) error(id, "曾发布或迁移页面不能修改 URL"); if ( id.startsWith("verla-pilot.") && id !== `verla-pilot.${d._type === "pilotQuestion" ? "questions" : "knowledge"}.${d.slug}` ) error(id, "迁移身份与 URL 不一致"); if ( docs.some( (other) => base(other._id) !== id && other._type === d._type && other.slug === d.slug, ) ) error(id, "URL 与其他草稿或正式内容重复"); const { _rev, _createdAt, _updatedAt, ...content } = d; void _rev; void _createdAt; void _updatedAt; return { ...content, _id: id, lockedSlug: d.slug }; }); const published = docs.filter((d) => !String(d._id).startsWith("drafts.")); // Unselected counterpart drafts never enter this candidate; all references must resolve here. const candidate = [ ...published.filter((d) => !ids.includes(String(d._id))), ...selected, ]; studyPairsFromDocuments(candidate); const newReservations: ObjectValue[] = []; for (const d of selected) { const kind = d._type === "pilotQuestion" ? "questions" : "knowledge", rid = `verla-url.${kind}.${d.slug}`; if ( reservations.some( (r) => (r.documentId === d._id && r._id !== rid) || (r._id === rid && r.documentId !== d._id), ) ) error(String(d._id), "URL 已被占用或历史地址被更改"); if (!reservations.some((r) => r._id === rid)) newReservations.push({ _id: rid, _type: "verlaUrlReservation", documentId: d._id, }); } const guarded = [ ...published, ...docs.filter( (d) => ids.includes(base(d._id)) && String(d._id).startsWith("drafts."), ), ]; const guards = guarded.map((d) => { if (typeof d._rev !== "string" || !d._rev) error(String(d._id), "缺少 revision"); return { id: String(d._id), revision: String(d._rev), title: String(d.title ?? ""), }; }); const plan = { documents: selected, guards, reservations: newReservations, deleteDrafts: ids.map((id) => `drafts.${id}`), }; if ( guards.length + selected.length + ids.length + newReservations.length > 900 || JSON.stringify(plan).length > 3_000_000 ) error("selection", "事务过大,请拆分为引用闭包完整的较小集合"); return plan; } ---src/lib/cms/sanity/tool-publication.ts import {isNativeContentId,documentBaseId} from './content-identity'; /** Pure Tool boundary: reuse the existing validated page contract and fixed identities. */ import {adaptStoryblokPublicToolStory} from '../storyblok/adapters'; import {pilotId, toTemplateStory} from './bridge'; import {resolveSanityImages, withoutResolvedAssetUrls} from './publication'; import {object, type ObjectValue} from './values'; export function publishedToolPages(documents: ObjectValue[]) { const seen = new Set<string>(); return documents.map(document => { if (document._type !== 'pilotTool' || typeof document.slug !== 'string' || (!isNativeContentId(document._id)&&document._id !== pilotId('tools', document.slug))) { throw new Error('正式 Tool 查询返回了非发布文档或错误身份'); } const story = toTemplateStory(object(resolveSanityImages(document)), 'tools'); const page = adaptStoryblokPublicToolStory(story); if (seen.has(page.canonicalPath)) throw new Error('Tool 正式地址重复'); seen.add(page.canonicalPath); return {story, ...page, catalog: page.catalog ?? {description:page.spec.metadata.description, order:500, title:page.spec.hero.currentLabel, visible:true}}; }).sort((a, b) => a.canonicalPath.localeCompare(b.canonicalPath)); } /** A transport must check both revisions before committing this validated candidate. */ export function prepareToolPublication(draft: ObjectValue, published: ObjectValue | null): ObjectValue { if (typeof draft.slug !== 'string' || (typeof draft._id!=='string'||!draft._id.startsWith('drafts.')||(!isNativeContentId(documentBaseId(draft._id))&&draft._id !== `drafts.${pilotId('tools', draft.slug)}`))) throw new Error('请选择地址未改变的 Tool 草稿'); const next = object(withoutResolvedAssetUrls(structuredClone(draft))); next._id = String(draft._id).slice(7); if (published && (published._id !== next._id || published.slug !== next.slug)) throw new Error('已发布 Tool 不能直接修改 URL'); delete next._rev; delete next._createdAt; delete next._updatedAt; publishedToolPages([next]); return next; } ---src/lib/cms/sanity/values.ts /** JSON values shared by Studio and server without framework imports. */ export type Value = string | number | boolean | null | Value[] | { [key: string]: Value }; export type ObjectValue = { [key: string]: Value }; /** Rejects malformed API data before it reaches a renderer. */ export function object(value: unknown): ObjectValue { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("内容必须是对象"); return value as ObjectValue; } export function objects(value: unknown): ObjectValue[] { if (!Array.isArray(value)) throw new Error("内容必须是列表"); return value.map(object); } export function string(value: unknown): string { if (typeof value !== "string") throw new Error("内容必须是文本"); return value; }