///
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
}
/** 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 = {}
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 = {}
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,
},
},
},
})