project-nomad/admin/app/services/docs_service.ts
2025-12-06 23:59:01 -08:00

94 lines
2.6 KiB
TypeScript

import Markdoc from '@markdoc/markdoc'
import { streamToString } from '../../util/docs.js'
import { getFile, getFileStatsIfExists, listDirectoryContentsRecursive } from '../utils/fs.js'
import path from 'path'
export class DocsService {
private docsPath = path.join(process.cwd(), 'docs')
async getDocs() {
const contents = await listDirectoryContentsRecursive(this.docsPath)
const files: Array<{ title: string; slug: string }> = []
for (const item of contents) {
if (item.type === 'file' && item.name.endsWith('.md')) {
const cleaned = this.prettify(item.name)
files.push({
title: cleaned,
slug: item.name.replace(/\.md$/, ''),
})
}
}
return files.sort((a, b) => a.title.localeCompare(b.title))
}
parse(content: string) {
const ast = Markdoc.parse(content)
const config = this.getConfig()
const errors = Markdoc.validate(ast, config)
if (errors.length > 0) {
throw new Error(`Markdoc validation errors: ${errors.map((e) => e.error).join(', ')}`)
}
return Markdoc.transform(ast, config)
}
async parseFile(_filename: string) {
if (!_filename) {
throw new Error('Filename is required')
}
const filename = _filename.endsWith('.md') ? _filename : `${_filename}.md`
const fileExists = await getFileStatsIfExists(path.join(this.docsPath, filename))
if (!fileExists) {
throw new Error(`File not found: ${filename}`)
}
const fileStream = await getFile(path.join(this.docsPath, filename), 'stream')
if (!fileStream) {
throw new Error(`Failed to read file stream: ${filename}`)
}
const content = await streamToString(fileStream)
return this.parse(content)
}
private prettify(filename: string) {
// Remove hyphens, underscores, and file extension
const cleaned = filename.replace(/_/g, ' ').replace(/\.md$/, '').replace(/-/g, ' ')
// Convert to Title Case
const titleCased = cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
return titleCased.charAt(0).toUpperCase() + titleCased.slice(1)
}
private getConfig() {
return {
tags: {
callout: {
render: 'Callout',
attributes: {
type: {
type: String,
default: 'info',
matches: ['info', 'warning', 'error', 'success'],
},
title: {
type: String,
},
},
},
},
nodes: {
heading: {
render: 'Heading',
attributes: {
level: { type: Number, required: true },
id: { type: String },
},
},
},
}
}
}