Files
tolaria/vite.config.ts

997 lines
34 KiB
TypeScript
Raw Normal View History

/// <reference types="vitest/config" />
import type { IncomingMessage, ServerResponse } from 'http'
import path from 'path'
import {
closeSync,
fstatSync,
mkdirSync,
openSync,
opendirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
type Dirent,
} from 'fs'
import os from 'os'
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[] {
return collectWikiLinksFromValue(value, 0)
}
function collectWikiLinksFromValue(value: unknown, depth: number): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (!Array.isArray(value)) return []
const nestedLink = nestedFlowWikilink(value, depth)
if (nestedLink) return [nestedLink]
return value.flatMap((item) => collectWikiLinksFromValue(item, depth + 1))
}
function nestedFlowWikilink(value: unknown[], depth: number): string | null {
if (depth === 0 || value.length !== 1 || typeof value[0] !== 'string') return null
return extractWikiLinks(value[0]).length === 0 ? `[[${value[0]}]]` : null
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic properties/relationships)
const DEDICATED_KEYS = new Set([
'aliases', 'is_a', 'is a', 'type', 'status', 'title', '_archived',
'archived', '_icon', 'icon', 'color', '_order', 'order',
'_sidebar_label', 'sidebar_label', 'sidebar label', 'template',
'_sort', 'sort', 'view', '_width', 'width', 'visible',
'_organized', '_favorite', '_favorite_index', '_list_properties_display',
].map((key) => key.toLowerCase()))
type FrontmatterPropertyValue = string | number | boolean | null
type VaultSearchResult = { title: string; path: string; snippet: string; score: number; note_type: string | null }
interface SearchEntryInput {
excludeFrontmatter: boolean
entry: VaultEntry
query: string
rawContent: string
}
interface SearchRequestInput {
excludeFrontmatter: boolean
query: string
vaultPath: string
}
2026-05-07 21:49:35 +02:00
interface VaultCommandPayload {
args?: Record<string, unknown>
cmd?: string
}
interface VaultCommandContext {
args: Record<string, unknown>
cmd: string
}
interface VaultCommandResponse {
payload: unknown
statusCode?: number
}
type VaultCommandHandler = (context: VaultCommandContext) => VaultCommandResponse
interface CommandStringInput {
args: Record<string, unknown>
key: string
}
interface CommandResponseInput {
payload: unknown
statusCode?: number
}
2026-05-07 22:40:33 +02:00
interface VaultReadCommandInput {
cmd: string
pathname: string
req: IncomingMessage
2026-05-07 21:49:35 +02:00
res: ServerResponse
url: URL
}
interface TitleWikilinkUpdateInput {
excludePath: string
oldTitle: string
vaultPath: string
}
interface LegacyWikilinkTargetInput {
oldPath: string
oldTitle: string
vaultPath: string
}
interface WikilinkTargetUpdateInput {
excludePath: string
newTarget: string
oldTargets: string[]
vaultPath: string
}
interface PathWikilinkUpdateInput {
newPath: string
oldPath: string
oldTitle: string
vaultPath: string
}
function getFrontmatterValue(
frontmatter: Record<string, unknown>,
keys: string[],
): unknown {
const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()))
return Object.entries(frontmatter).find(([key]) => normalizedKeys.has(key.toLowerCase()))?.[1]
}
function parseYamlBool(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
if (typeof value !== 'string') return null
switch (value.toLowerCase()) {
case 'true':
case 'yes':
return true
case 'false':
case 'no':
return false
default:
return null
}
}
const vitestCoverageDirectory = process.env.VITEST_COVERAGE_DIR
?? path.join(os.tmpdir(), 'tolaria-vitest-coverage', String(process.pid))
2026-04-23 13:45:24 +02:00
const devServerWatchIgnored = [
'**/coverage/**',
'**/test-results/**',
'**/playwright-report/**',
'**/dist/**',
'**/src-tauri/target/**',
]
function readUtf8File(filePath: string): string {
const fd = openSync(filePath, 'r')
try {
return readFileSync(fd, 'utf-8')
} finally {
closeSync(fd)
}
}
function writeUtf8File(filePath: string, content: string): void {
const fd = openSync(filePath, 'w')
try {
writeFileSync(fd, content, 'utf-8')
} finally {
closeSync(fd)
}
}
function pathStats(filePath: string) {
const fd = openSync(filePath, 'r')
try {
return fstatSync(fd)
} finally {
closeSync(fd)
}
}
function pathExists(filePath: string): boolean {
try {
pathStats(filePath)
return true
} catch {
return false
}
}
function directoryEntries(dir: string): Dirent[] {
const directory = opendirSync(dir)
try {
const entries: Dirent[] = []
let entry = directory.readSync()
while (entry) {
entries.push(entry)
entry = directory.readSync()
}
return entries
} finally {
directory.closeSync()
}
}
function isInsideRelativePath(relative: string): boolean {
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
}
function resolveInside(root: string, target: string): string | null {
const normalizedTarget = path.normalize(target)
if (path.isAbsolute(normalizedTarget)) return null
const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`)
return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null
}
2026-04-23 13:45:24 +02:00
function frontmatterString(frontmatter: Record<string, unknown>, ...keys: string[]): string | null {
const value = getFrontmatterValue(frontmatter, keys)
return typeof value === 'string' ? value : null
}
2026-04-23 13:45:24 +02:00
function frontmatterStringArray(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
const value = getFrontmatterValue(frontmatter, keys)
if (Array.isArray(value)) return value.map(String)
if (typeof value === 'string') return [value]
return []
}
2026-04-23 13:45:24 +02:00
function frontmatterBool(frontmatter: Record<string, unknown>, ...keys: string[]): boolean | null {
return parseYamlBool(getFrontmatterValue(frontmatter, keys))
}
2026-04-23 13:45:24 +02:00
function markdownTitle(content: string, frontmatter: Record<string, unknown>, fallback: string): string {
const title = frontmatterString(frontmatter, 'title')
if (title) return title
2026-04-23 13:45:24 +02:00
const h1Match = content.match(/^#\s+(.+)$/m)
return h1Match ? h1Match[1].trim() : fallback
}
2026-04-23 13:45:24 +02:00
function markdownBodyText(content: string): string {
return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
}
2026-04-23 13:45:24 +02:00
function frontmatterWikiLinks(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value))
}
2026-04-23 13:45:24 +02:00
function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<string, string[]> {
const relationships: Record<string, string[]> = {}
for (const [key, value] of Object.entries(frontmatter)) {
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
const links = wikiLinksFromValue(value)
if (links.length > 0) relationships[key] = links
}
return relationships
}
function frontmatterProperties(frontmatter: Record<string, unknown>): Record<string, FrontmatterPropertyValue> {
const properties: Record<string, FrontmatterPropertyValue> = {}
for (const [key, value] of Object.entries(frontmatter)) {
if (DEDICATED_KEYS.has(key.toLowerCase()) || key.trim().startsWith('_')) continue
const propertyValue = frontmatterPropertyValue(value)
if (propertyValue !== undefined) properties[key] = propertyValue
}
return properties
}
function isScalarFrontmatterProperty(value: unknown): value is number | boolean {
return typeof value === 'number' || typeof value === 'boolean'
}
function singleStringArrayValue(value: unknown): string | undefined {
if (!Array.isArray(value)) return undefined
if (value.length !== 1) return undefined
return typeof value[0] === 'string' ? value[0] : undefined
}
function wikiLinkFreeString(value: string): string | undefined {
return extractWikiLinks(value).length === 0 ? value : undefined
}
function frontmatterPropertyValue(value: unknown): FrontmatterPropertyValue | undefined {
if (value === null) return null
if (isScalarFrontmatterProperty(value)) return value
if (typeof value === 'string') return wikiLinkFreeString(value)
const singleArrayValue = singleStringArrayValue(value)
return singleArrayValue === undefined ? undefined : wikiLinkFreeString(singleArrayValue)
}
2026-04-23 13:45:24 +02:00
function parseMarkdownFile(filePath: string): VaultEntry | null {
try {
const raw = readUtf8File(filePath)
const stats = pathStats(filePath)
2026-04-23 13:45:24 +02:00
const { data, content } = matter(raw)
const fm = data as Record<string, unknown>
2026-04-23 13:45:24 +02:00
const filename = path.basename(filePath)
const basename = filename.replace(/\.md$/, '')
const title = markdownTitle(content, fm, basename)
const bodyText = markdownBodyText(content)
const snippet = bodyText.slice(0, 200)
return {
path: filePath,
filename,
title,
2026-04-23 13:45:24 +02:00
isA: frontmatterString(fm, 'is_a', 'is a', 'type'),
aliases: frontmatterStringArray(fm, 'aliases'),
belongsTo: frontmatterWikiLinks(fm, 'belongs_to', 'belongs to'),
relatedTo: frontmatterWikiLinks(fm, 'related_to', 'related to'),
status: frontmatterString(fm, 'status'),
archived: frontmatterBool(fm, 'archived') ?? false,
trashed: frontmatterBool(fm, 'trashed') ?? false,
trashedAt: null,
modifiedAt: stats.mtimeMs,
2026-04-23 13:45:24 +02:00
createdAt: stats.birthtimeMs,
fileSize: stats.size,
snippet,
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
2026-04-23 13:45:24 +02:00
relationships: frontmatterRelationships(fm),
icon: frontmatterString(fm, 'icon'),
color: frontmatterString(fm, 'color'),
order: fm.order != null ? Number(fm.order) : null,
2026-04-23 13:45:24 +02:00
sidebarLabel: frontmatterString(fm, 'sidebar label', 'sidebar_label'),
template: frontmatterString(fm, 'template'),
sort: frontmatterString(fm, 'sort'),
view: frontmatterString(fm, 'view'),
visible: frontmatterBool(fm, 'visible'),
outgoingLinks: [],
properties: frontmatterProperties(fm),
}
} catch {
return null
}
}
/** Recursively find all .md files under a directory. */
function findMarkdownFiles(dir: string): string[] {
const results: string[] = []
try {
const items = directoryEntries(dir)
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = resolveInside(dir, item.name)
if (!full) continue
if (item.isDirectory()) {
results.push(...findMarkdownFiles(full))
} else if (item.name.endsWith('.md')) {
results.push(full)
}
}
} catch {
// skip unreadable dirs
}
return results
}
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
2026-05-07 21:49:35 +02:00
function commandString({ args, key }: CommandStringInput): string | null {
const value = Reflect.get(args, key)
return typeof value === 'string' && value.length > 0 ? value : null
}
function commandBool({ args, key }: CommandStringInput): boolean {
const value = Reflect.get(args, key)
return value === true || value === '1' || value === 'true'
}
2026-05-07 21:49:35 +02:00
function commandResponse({ payload, statusCode = 200 }: CommandResponseInput): VaultCommandResponse {
return { payload, statusCode }
}
function invalidPathResponse(): VaultCommandResponse {
return commandResponse({ payload: { error: 'Invalid or missing path' }, statusCode: 400 })
}
function existingCommandPath(input: CommandStringInput): string | null {
const filePath = commandString(input)
return filePath && pathExists(filePath) ? filePath : null
}
function commandVaultList({ args }: VaultCommandContext): VaultCommandResponse {
const dirPath = existingCommandPath({ args, key: 'path' })
if (!dirPath) return invalidPathResponse()
const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean)
return commandResponse({ payload: entries })
}
function commandVaultContent({ args }: VaultCommandContext): VaultCommandResponse {
const filePath = existingCommandPath({ args, key: 'path' })
if (!filePath) return invalidPathResponse()
return commandResponse({ payload: { content: readUtf8File(filePath) } })
}
function commandVaultAllContent({ args }: VaultCommandContext): VaultCommandResponse {
const dirPath = existingCommandPath({ args, key: 'path' })
if (!dirPath) return invalidPathResponse()
const contentMap: Record<string, string> = {}
for (const filePath of findMarkdownFiles(dirPath)) {
try {
Reflect.set(contentMap, filePath, readUtf8File(filePath))
} catch {
// Skip unreadable files.
}
}
return commandResponse({ payload: contentMap })
}
function commandVaultEntry({ args }: VaultCommandContext): VaultCommandResponse {
const filePath = existingCommandPath({ args, key: 'path' })
if (!filePath) return invalidPathResponse()
return commandResponse({ payload: parseMarkdownFile(filePath) })
}
function commandVaultSearch({ args }: VaultCommandContext): VaultCommandResponse {
const vaultPath = commandString({ args, key: 'vault_path' })
const query = (commandString({ args, key: 'query' }) ?? '').toLowerCase()
const mode = commandString({ args, key: 'mode' }) ?? 'all'
const excludeFrontmatter = commandBool({ args, key: 'exclude_frontmatter' })
const results = vaultPath && query
? collectVaultSearchResults({ vaultPath, query, excludeFrontmatter })
: []
2026-05-07 21:49:35 +02:00
return commandResponse({ payload: { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode } })
}
function commandVaultSave({ args }: VaultCommandContext): VaultCommandResponse {
const filePath = commandString({ args, key: 'path' })
const content = Reflect.get(args, 'content')
if (!filePath || typeof content !== 'string') return commandResponse({ payload: { error: 'Missing path or content' }, statusCode: 400 })
mkdirSync(path.dirname(filePath), { recursive: true })
writeUtf8File(filePath, content)
return commandResponse({ payload: null })
}
function commandVaultRename({ args }: VaultCommandContext): VaultCommandResponse {
const oldPath = commandString({ args, key: 'old_path' })
const newTitle = commandString({ args, key: 'new_title' })
if (!oldPath || !newTitle) return commandResponse({ payload: { error: 'Missing rename input' }, statusCode: 400 })
const vaultPath = commandString({ args, key: 'vault_path' })
const oldContent = readUtf8File(oldPath)
const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const newPath = markdownSiblingPath(oldPath, slug)
if (!newPath) return commandResponse({ payload: { error: 'Invalid title' }, statusCode: 400 })
writeUtf8File(newPath, oldContent.replace(/^# .+$/m, `# ${newTitle}`))
if (newPath !== oldPath) unlinkSync(oldPath)
const updatedFiles = vaultPath ? updateTitleWikilinks({ excludePath: newPath, oldTitle, vaultPath }) : 0
return commandResponse({ payload: { new_path: newPath, updated_files: updatedFiles } })
}
function commandVaultRenameFilename({ args }: VaultCommandContext): VaultCommandResponse {
const oldPath = commandString({ args, key: 'old_path' })
if (!oldPath) return commandResponse({ payload: { error: 'Missing old path' }, statusCode: 400 })
const filename = validateMarkdownFilenameStem(commandString({ args, key: 'new_filename_stem' }))
if (!filename.ok) return commandResponse({ payload: { error: filename.error }, statusCode: 400 })
const newPath = markdownSiblingPath(oldPath, filename.stem)
if (!newPath) return commandResponse({ payload: { error: 'Invalid filename' }, statusCode: 400 })
if (newPath !== oldPath && pathExists(newPath)) {
return commandResponse({ payload: { error: 'A note with that name already exists' }, statusCode: 409 })
}
const vaultPath = commandString({ args, key: 'vault_path' })
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
renameSync(oldPath, newPath)
const updatedFiles = vaultPath ? updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }) : 0
return commandResponse({ payload: { new_path: newPath, updated_files: updatedFiles } })
}
function commandVaultDelete({ args }: VaultCommandContext): VaultCommandResponse {
const filePath = commandString({ args, key: 'path' })
if (!filePath) return commandResponse({ payload: { error: 'Missing path' }, statusCode: 400 })
unlinkSync(filePath)
return commandResponse({ payload: filePath })
}
const VAULT_COMMAND_HANDLERS = new Map<string, VaultCommandHandler>([
['delete_note', commandVaultDelete],
['get_all_content', commandVaultAllContent],
['get_note_content', commandVaultContent],
['list_vault', commandVaultList],
['reload_vault', commandVaultList],
['reload_vault_entry', commandVaultEntry],
['rename_note', commandVaultRename],
['rename_note_filename', commandVaultRenameFilename],
['save_note_content', commandVaultSave],
['search_vault', commandVaultSearch],
['validate_note_content', commandVaultContent],
])
function runVaultCommand(context: VaultCommandContext): VaultCommandResponse {
const handler = VAULT_COMMAND_HANDLERS.get(context.cmd)
if (handler) return handler(context)
return commandResponse({ payload: { error: 'Unsupported vault command' }, statusCode: 404 })
}
function vaultCommandContext(payload: VaultCommandPayload): VaultCommandContext | null {
if (!payload.cmd || !payload.args) return null
return { cmd: payload.cmd, args: payload.args }
}
const VAULT_ENDPOINT_ARG_KEYS = ['path', 'vault_path', 'query', 'mode', 'reload', 'exclude_frontmatter'] as const
2026-05-07 22:40:33 +02:00
function readVaultQueryArgs(url: URL): Record<string, unknown> {
const args: Record<string, unknown> = {}
for (const key of VAULT_ENDPOINT_ARG_KEYS) {
const value = url.searchParams.get(key)
if (value !== null) Reflect.set(args, key, value)
}
return args
}
async function readVaultEndpointArgs(url: URL, req: IncomingMessage): Promise<Record<string, unknown>> {
if (req.method === 'POST') return readJsonBody<Record<string, unknown>>(req)
return readVaultQueryArgs(url)
}
async function handleVaultReadCommand(
{ cmd, pathname, req, res, url }: VaultReadCommandInput,
): Promise<boolean> {
if (url.pathname !== pathname) return false
if (req.method !== 'GET' && req.method !== 'POST') {
sendJson(res, { error: 'Unsupported method' }, 405)
return true
}
try {
const response = runVaultCommand({ args: await readVaultEndpointArgs(url, req), cmd })
sendJson(res, response.payload, response.statusCode)
} catch (err: unknown) {
sendCaughtError(res, err, 'Vault read failed')
}
return true
}
2026-05-07 21:49:35 +02:00
function updateTitleWikilinks({ excludePath, oldTitle, vaultPath }: TitleWikilinkUpdateInput): number {
2026-04-10 20:20:08 +02:00
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
2026-05-07 21:49:35 +02:00
const oldTargets = collectLegacyWikilinkTargets({ oldPath: excludePath, oldTitle, vaultPath })
return updateWikilinksForTargets({ excludePath, newTarget: newPathStem, oldTargets, vaultPath })
2026-04-10 20:20:08 +02:00
}
2026-05-07 21:49:35 +02:00
function collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath }: LegacyWikilinkTargetInput): string[] {
2026-04-10 20:20:08 +02:00
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
const oldFilenameStem = path.basename(oldPath, '.md')
return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))]
}
2026-05-07 21:49:35 +02:00
function updateWikilinksForTargets({ excludePath, newTarget, oldTargets, vaultPath }: WikilinkTargetUpdateInput): number {
2026-04-10 20:20:08 +02:00
if (oldTargets.length === 0) return 0
const allFiles = findMarkdownFiles(vaultPath)
const targets = new Set(oldTargets)
let updatedFiles = 0
for (const filePath of allFiles) {
if (filePath === excludePath) continue
try {
const content = readUtf8File(filePath)
const replaced = content.replace(/\[\[([^\]|]+)(\|[^\]]*)?\]\]/g, (match: string, target: string, pipe: string | undefined) => {
if (!targets.has(target)) return match
return pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]`
})
if (replaced !== content) {
writeUtf8File(filePath, replaced)
updatedFiles++
}
} catch {
// Skip unreadable files in the dev vault API.
}
}
return updatedFiles
}
2026-05-07 21:49:35 +02:00
function updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }: PathWikilinkUpdateInput): number {
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
2026-05-07 21:49:35 +02:00
const oldTargets = collectLegacyWikilinkTargets({ oldPath, oldTitle, vaultPath })
return updateWikilinksForTargets({ excludePath: newPath, newTarget: newRelativeStem, oldTargets, vaultPath })
}
function handleVaultPing(url: URL, res: ServerResponse): boolean {
if (url.pathname !== '/api/vault/ping') return false
sendJson(res, { ok: true })
return true
}
2026-05-07 22:40:33 +02:00
async function handleVaultList(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
return handleVaultReadCommand({ cmd: 'list_vault', pathname: '/api/vault/list', req, res, url })
}
2026-05-07 22:40:33 +02:00
async function handleVaultContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
return handleVaultReadCommand({ cmd: 'get_note_content', pathname: '/api/vault/content', req, res, url })
}
2026-05-07 22:40:33 +02:00
async function handleVaultAllContent(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
return handleVaultReadCommand({ cmd: 'get_all_content', pathname: '/api/vault/all-content', req, res, url })
}
2026-05-07 22:40:33 +02:00
async function handleVaultEntry(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
return handleVaultReadCommand({ cmd: 'reload_vault_entry', pathname: '/api/vault/entry', req, res, url })
}
2026-05-07 22:40:33 +02:00
async function handleVaultSearch(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
return handleVaultReadCommand({ cmd: 'search_vault', pathname: '/api/vault/search', req, res, url })
}
function collectVaultSearchResults({ excludeFrontmatter, vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
const results: VaultSearchResult[] = []
for (const filePath of findMarkdownFiles(vaultPath)) {
const entry = parseMarkdownFile(filePath)
if (!entry || entry.trashed) continue
const rawContent = readUtf8File(filePath)
if (entryMatchesSearch({ entry, rawContent, query, excludeFrontmatter })) {
results.push(searchResultFromEntry(entry))
}
}
return results.slice(0, 20)
}
function searchableSearchContent(rawContent: string, excludeFrontmatter: boolean): string {
return excludeFrontmatter ? matter(rawContent).content : rawContent
}
function entryMatchesSearch({ entry, excludeFrontmatter, rawContent, query }: SearchEntryInput): boolean {
const content = searchableSearchContent(rawContent, excludeFrontmatter)
return entry.title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
function searchResultFromEntry(entry: VaultEntry): VaultSearchResult {
return { title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }
}
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (!isPostRoute(url, req, '/api/vault/save')) return false
try {
await saveVaultContent(req, res)
} catch (err: unknown) {
sendCaughtError(res, err, 'Save failed')
}
return true
}
async function saveVaultContent(req: IncomingMessage, res: ServerResponse): Promise<void> {
const { path: filePath, content } = await readJsonBody<{ path?: string; content?: string }>(req)
if (!filePath || content === undefined) {
sendJson(res, { error: 'Missing path or content' }, 400)
return
}
mkdirSync(path.dirname(filePath), { recursive: true })
writeUtf8File(filePath, content)
sendJson(res, null)
}
async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (!isPostRoute(url, req, '/api/vault/rename')) return false
try {
await renameVaultNoteTitle(req, res)
} catch (err: unknown) {
sendCaughtError(res, err, 'Rename failed')
}
return true
}
async function renameVaultNoteTitle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const {
vault_path: vaultPath,
old_path: oldPath,
new_title: newTitle,
} = await readJsonBody<{ vault_path?: string; old_path: string; new_title: string }>(req)
const oldContent = readUtf8File(oldPath)
const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const newPath = markdownSiblingPath(oldPath, slug)
if (!newPath) {
sendJson(res, { error: 'Invalid title' }, 400)
return
}
writeUtf8File(newPath, oldContent.replace(/^# .+$/m, `# ${newTitle}`))
if (newPath !== oldPath) unlinkSync(oldPath)
2026-05-07 21:49:35 +02:00
const updatedFiles = vaultPath ? updateTitleWikilinks({ excludePath: newPath, oldTitle, vaultPath }) : 0
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
}
2026-04-23 13:45:24 +02:00
type FilenameStemValidation =
| { ok: true; stem: string }
| { ok: false; error: string }
function validateMarkdownFilenameStem(value: unknown): FilenameStemValidation {
const stem = String(value ?? '').trim().replace(/\.md$/i, '').trim()
if (!stem) return { ok: false, error: 'New filename cannot be empty' }
if (isUnsafeMarkdownFilenameStem(stem)) return { ok: false, error: 'Invalid filename' }
return { ok: true, stem }
}
function isUnsafeMarkdownFilenameStem(stem: string): boolean {
return stem === '.' || stem === '..' || stem.includes('/') || stem.includes('\\')
}
function markdownSiblingPath(filePath: string, stem: string): string | null {
if (isUnsafeMarkdownFilenameStem(stem)) return null
return resolveInside(path.dirname(filePath), `${stem}.md`)
}
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (!isPostRoute(url, req, '/api/vault/rename-filename')) return false
try {
await renameVaultNoteFilename(req, res)
} catch (err: unknown) {
sendCaughtError(res, err, 'Rename failed')
}
return true
}
async function renameVaultNoteFilename(req: IncomingMessage, res: ServerResponse): Promise<void> {
const {
vault_path: vaultPath,
old_path: oldPath,
new_filename_stem: newFilenameStem,
} = await readJsonBody<{ vault_path?: string; old_path: string; new_filename_stem: string }>(req)
const filename = validateMarkdownFilenameStem(newFilenameStem)
if (!filename.ok) {
sendJson(res, { error: filename.error }, 400)
return
}
const newPath = markdownSiblingPath(oldPath, filename.stem)
if (!newPath) {
sendJson(res, { error: 'Invalid filename' }, 400)
return
}
if (newPath !== oldPath && pathExists(newPath)) {
sendJson(res, { error: 'A note with that name already exists' }, 409)
return
}
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
renameSync(oldPath, newPath)
2026-05-07 21:49:35 +02:00
const updatedFiles = vaultPath ? updatePathWikilinks({ newPath, oldPath, oldTitle, vaultPath }) : 0
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
}
async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/delete' || req.method !== 'POST') return false
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
sendJson(res, { error: 'Missing path' }, 400)
return true
}
unlinkSync(filePath)
sendJson(res, filePath)
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500)
}
return true
}
2026-05-07 21:49:35 +02:00
async function handleVaultCommand(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (!isPostRoute(url, req, '/api/vault/command')) return false
try {
const context = vaultCommandContext(await readJsonBody<VaultCommandPayload>(req))
if (!context) {
sendJson(res, { error: 'Invalid vault command' }, 400)
return true
}
const response = runVaultCommand(context)
sendJson(res, response.payload, response.statusCode)
} catch (err: unknown) {
sendCaughtError(res, err, 'Vault command failed')
}
return true
}
async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
const handlers = [
() => Promise.resolve(handleVaultPing(url, res)),
2026-05-07 21:49:35 +02:00
() => handleVaultCommand(url, req, res),
2026-05-07 22:40:33 +02:00
() => handleVaultList(url, req, res),
() => handleVaultContent(url, req, res),
() => handleVaultAllContent(url, req, res),
() => handleVaultEntry(url, req, res),
() => handleVaultSearch(url, req, res),
() => handleVaultSave(url, req, res),
() => handleVaultRename(url, req, res),
() => handleVaultRenameFilename(url, req, res),
() => handleVaultDelete(url, req, res),
]
for (const handler of handlers) {
if (await handler()) return true
}
return false
}
function vaultApiPlugin(): Plugin {
return {
name: 'vault-api',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (await handleVaultApiRequest(req, res)) return
next()
})
},
}
}
// --- Proxy helpers ---
function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = ''
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
req.on('end', () => resolve(body))
})
}
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
return JSON.parse(await readRequestBody(req)) as T
}
function isPostRoute(url: URL, req: IncomingMessage, pathname: string): boolean {
return url.pathname === pathname && req.method === 'POST'
}
function sendCaughtError(res: ServerResponse, err: unknown, fallback: string): void {
sendJson(res, { error: err instanceof Error ? err.message : fallback }, 500)
}
/** 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(), mcpBridgeInfoPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Inject the demo-vault-v2 path in local dev only — production Tauri builds and
// CI must resolve the default vault path at runtime via the backend to avoid
// baking the CI runner's absolute path into the distributed bundle.
define: {
...(process.env.CI || (process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG)
? {}
: { __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,
2026-04-23 13:45:24 +02:00
watch: {
ignored: devServerWatchIgnored,
},
},
// 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}'],
2026-05-11 16:37:06 +02:00
// The full jsdom suite is heavy enough that unconstrained worker fan-out can
// starve UI tests on local dev machines. Keep the default hook path stable,
// while still allowing CI or one-off runs to opt into a different cap.
maxWorkers: process.env.VITEST_MAX_WORKERS ?? 4,
testTimeout: 10_000,
coverage: {
provider: 'v8',
2026-04-21 10:01:51 +02:00
reporter: ['text', 'json', 'html', 'lcov'],
// Keep coverage temp files off the mounted workspace to avoid flaky
// read-after-write races when Vitest re-reads its own coverage shards.
reportsDirectory: vitestCoverageDirectory,
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/useAiAgent.ts',
'src/utils/ai-chat.ts',
'src/utils/ai-agent.ts',
'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,
},
},
},
})