229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
/**
|
|
* Vite dev middleware exposing the local doocus docs-output tree to the app.
|
|
*
|
|
* Runs in the dev server process (Node), so it can reach the original source
|
|
* file by the absolute path stored in each doc's manifest.json — needed for the
|
|
* package builder, which bundles the original alongside content.md + meta.json.
|
|
* All reads are local; nothing leaves the machine.
|
|
*
|
|
* Routes:
|
|
* GET /api/docs list extracted docs
|
|
* GET /api/docs/:id meta.json + content.md + thumb/original flags
|
|
* GET /api/docs/:id/thumb stream thumb.jpg
|
|
* GET /api/docs/:id/original stream the original source file
|
|
*/
|
|
import type { Plugin } from 'vite'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import fs from 'node:fs'
|
|
import fsp from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
interface Options {
|
|
outputDir: string
|
|
}
|
|
|
|
interface Manifest {
|
|
source?: { name?: string; path?: string; sha256?: string }
|
|
processed_at?: string
|
|
outputs?: { content?: string; meta?: string; thumb?: string | null }
|
|
}
|
|
|
|
const ORIGINAL_MIME: Record<string, string> = {
|
|
'.pdf': 'application/pdf',
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'.csv': 'text/csv',
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
'.json': 'application/json',
|
|
'.md': 'text/markdown',
|
|
'.txt': 'text/plain',
|
|
'.yaml': 'text/yaml',
|
|
'.yml': 'text/yaml',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.mp4': 'video/mp4',
|
|
}
|
|
|
|
export function doocusApi(opts: Options): Plugin {
|
|
const outputDir = path.resolve(opts.outputDir)
|
|
const toPosix = (p: string) => p.split(path.sep).join('/')
|
|
|
|
/** Resolve a doc id (possibly nested) to its directory, rejecting traversal. */
|
|
function docDir(id: string): string | null {
|
|
const dir = path.resolve(outputDir, id)
|
|
if (dir !== outputDir && !dir.startsWith(outputDir + path.sep)) return null
|
|
try {
|
|
if (fs.statSync(dir).isDirectory()) return dir
|
|
} catch { /* missing */ }
|
|
return null
|
|
}
|
|
|
|
/** Find every doc run directory (one containing meta.json) under `base`. */
|
|
async function findDocDirs(base: string, maxDepth = 8): Promise<string[]> {
|
|
const found: string[] = []
|
|
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
if (depth > maxDepth) return
|
|
let ents
|
|
try {
|
|
ents = await fsp.readdir(dir, { withFileTypes: true })
|
|
} catch { return }
|
|
if (ents.some((e) => e.isFile() && e.name === 'meta.json')) {
|
|
found.push(dir)
|
|
return // a doc dir; don't descend into assets/
|
|
}
|
|
for (const e of ents) {
|
|
if (!e.isDirectory()) continue
|
|
if (e.name === 'assets' || e.name === 'node_modules' || e.name.startsWith('.')) continue
|
|
await walk(path.join(dir, e.name), depth + 1)
|
|
}
|
|
}
|
|
await walk(base, 0)
|
|
return found
|
|
}
|
|
|
|
function readJson<T>(file: string): T | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: 'doocus-api',
|
|
configureServer(server) {
|
|
server.config.logger.info(`[doocus-api] serving docs from: ${outputDir}`)
|
|
server.middlewares.use('/api', (req, res, next) => {
|
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
const parts = url.pathname.split('/').filter(Boolean)
|
|
handle(req, res, parts).catch((err) => {
|
|
server.config.logger.error(`[doocus-api] ${String(err)}`)
|
|
sendJson(res, 500, { error: String(err) })
|
|
}).then((handled) => {
|
|
if (!handled) next()
|
|
})
|
|
})
|
|
},
|
|
}
|
|
|
|
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<boolean> {
|
|
if (parts[0] !== 'docs') return false
|
|
|
|
// GET /api/docs
|
|
if (parts.length === 1 && req.method === 'GET') {
|
|
await listDocs(res)
|
|
return true
|
|
}
|
|
|
|
const id = parts[1] ? decodeURIComponent(parts[1]) : ''
|
|
const dir = id ? docDir(id) : null
|
|
if (!dir) { sendJson(res, 404, { error: 'doc not found' }); return true }
|
|
|
|
// GET /api/docs/:id
|
|
if (parts.length === 2 && req.method === 'GET') {
|
|
await getDoc(res, id, dir)
|
|
return true
|
|
}
|
|
// GET /api/docs/:id/thumb
|
|
if (parts.length === 3 && parts[2] === 'thumb' && req.method === 'GET') {
|
|
serveThumb(res, dir)
|
|
return true
|
|
}
|
|
// GET /api/docs/:id/original
|
|
if (parts.length === 3 && parts[2] === 'original' && req.method === 'GET') {
|
|
serveOriginal(res, dir)
|
|
return true
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'not found' })
|
|
return true
|
|
}
|
|
|
|
async function listDocs(res: ServerResponse): Promise<void> {
|
|
const dirs = await findDocDirs(outputDir)
|
|
const docs = []
|
|
for (const dir of dirs) {
|
|
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
|
|
if (!meta) continue
|
|
const id = toPosix(path.relative(outputDir, dir))
|
|
const source = (meta.source ?? {}) as { name?: string; bytes?: number }
|
|
docs.push({
|
|
id,
|
|
name: source.name ?? id,
|
|
type: meta.type ?? null,
|
|
family: meta.family ?? null,
|
|
title: meta.title ?? null,
|
|
extractedAt: meta.extracted_at ?? null,
|
|
wordCount: meta.word_count ?? 0,
|
|
bytes: source.bytes ?? 0,
|
|
hasThumb: fs.existsSync(path.join(dir, 'thumb.jpg')),
|
|
warnings: Array.isArray(meta.warnings) ? meta.warnings.length : 0,
|
|
})
|
|
}
|
|
docs.sort((a, b) =>
|
|
String(b.extractedAt ?? '').localeCompare(String(a.extractedAt ?? '')) || a.id.localeCompare(b.id))
|
|
sendJson(res, 200, { docs, outputDir })
|
|
}
|
|
|
|
async function getDoc(res: ServerResponse, id: string, dir: string): Promise<void> {
|
|
const meta = readJson<Record<string, unknown>>(path.join(dir, 'meta.json'))
|
|
if (!meta) { sendJson(res, 404, { error: 'meta missing' }); return }
|
|
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
|
|
|
|
let content = ''
|
|
try {
|
|
content = await fsp.readFile(path.join(dir, 'content.md'), 'utf-8')
|
|
} catch { /* none */ }
|
|
|
|
const hasThumb = fs.existsSync(path.join(dir, 'thumb.jpg'))
|
|
const originalPath = manifest?.source?.path
|
|
const hasOriginal = !!originalPath && isFile(originalPath)
|
|
|
|
sendJson(res, 200, {
|
|
id,
|
|
meta,
|
|
content,
|
|
hasThumb,
|
|
thumbUrl: hasThumb ? `/api/docs/${encodeURIComponent(id)}/thumb` : null,
|
|
hasOriginal,
|
|
originalUrl: hasOriginal ? `/api/docs/${encodeURIComponent(id)}/original` : null,
|
|
})
|
|
}
|
|
|
|
function serveThumb(res: ServerResponse, dir: string): void {
|
|
const full = path.join(dir, 'thumb.jpg')
|
|
if (!fs.existsSync(full)) { sendJson(res, 404, { error: 'no thumb' }); return }
|
|
res.setHeader('Content-Type', 'image/jpeg')
|
|
res.setHeader('Cache-Control', 'no-cache')
|
|
fs.createReadStream(full).pipe(res)
|
|
}
|
|
|
|
function serveOriginal(res: ServerResponse, dir: string): void {
|
|
const manifest = readJson<Manifest>(path.join(dir, 'manifest.json'))
|
|
const original = manifest?.source?.path
|
|
if (!original || !isFile(original)) {
|
|
sendJson(res, 404, { error: 'original source not reachable on this machine' })
|
|
return
|
|
}
|
|
const mime = ORIGINAL_MIME[path.extname(original).toLowerCase()] ?? 'application/octet-stream'
|
|
res.setHeader('Content-Type', mime)
|
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(original)}"`)
|
|
fs.createReadStream(original).pipe(res)
|
|
}
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
|
|
const body = JSON.stringify(obj)
|
|
res.statusCode = status
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Content-Length', Buffer.byteLength(body))
|
|
res.end(body)
|
|
}
|
|
|
|
function isFile(p: string): boolean {
|
|
try { return fs.statSync(p).isFile() } catch { return false }
|
|
}
|