diff --git a/vite.config.ts b/vite.config.ts index fe6a7b08..34149607 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,88 +88,96 @@ function parseYamlBool(value: unknown): boolean | null { const vitestCoverageDirectory = process.env.VITEST_COVERAGE_DIR ?? path.join(os.tmpdir(), 'tolaria-vitest-coverage', String(process.pid)) +const devServerWatchIgnored = [ + '**/coverage/**', + '**/test-results/**', + '**/playwright-report/**', + '**/dist/**', + '**/src-tauri/target/**', +] + +function frontmatterString(frontmatter: Record, ...keys: string[]): string | null { + const value = getFrontmatterValue(frontmatter, keys) + return typeof value === 'string' ? value : null +} + +function frontmatterStringArray(frontmatter: Record, ...keys: string[]): string[] { + const value = getFrontmatterValue(frontmatter, keys) + if (Array.isArray(value)) return value.map(String) + if (typeof value === 'string') return [value] + return [] +} + +function frontmatterBool(frontmatter: Record, ...keys: string[]): boolean | null { + return parseYamlBool(getFrontmatterValue(frontmatter, keys)) +} + +function markdownTitle(content: string, frontmatter: Record, fallback: string): string { + const title = frontmatterString(frontmatter, 'title') + if (title) return title + + const h1Match = content.match(/^#\s+(.+)$/m) + return h1Match ? h1Match[1].trim() : fallback +} + +function markdownBodyText(content: string): string { + return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim() +} + +function frontmatterWikiLinks(frontmatter: Record, ...keys: string[]): string[] { + return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value)) +} + +function frontmatterRelationships(frontmatter: Record): Record { + const relationships: Record = {} + 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 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 { data, content } = matter(raw) + const fm = data as Record 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 => { - const value = getFrontmatterValue(fm, keys) - return typeof value === 'string' ? value : null - } - - // Helper to get a frontmatter array-of-strings field - const getArray = (...keys: string[]): string[] => { - const value = getFrontmatterValue(fm, keys) - if (Array.isArray(value)) return value.map(String) - if (typeof value === 'string') return [value] - 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 title = markdownTitle(content, fm, basename) + const bodyText = markdownBodyText(content) 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 - } - } - - // 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 => { - return parseYamlBool(getFrontmatterValue(fm, keys)) - } - 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, + 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, - createdAt, + createdAt: stats.birthtimeMs, fileSize: stats.size, snippet, wordCount: bodyText.split(/\s+/).filter(Boolean).length, - relationships, - icon: getString('icon'), - color: getString('color'), + relationships: frontmatterRelationships(fm), + icon: frontmatterString(fm, 'icon'), + color: frontmatterString(fm, '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'), + 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: {}, } @@ -365,6 +373,21 @@ async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResp return true } +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('\\') +} + async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise { if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false try { @@ -374,17 +397,13 @@ async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: Se old_path: oldPath, new_filename_stem: newFilenameStem, } = JSON.parse(body) - const trimmed = String(newFilenameStem ?? '').trim().replace(/\.md$/i, '').trim() - if (!trimmed) { - sendJson(res, { error: 'New filename cannot be empty' }, 400) - return true - } - if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) { - sendJson(res, { error: 'Invalid filename' }, 400) + const filename = validateMarkdownFilenameStem(newFilenameStem) + if (!filename.ok) { + sendJson(res, { error: filename.error }, 400) return true } - const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`) + const newPath = path.join(path.dirname(oldPath), `${filename.stem}.md`) const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md') if (newPath !== oldPath && fs.existsSync(newPath)) { sendJson(res, { error: 'A note with that name already exists' }, 409) @@ -505,6 +524,9 @@ export default defineConfig({ port: 5202, strictPort: true, allowedHosts: true, + watch: { + ignored: devServerWatchIgnored, + }, }, // Env variables starting with TAURI_ are exposed to the frontend