Files
tolaria/vite.config.ts
lucaronin 663f7b199b feat: add vault API middleware for browser testing with real vault files
Add a Vite dev server middleware plugin that serves vault data over HTTP,
allowing the browser version of the app to read real markdown files instead
of hardcoded mock data. The mock layer now checks for API availability at
load time and falls back to hardcoded data when the API is unavailable.

- GET /api/vault/ping — health check
- GET /api/vault/list?path=<dir> — scan dir, parse frontmatter → VaultEntry[]
- GET /api/vault/content?path=<file> — raw file content
- GET /api/vault/all-content?path=<dir> — all .md files content map
- gray-matter added as dev dependency for frontmatter parsing
- useVaultLoader now passes vaultPath to mock invoke calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:39:45 +01:00

283 lines
8.3 KiB
TypeScript

/// <reference types="vitest/config" />
import path from 'path'
import fs from 'fs'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import matter from 'gray-matter'
// --- Vault API middleware (dev only) ---
interface VaultEntry {
path: string
filename: string
title: string
isA: string | null
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
owner: string | null
cadence: string | null
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
relationships: Record<string, string[]>
}
/** Extract all [[wiki-links]] from a string. */
function extractWikiLinks(value: string): string[] {
const matches = value.match(/\[\[[^\]]+\]\]/g)
return matches ?? []
}
/** Extract wiki-links from a frontmatter value (string or array of strings). */
function wikiLinksFromValue(value: unknown): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (Array.isArray(value)) {
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
}
return []
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
const DEDICATED_KEYS = new Set([
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
'related_to', 'related to', 'status', 'owner', 'cadence',
'created_at', 'created at', 'title',
])
function parseMarkdownFile(filePath: string): VaultEntry | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8')
const stats = fs.statSync(filePath)
const { data: fm, content } = matter(raw)
const filename = path.basename(filePath)
const basename = filename.replace(/\.md$/, '')
// Title: first H1 in body, or frontmatter title, or filename
const h1Match = content.match(/^#\s+(.+)$/m)
const title = (fm.title as string) || (h1Match ? h1Match[1].trim() : basename)
// Helper to get a frontmatter string field (case-insensitive key)
const getString = (...keys: string[]): string | null => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'string') {
return fm[fk]
}
}
}
return null
}
// Helper to get a frontmatter array-of-strings field
const getArray = (...keys: string[]): string[] => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase()) {
const val = fm[fk]
if (Array.isArray(val)) return val.map(String)
if (typeof val === 'string') return [val]
}
}
}
return []
}
const aliases = getArray('aliases')
const belongsToRaw = getArray('belongs_to', 'belongs to')
const relatedToRaw = getArray('related_to', 'related to')
const belongsTo = belongsToRaw.flatMap((v) => extractWikiLinks(v))
const relatedTo = relatedToRaw.flatMap((v) => extractWikiLinks(v))
// Created at → unix ms
const createdAtRaw = getString('created_at', 'created at')
let createdAt: number | null = null
if (createdAtRaw) {
const d = new Date(createdAtRaw)
if (!isNaN(d.getTime())) createdAt = d.getTime()
}
// Snippet: first 200 chars of body after frontmatter, stripped of markdown
const bodyText = content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
const snippet = bodyText.slice(0, 200)
// Generic relationships: any frontmatter key whose value contains wiki-links
const relationships: Record<string, string[]> = {}
for (const key of Object.keys(fm)) {
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
const links = wikiLinksFromValue(fm[key])
if (links.length > 0) {
relationships[key] = links
}
}
return {
path: filePath,
filename,
title,
isA: getString('is_a', 'is a'),
aliases,
belongsTo,
relatedTo,
status: getString('status'),
owner: getString('owner'),
cadence: getString('cadence'),
modifiedAt: stats.mtimeMs,
createdAt,
fileSize: stats.size,
snippet,
relationships,
}
} catch {
return null
}
}
/** Recursively find all .md files under a directory. */
function findMarkdownFiles(dir: string): string[] {
const results: string[] = []
try {
const items = fs.readdirSync(dir, { withFileTypes: true })
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...findMarkdownFiles(full))
} else if (item.name.endsWith('.md')) {
results.push(full)
}
}
} catch {
// skip unreadable dirs
}
return results
}
function vaultApiPlugin(): Plugin {
return {
name: 'vault-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/api/vault/ping') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: true }))
return
}
if (url.pathname === '/api/vault/list') {
const dirPath = url.searchParams.get('path')
if (!dirPath || !fs.existsSync(dirPath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const files = findMarkdownFiles(dirPath)
const entries = files.map(parseMarkdownFile).filter(Boolean)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entries))
return
}
if (url.pathname === '/api/vault/content') {
const filePath = url.searchParams.get('path')
if (!filePath || !fs.existsSync(filePath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const content = fs.readFileSync(filePath, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ content }))
return
}
if (url.pathname === '/api/vault/all-content') {
const dirPath = url.searchParams.get('path')
if (!dirPath || !fs.existsSync(dirPath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const files = findMarkdownFiles(dirPath)
const contentMap: Record<string, string> = {}
for (const f of files) {
try {
contentMap[f] = fs.readFileSync(f, 'utf-8')
} catch {
// skip unreadable files
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(contentMap))
return
}
next()
})
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), vaultApiPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Prevent vite from obscuring Rust errors
clearScreen: false,
// Tauri expects a fixed port
server: {
port: 5173,
strictPort: true,
allowedHosts: true,
},
// Env variables starting with TAURI_ are exposed to the frontend
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri uses Chromium on Windows and WebKit on macOS/Linux
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
// Don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
// Produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.{test,spec}.{ts,tsx}',
'src/test/**',
'src/mock-tauri.ts',
'src/main.tsx',
],
thresholds: {
lines: 70,
functions: 70,
branches: 70,
statements: 70,
},
},
},
})