329 lines
7.7 KiB
TypeScript
329 lines
7.7 KiB
TypeScript
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`
|
||
}
|