Files
tolaria/vite.config.ts
Test e6b3278fea refactor: remove owner/cadence from Frontmatter, source created_at from filesystem
Remove owner, cadence, created_at, and created_time as hardcoded fields from
the Frontmatter struct — they don't drive app logic and belong in generic
properties. Owner and cadence values now flow through to the properties map
(including single-element array unwrapping). Creation date is sourced from
filesystem metadata (birthtime on macOS) instead of frontmatter.

Also fixes title extraction: add H1 heading extraction to extract_title()
(priority: frontmatter title → H1 → filename slug). Previously titles fell
back directly to filename when no frontmatter title was set.

StringOrList is retained for scalar Frontmatter fields as a defensive measure:
YAML values can arrive as single-element arrays (e.g. Status: [Active]) which
would cause entire deserialization to fail without it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:56:33 +01:00

611 lines
21 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
archived: boolean
trashed: boolean
trashedAt: number | null
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
icon: string | null
color: string | null
order: number | null
sidebarLabel: string | null
template: string | null
sort: string | null
view: string | null
visible: boolean | null
outgoingLinks: string[]
properties: Record<string, string | number | boolean | null>
}
/** 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', '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 from filesystem metadata (birthtime), not frontmatter
const createdAt = stats.birthtimeMs
// 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
}
}
// Boolean field helper — handles both real booleans and YAML 1.1 string
// variants ("Yes"/"yes"/"True"/"true") that js-yaml 4.x (YAML 1.2) leaves as strings.
const getBool = (...keys: string[]): boolean | null => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase()) {
const v = fm[fk]
if (typeof v === 'boolean') return v
if (typeof v === 'string') {
const lc = v.toLowerCase()
if (lc === 'true' || lc === 'yes') return true
if (lc === 'false' || lc === 'no') return false
}
}
}
}
return null
}
return {
path: filePath,
filename,
title,
isA: getString('is_a', 'is a', 'type'),
aliases,
belongsTo,
relatedTo,
status: getString('status'),
archived: getBool('archived') ?? false,
trashed: getBool('trashed') ?? false,
trashedAt: null,
modifiedAt: stats.mtimeMs,
createdAt,
fileSize: stats.size,
snippet,
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
relationships,
icon: getString('icon'),
color: getString('color'),
order: fm.order != null ? Number(fm.order) : null,
sidebarLabel: getString('sidebar label', 'sidebar_label'),
template: getString('template'),
sort: getString('sort'),
view: getString('view'),
visible: getBool('visible'),
outgoingLinks: [],
properties: {},
}
} 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(async (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
}
if (url.pathname === '/api/vault/entry') {
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 entry = parseMarkdownFile(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entry))
return
}
if (url.pathname === '/api/vault/search') {
const vaultPath = url.searchParams.get('vault_path')
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode }))
return
}
const files = findMarkdownFiles(vaultPath)
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
for (const f of files) {
const entry = parseMarkdownFile(f)
if (!entry || entry.trashed) continue
const raw = fs.readFileSync(f, 'utf-8')
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode }))
return
}
// --- POST endpoints for write operations ---
if (url.pathname === '/api/vault/save' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath, content } = JSON.parse(body)
if (!filePath || content === undefined) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path or content' }))
return
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end('null')
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' }))
}
return
}
if (url.pathname === '/api/vault/rename' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
const oldContent = fs.readFileSync(oldPath, 'utf-8')
const h1Match = oldContent.match(/^# (.+)$/m)
const oldTitle = h1Match ? h1Match[1].trim() : ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = path.dirname(oldPath)
const newPath = path.join(parentDir, `${slug}.md`)
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
fs.writeFileSync(newPath, newContent, 'utf-8')
if (newPath !== oldPath) fs.unlinkSync(oldPath)
let updatedFiles = 0
if (oldTitle && vaultPath) {
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const f of allFiles) {
if (f === newPath) continue
try {
const c = fs.readFileSync(f, 'utf-8')
const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
)
if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ }
} catch { /* skip */ }
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles }))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' }))
}
return
}
if (url.pathname === '/api/vault/delete' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path' }))
return
}
fs.unlinkSync(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(filePath))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' }))
}
return
}
next()
})
},
}
}
// --- AI Chat proxy helpers ---
function readRequestBody(req: import('http').IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = ''
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
req.on('end', () => resolve(body))
})
}
async function forwardToAnthropic(params: {
apiKey: string; model?: string; messages: { role: string; content: string }[]; system?: string; maxTokens?: number
}): Promise<Response> {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: params.model || 'claude-3-5-haiku-20241022',
max_tokens: params.maxTokens || 4096,
system: params.system || undefined,
messages: params.messages,
stream: true,
}),
})
}
/** Forward to Anthropic with tool definitions (non-streaming for tool loop) */
async function forwardToAnthropicAgent(params: {
apiKey: string; model?: string; messages: unknown[]; system?: string
maxTokens?: number; tools?: unknown[]
}): Promise<Response> {
return fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: params.model || 'claude-3-5-haiku-20241022',
max_tokens: params.maxTokens || 4096,
system: params.system || undefined,
messages: params.messages,
tools: params.tools || undefined,
}),
})
}
async function streamResponseBody(source: ReadableStream<Uint8Array>, res: import('http').ServerResponse): Promise<void> {
const reader = source.getReader()
const decoder = new TextDecoder()
let done = false
while (!done) {
const { value, done: streamDone } = await reader.read()
done = streamDone
if (value) res.write(decoder.decode(value, { stream: true }))
}
res.end()
}
/** Proxy endpoint for Anthropic API calls (avoids browser CORS issues) */
function aiChatProxyPlugin(): Plugin {
return {
name: 'ai-chat-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url !== '/api/ai/chat' || req.method !== 'POST') return next()
try {
const body = await readRequestBody(req)
const params = JSON.parse(body)
if (!params.apiKey) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing API key' }))
return
}
const anthropicRes = await forwardToAnthropic(params)
if (!anthropicRes.ok) {
res.statusCode = anthropicRes.status
res.setHeader('Content-Type', 'application/json')
res.end(await anthropicRes.text())
return
}
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
if (anthropicRes.body) {
await streamResponseBody(anthropicRes.body, res)
} else {
res.end()
}
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
}
})
},
}
}
/** Agent proxy — non-streaming Anthropic calls with tool support */
function aiAgentProxyPlugin(): Plugin {
return {
name: 'ai-agent-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url !== '/api/ai/agent' || req.method !== 'POST') return next()
try {
const body = await readRequestBody(req)
const params = JSON.parse(body)
if (!params.apiKey) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing API key' }))
return
}
const anthropicRes = await forwardToAnthropicAgent(params)
res.statusCode = anthropicRes.status
res.setHeader('Content-Type', 'application/json')
res.end(await anthropicRes.text())
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal server error' }))
}
})
},
}
}
/** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */
function mcpBridgeInfoPlugin(): Plugin {
return {
name: 'mcp-bridge-info',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/api/mcp/info') return next()
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
wsUrl: `ws://localhost:${process.env.MCP_WS_PORT || 9710}`,
available: true,
}))
})
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), aiAgentProxyPlugin(), mcpBridgeInfoPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Inject the demo-vault-v2 path so browser code resolves it relative to the project root
define: {
__DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')),
},
// Prevent vite from obscuring Rust errors
clearScreen: false,
// Tauri expects a fixed port
server: {
port: 5202,
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',
'src/types.ts',
'src/hooks/useMcpBridge.ts',
'src/hooks/useAIChat.ts',
'src/hooks/useAiAgent.ts',
'src/utils/ai-chat.ts',
'src/utils/ai-agent.ts',
'src/components/AIChatPanel.tsx',
'src/components/ui/dropdown-menu.tsx',
'src/components/ui/scroll-area.tsx',
'src/components/ui/select.tsx',
'src/components/ui/separator.tsx',
'src/components/ui/tabs.tsx',
'src/components/ui/tooltip.tsx',
'src/components/ui/card.tsx',
],
thresholds: {
lines: 70,
functions: 70,
branches: 70,
statements: 70,
},
},
},
})