162 lines
5.1 KiB
JavaScript
162 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Production static server for Laputa App demo.
|
|
* Serves dist/ + handles /api/vault/* routes for browser testing.
|
|
*/
|
|
|
|
import http from 'http'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import matter from 'gray-matter'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const DIST_DIR = path.join(__dirname, '..', 'dist')
|
|
const REPO_DIR = path.resolve(__dirname, '..')
|
|
const PORT = 5173
|
|
|
|
function isAllowedPath(p) {
|
|
const resolved = path.resolve(p)
|
|
return resolved.startsWith(REPO_DIR)
|
|
}
|
|
|
|
const MIME = {
|
|
'.html': 'text/html',
|
|
'.js': 'application/javascript',
|
|
'.css': 'text/css',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.ico': 'image/x-icon',
|
|
'.woff2':'font/woff2',
|
|
'.json': 'application/json',
|
|
}
|
|
|
|
function findMarkdownFiles(dir) {
|
|
const results = []
|
|
try {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) results.push(...findMarkdownFiles(full))
|
|
else if (entry.name.endsWith('.md')) results.push(full)
|
|
}
|
|
} catch {}
|
|
return results
|
|
}
|
|
|
|
function extractWikiLinks(value) {
|
|
if (!value) return []
|
|
const str = Array.isArray(value) ? value.join(' ') : String(value)
|
|
return [...str.matchAll(/\[\[([^\]]+)\]\]/g)].map(m => `[[${m[1]}]]`)
|
|
}
|
|
|
|
function parseMarkdownFile(filePath) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
const { data: fm, content } = matter(raw)
|
|
const stat = fs.statSync(filePath)
|
|
|
|
const DEDICATED = new Set(['aliases','Is A','Belongs to','Related to','Status','Owner','Cadence','Created at'])
|
|
const relationships = {}
|
|
for (const [k, v] of Object.entries(fm)) {
|
|
if (DEDICATED.has(k)) continue
|
|
const links = extractWikiLinks(v)
|
|
if (links.length) relationships[k] = links
|
|
}
|
|
|
|
const bodyText = content.replace(/---[\s\S]*?---/, '').trim()
|
|
const h1 = bodyText.match(/^#\s+(.+)/m)?.[1]
|
|
const aliases = Array.isArray(fm.aliases) ? fm.aliases : fm.aliases ? [fm.aliases] : []
|
|
|
|
return {
|
|
path: filePath,
|
|
filename: path.basename(filePath),
|
|
title: h1 || aliases[0] || path.basename(filePath, '.md'),
|
|
isA: fm['Is A'] ?? null,
|
|
aliases,
|
|
belongsTo: extractWikiLinks(fm['Belongs to']),
|
|
relatedTo: extractWikiLinks(fm['Related to']),
|
|
status: fm['Status'] ?? null,
|
|
owner: fm['Owner'] ?? null,
|
|
cadence: fm['Cadence'] ?? null,
|
|
modifiedAt: stat.mtimeMs,
|
|
createdAt: fm['Created at'] ? new Date(fm['Created at']).getTime() : null,
|
|
fileSize: stat.size,
|
|
snippet: bodyText.replace(/^#+\s+.+/gm, '').replace(/\n+/g, ' ').trim().slice(0, 200),
|
|
relationships,
|
|
}
|
|
} catch { return null }
|
|
}
|
|
|
|
function serveVaultApi(url, res) {
|
|
const params = new URL(url, 'http://localhost')
|
|
|
|
if (params.pathname === '/api/vault/ping') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ ok: true }))
|
|
return true
|
|
}
|
|
|
|
if (params.pathname === '/api/vault/list') {
|
|
const dir = params.searchParams.get('path')
|
|
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
|
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
|
}
|
|
const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean)
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify(entries))
|
|
return true
|
|
}
|
|
|
|
if (params.pathname === '/api/vault/content') {
|
|
const file = params.searchParams.get('path')
|
|
if (!file || !isAllowedPath(file) || !fs.existsSync(file)) {
|
|
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ content: fs.readFileSync(file, 'utf-8') }))
|
|
return true
|
|
}
|
|
|
|
if (params.pathname === '/api/vault/all-content') {
|
|
const dir = params.searchParams.get('path')
|
|
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
|
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
|
}
|
|
const map = {}
|
|
for (const f of findMarkdownFiles(dir)) {
|
|
try { map[f] = fs.readFileSync(f, 'utf-8') } catch {}
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify(map))
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const url = req.url ?? '/'
|
|
|
|
// API routes
|
|
if (url.startsWith('/api/vault/')) {
|
|
if (!serveVaultApi(url, res)) {
|
|
res.writeHead(404); res.end()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Static files
|
|
let filePath = path.join(DIST_DIR, url === '/' ? 'index.html' : url)
|
|
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
filePath = path.join(DIST_DIR, 'index.html') // SPA fallback
|
|
}
|
|
const ext = path.extname(filePath)
|
|
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
|
|
fs.createReadStream(filePath).pipe(res)
|
|
})
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`✅ Laputa demo server running on http://0.0.0.0:${PORT}`)
|
|
console.log(` Tailscale: https://mac-mini.tail7cbc15.ts.net`)
|
|
})
|