Files
termi-blog/admin/src/lib/markdown-document.ts

329 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`
}