- Add fresh app structure (auth, tenant, mail, insight, docs, panel) - Include CMS content, API routes, scripts, and config - Migrate UI components, themes, and extensions to fresh runtime
124 lines
3.0 KiB
TypeScript
124 lines
3.0 KiB
TypeScript
/**
|
|
* Markdown rendering utilities for Fresh + Deno
|
|
*
|
|
* Renders markdown files with frontmatter and sanitization
|
|
*/
|
|
|
|
import matter from 'gray-matter'
|
|
import { marked } from 'marked'
|
|
import sanitizeHtml from 'sanitize-html'
|
|
|
|
import {
|
|
assertContentFile,
|
|
ContentNotFoundError,
|
|
normalizeContentPath,
|
|
toContentRelativePath,
|
|
} from './content-utils.ts'
|
|
|
|
export interface MarkdownRenderResult {
|
|
path: string
|
|
html: string
|
|
meta: Record<string, unknown>
|
|
}
|
|
|
|
const cache = new Map<string, { mtimeMs: number; result: MarkdownRenderResult }>()
|
|
|
|
function sanitize(html: string): string {
|
|
return sanitizeHtml(html, {
|
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
'img',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
]),
|
|
allowedAttributes: {
|
|
...sanitizeHtml.defaults.allowedAttributes,
|
|
a: ['href', 'name', 'target', 'rel'],
|
|
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
},
|
|
transformTags: {
|
|
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' }),
|
|
},
|
|
})
|
|
}
|
|
|
|
function serializeFrontmatter(
|
|
data: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> {
|
|
if (!data || typeof data !== 'object') {
|
|
return {}
|
|
}
|
|
|
|
const serializeValue = (value: unknown): unknown => {
|
|
if (value instanceof Date) {
|
|
return value.toISOString()
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map(serializeValue)
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [
|
|
key,
|
|
serializeValue(nested),
|
|
]),
|
|
)
|
|
}
|
|
return value
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(data).map(([key, value]) => [key, serializeValue(value)]),
|
|
)
|
|
}
|
|
|
|
export async function renderMarkdownFile(
|
|
requestPath: string,
|
|
): Promise<MarkdownRenderResult> {
|
|
const absolutePath = await assertContentFile(requestPath)
|
|
const stats = await Deno.stat(absolutePath)
|
|
|
|
const cached = cache.get(absolutePath)
|
|
if (cached && cached.mtimeMs === (stats.mtime?.getTime() || 0)) {
|
|
return cached.result
|
|
}
|
|
|
|
const fileContent = await Deno.readTextFile(absolutePath)
|
|
const { content, data } = matter(fileContent)
|
|
|
|
const rawHtml = marked.parse(content, { async: false }) as string
|
|
const html = sanitize(rawHtml)
|
|
|
|
const result: MarkdownRenderResult = {
|
|
path: toContentRelativePath(absolutePath),
|
|
html,
|
|
meta: serializeFrontmatter(data as Record<string, unknown> | undefined),
|
|
}
|
|
|
|
cache.set(absolutePath, {
|
|
mtimeMs: stats.mtime?.getTime() || 0,
|
|
result,
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
export function invalidateMarkdownCache(requestPath: string) {
|
|
try {
|
|
const absolutePath = normalizeContentPath(requestPath)
|
|
cache.delete(absolutePath)
|
|
} catch (error) {
|
|
// Ignore invalid paths during invalidation
|
|
console.warn('[render-markdown] Failed to invalidate cache:', error)
|
|
}
|
|
}
|
|
|
|
export function clearMarkdownCache() {
|
|
cache.clear()
|
|
}
|
|
|
|
export { ContentNotFoundError }
|