import { normalizeMarkdown } from '@/lib/markdown-diff' export type ParsedMarkdownMeta = { title: string slug: string description: string category: string postType: string image: string images: string[] pinned: boolean status: string visibility: string publishAt: string unpublishAt: string canonicalUrl: string noindex: boolean ogImage: string redirectFrom: string[] redirectTo: string tags: string[] } export type ParsedMarkdownDocument = { meta: ParsedMarkdownMeta body: string markdown: string } const defaultMeta: ParsedMarkdownMeta = { title: '', slug: '', description: '', category: '', postType: 'article', image: '', images: [], pinned: false, status: 'published', visibility: 'public', publishAt: '', unpublishAt: '', canonicalUrl: '', noindex: false, ogImage: '', redirectFrom: [], redirectTo: '', tags: [], } function parseScalar(value: string) { const trimmed = value.trim() if (!trimmed) { return '' } if ( trimmed.startsWith('"') || trimmed.startsWith("'") || trimmed.startsWith('[') || trimmed.startsWith('{') ) { try { return JSON.parse(trimmed) } catch { return trimmed.replace(/^['"]|['"]$/g, '') } } if (trimmed === 'true') { return true } if (trimmed === 'false') { return false } return trimmed } function toStringList(value: unknown) { if (Array.isArray(value)) { return value .map((item) => String(item).trim()) .filter(Boolean) } if (typeof value === 'string') { return value .split(/[,,]/) .map((item) => item.trim()) .filter(Boolean) } return [] } export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument { const normalized = normalizeMarkdown(markdown) const meta: ParsedMarkdownMeta = { ...defaultMeta } if (!normalized.startsWith('---\n')) { return { meta, body: normalized.trimStart(), markdown: normalized, } } const endIndex = normalized.indexOf('\n---\n', 4) if (endIndex === -1) { return { meta, body: normalized.trimStart(), markdown: normalized, } } const frontmatter = normalized.slice(4, endIndex) const body = normalized.slice(endIndex + 5).trimStart() let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null const categories: string[] = [] frontmatter.split('\n').forEach((line) => { const listItemMatch = line.match(/^\s*-\s*(.+)\s*$/) if (listItemMatch && currentListKey) { const parsed = parseScalar(listItemMatch[1]) const nextValue = typeof parsed === 'string' ? parsed.trim() : String(parsed).trim() if (!nextValue) { return } if (currentListKey === 'tags') { meta.tags.push(nextValue) } else if (currentListKey === 'images') { meta.images.push(nextValue) } else if (currentListKey === 'redirect_from') { meta.redirectFrom.push(nextValue) } else { categories.push(nextValue) } return } currentListKey = null const keyMatch = line.match(/^([A-Za-z_]+):\s*(.*)$/) if (!keyMatch) { return } const [, rawKey, rawValue] = keyMatch const key = rawKey.trim() const value = parseScalar(rawValue) if (key === 'tags') { const tags = toStringList(value) if (tags.length) { meta.tags = tags } else if (!String(rawValue).trim()) { currentListKey = 'tags' } return } if (key === 'images') { const images = toStringList(value) if (images.length) { meta.images = images } else if (!String(rawValue).trim()) { currentListKey = 'images' } return } if (key === 'redirect_from') { const redirectFrom = toStringList(value) if (redirectFrom.length) { meta.redirectFrom = redirectFrom } else if (!String(rawValue).trim()) { currentListKey = 'redirect_from' } return } if (key === 'categories' || key === 'category') { const parsedCategories = toStringList(value) if (parsedCategories.length) { categories.push(...parsedCategories) } else if (!String(rawValue).trim()) { currentListKey = 'categories' } return } switch (key) { case 'title': meta.title = String(value).trim() break case 'slug': meta.slug = String(value).trim() break case 'description': meta.description = String(value).trim() break case 'post_type': meta.postType = String(value).trim() || 'article' break case 'image': meta.image = String(value).trim() break case 'pinned': meta.pinned = Boolean(value) break case 'status': meta.status = String(value).trim() || 'published' break case 'visibility': meta.visibility = String(value).trim() || 'public' break case 'publish_at': meta.publishAt = String(value).trim() break case 'unpublish_at': meta.unpublishAt = String(value).trim() break case 'canonical_url': meta.canonicalUrl = String(value).trim() break case 'noindex': meta.noindex = Boolean(value) break case 'og_image': meta.ogImage = String(value).trim() break case 'redirect_to': meta.redirectTo = String(value).trim() break case 'published': meta.status = value === false ? 'draft' : 'published' break case 'draft': if (value === true) { meta.status = 'draft' } break default: break } }) meta.category = categories[0] ?? meta.category return { meta, body, markdown: normalized, } } export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) { const lines = [ '---', `title: ${JSON.stringify(meta.title.trim() || meta.slug || 'untitled-post')}`, `slug: ${meta.slug.trim() || 'untitled-post'}`, ] if (meta.description.trim()) { lines.push(`description: ${JSON.stringify(meta.description.trim())}`) } if (meta.category.trim()) { lines.push(`category: ${JSON.stringify(meta.category.trim())}`) } lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`) lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`) lines.push(`status: ${JSON.stringify(meta.status.trim() || 'published')}`) lines.push(`visibility: ${JSON.stringify(meta.visibility.trim() || 'public')}`) lines.push(`noindex: ${meta.noindex ? 'true' : 'false'}`) if (meta.publishAt.trim()) { lines.push(`publish_at: ${JSON.stringify(meta.publishAt.trim())}`) } if (meta.unpublishAt.trim()) { lines.push(`unpublish_at: ${JSON.stringify(meta.unpublishAt.trim())}`) } if (meta.image.trim()) { lines.push(`image: ${JSON.stringify(meta.image.trim())}`) } if (meta.images.length) { lines.push('images:') meta.images.forEach((image) => { lines.push(` - ${JSON.stringify(image)}`) }) } if (meta.tags.length) { lines.push('tags:') meta.tags.forEach((tag) => { lines.push(` - ${JSON.stringify(tag)}`) }) } if (meta.canonicalUrl.trim()) { lines.push(`canonical_url: ${JSON.stringify(meta.canonicalUrl.trim())}`) } if (meta.ogImage.trim()) { lines.push(`og_image: ${JSON.stringify(meta.ogImage.trim())}`) } if (meta.redirectFrom.length) { lines.push('redirect_from:') meta.redirectFrom.forEach((item) => { lines.push(` - ${JSON.stringify(item)}`) }) } if (meta.redirectTo.trim()) { lines.push(`redirect_to: ${JSON.stringify(meta.redirectTo.trim())}`) } return `${lines.join('\n')}\n---\n\n${body.trim()}\n` }