diff --git a/official-plugins/docs/README.md b/official-plugins/docs/README.md index 354906313..abb173174 100644 --- a/official-plugins/docs/README.md +++ b/official-plugins/docs/README.md @@ -27,6 +27,10 @@ provider, and directive are all Docs. `.markdown` files, so it can be selected under Settings → File openers or chosen from a file link's Open with menu. Workspace and absolute host files retain compare-and-swap saves even when they are outside a Docs vault. +- **YAML frontmatter:** fenced frontmatter supplies the document title when it + has a string `title`, stays out of the rendered body and search preview, and + is preserved byte-for-byte when the rich editor saves body changes. Docs + also leaves frontmatter-managed filenames unchanged on save. - **Tables:** GitHub-flavored Markdown tables render as editable cells. Use Tab and Shift+Tab to move between cells (Tab from the final cell adds a row), and drag column boundaries to resize them. Saves remain portable Markdown. diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 509d245c7..476473e17 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -101,6 +101,24 @@ describe("Docs nav panel", () => { }); }); + it("keeps the vault loading state clear of top-left app chrome", () => { + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "" }, + { rpc: { listNotes: () => new Promise(() => undefined) } }, + ); + + const loading = slot.getByText("Loading vaults…"); + expect(loading.className.split(/\s+/)).toEqual( + expect.arrayContaining([ + "flex", + "flex-1", + "items-center", + "justify-center", + ]), + ); + }); + it("moves the sidebar toggle into the shared panel header", async () => { const panel = app.navPanels[0]!; const slot = renderSlot( @@ -429,6 +447,59 @@ describe("Docs nav panel", () => { }); }); + it("hides and preserves YAML frontmatter when editing a document", async () => { + const frontmatter = [ + "---\r\n", + "title: Wiki page\r\n", + "type: knowledge\r\n", + "---\r\n", + ].join(""); + const saveNote = vi.fn((_input: unknown) => ({ + outcome: "written", + sha256: "next-sha", + })); + const slot = renderSlot( + app.navPanels[0]!, + { subPath: "personal/wiki-page.md" }, + { + rpc: { + listNotes: () => + listNotesResult([ + { + path: "wiki-page.md", + title: "Wiki page", + preview: "Original body.", + modifiedAtMs: 1, + }, + ]), + readNote: () => ({ + content: `${frontmatter}# Wiki page\r\n\r\nOriginal body.`, + sha256: "sha", + }), + preparePreview: () => preview, + renameToTitle: () => ({ path: "wiki-page.md" }), + saveNote, + }, + }, + ); + + const body = await slot.findByText("Original body."); + const editor = slot.container.querySelector(".tiptap"); + expect(editor?.textContent).not.toContain("type: knowledge"); + expect(editor?.querySelector("hr")).toBeNull(); + + body.textContent = "Edited body."; + fireEvent.input(body); + await waitFor(() => expect(saveNote).toHaveBeenCalled(), { + timeout: 2_000, + }); + expect(saveNote.mock.calls.at(-1)?.[0]).toMatchObject({ + content: expect.stringMatching( + /^---\r\ntitle: Wiki page\r\ntype: knowledge\r\n---\r\n# Wiki page\n\nEdited body\./, + ), + }); + }); + it("renders nested folders, images, and sandboxed HTML directives", async () => { const slot = renderSlot( app.navPanels[0]!, diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index 1bff776f8..4b40320d9 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -20,6 +20,7 @@ import { type PluginThreadPanelProps, } from "@bb/plugin-sdk/app"; import type { docsRpcContract } from "./server.js"; +import { parseMarkdownDocument } from "./markdown-document.js"; import { Editor, Extension, @@ -503,6 +504,7 @@ function TiptapEditor({ useEffect(() => { ensureEditorStyles(); if (!rootRef.current) return; + const markdownDocument = parseMarkdownDocument(initialValue); let editor: Editor; const upload = async (file: File) => { if (!file.type.startsWith("image/")) return false; @@ -539,7 +541,7 @@ function TiptapEditor({ linkify: true, }), ], - content: displayMarkdown(initialValue, previewBaseUrl, notePath), + content: displayMarkdown(markdownDocument.body, previewBaseUrl, notePath), autofocus: "end", editorProps: { handlePaste(_view, event) { @@ -562,6 +564,7 @@ function TiptapEditor({ }, }); const getMarkdown = () => + markdownDocument.frontmatter + storedMarkdown( editor.storage.markdown.getMarkdown(), previewBaseUrl, @@ -1941,7 +1944,12 @@ function NotesPanel({ subPath }: PluginNavPanelProps) { if (!data || !activeVaultId) return ( -
Loading vaults…
+
+ Loading vaults… +
); const selectedFolder = filePath ? dirname(filePath) : ""; diff --git a/official-plugins/docs/markdown-document.ts b/official-plugins/docs/markdown-document.ts new file mode 100644 index 000000000..1e15e7d8b --- /dev/null +++ b/official-plugins/docs/markdown-document.ts @@ -0,0 +1,66 @@ +import { parse } from "yaml"; + +export interface MarkdownDocument { + frontmatter: string; + body: string; + title: string | null; +} + +interface MarkdownLine { + start: number; + nextStart: number; + text: string; +} + +function readLine(content: string, start: number): MarkdownLine { + const newline = content.indexOf("\n", start); + const end = newline === -1 ? content.length : newline; + const textEnd = end > start && content[end - 1] === "\r" ? end - 1 : end; + return { + start, + nextStart: newline === -1 ? content.length : newline + 1, + text: content.slice(start, textEnd), + }; +} + +function parseFrontmatterTitle(source: string): string | null { + try { + const metadata: unknown = parse(source, { maxAliasCount: 20 }); + if ( + typeof metadata !== "object" || + metadata === null || + Array.isArray(metadata) + ) { + return null; + } + const title = (metadata as Record).title; + return typeof title === "string" && title.trim() ? title.trim() : null; + } catch { + return null; + } +} + +export function parseMarkdownDocument(content: string): MarkdownDocument { + const firstLineStart = content.startsWith("\uFEFF") ? 1 : 0; + const firstLine = readLine(content, firstLineStart); + if (firstLine.text !== "---") { + return { frontmatter: "", body: content, title: null }; + } + + let lineStart = firstLine.nextStart; + while (lineStart < content.length) { + const line = readLine(content, lineStart); + if (line.text === "---" || line.text === "...") { + const frontmatterSource = content.slice(firstLine.nextStart, line.start); + return { + frontmatter: content.slice(0, line.nextStart), + body: content.slice(line.nextStart), + title: parseFrontmatterTitle(frontmatterSource), + }; + } + if (line.nextStart === lineStart) break; + lineStart = line.nextStart; + } + + return { frontmatter: "", body: content, title: null }; +} diff --git a/official-plugins/docs/package.json b/official-plugins/docs/package.json index 6d5cf1a9d..4d1dfaeba 100644 --- a/official-plugins/docs/package.json +++ b/official-plugins/docs/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-simple-notes", - "version": "0.2.0", + "version": "0.2.2", "description": "Create and edit Markdown documents across local and connected-host vaults.", "private": true, "license": "MIT", @@ -80,6 +80,7 @@ "@tiptap/pm": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "tiptap-markdown": "^0.8.10", + "yaml": "^2.8.2", "zod": "^4.3.6" } } diff --git a/official-plugins/docs/server.test.ts b/official-plugins/docs/server.test.ts index 601835f32..a0c32e009 100644 --- a/official-plugins/docs/server.test.ts +++ b/official-plugins/docs/server.test.ts @@ -375,6 +375,70 @@ describe("Docs mention provider", () => { ]); }); + it("ignores YAML frontmatter when deriving note titles and previews", async () => { + const { harness } = await loadNotebook({ + "defensibility.md": [ + "---", + "type: knowledge", + "summary: Product strategy metadata", + "---", + "# AI application-layer defensibility", + "", + "Durable product strategy.", + ].join("\n"), + }); + const provider = harness.registrations.mentionProviders[0]!; + + await expect( + provider.search({ + trigger: "@", + query: "defensibility", + projectId: null, + threadId: null, + }), + ).resolves.toEqual([ + { + id: "personal:defensibility.md", + title: "AI application-layer defensibility", + subtitle: "Personal · Durable product strategy.", + icon: "FileText", + }, + ]); + }); + + it("uses a YAML frontmatter title when the body has no document heading", async () => { + const { harness } = await loadNotebook({ + "california-report.md": [ + "---", + 'title: "6th Annual Report: Evaluation of California\'s Caregiver Services"', + "type: knowledge", + "---", + "## Key findings used in wiki", + "", + "CareNav assessments identify unmet caregiver needs.", + ].join("\n"), + }); + const provider = harness.registrations.mentionProviders[0]!; + + await expect( + provider.search({ + trigger: "@", + query: "california", + projectId: null, + threadId: null, + }), + ).resolves.toEqual([ + { + id: "personal:california-report.md", + title: + "6th Annual Report: Evaluation of California's Caregiver Services", + subtitle: + "Personal · CareNav assessments identify unmet caregiver needs.", + icon: "FileText", + }, + ]); + }); + it("resolves the note's current content at send time", async () => { const { harness } = await loadNotebook({ "ideas.md": "# Fresh Ideas\n\nBuild the mention flow.", @@ -422,6 +486,26 @@ describe("Docs vault operations", () => { ).rejects.toThrow('unknown setting "directory"'); }); + it("keeps a frontmatter-managed document at its existing path", async () => { + const { harness } = await loadNotebook({ + "stable-wiki-slug.md": [ + "---", + "title: A different display title", + "type: knowledge", + "---", + "# A different display title", + ].join("\n"), + }); + + await expect( + harness.callRpc("renameToTitle", { + vaultId: "personal", + path: "stable-wiki-slug.md", + }), + ).resolves.toEqual({ path: "stable-wiki-slug.md" }); + expect(harness.sdk.callsTo("files.move")).toEqual([]); + }); + it("registers the agent-discoverable Docs CLI", async () => { const { harness } = await loadNotebook({ "plan.md": "# Plan" }); expect(harness.registrations.cli).toMatchObject({ diff --git a/official-plugins/docs/server.ts b/official-plugins/docs/server.ts index dc6370412..82c68a8b6 100644 --- a/official-plugins/docs/server.ts +++ b/official-plugins/docs/server.ts @@ -2,6 +2,7 @@ import { watch, type FSWatcher } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { parseMarkdownDocument } from "./markdown-document.js"; import { defineRpcContract, type BbPluginApi, @@ -557,22 +558,54 @@ function cleanLine(line: string): string { .trim(); } -function deriveTitle(content: string, fallback: string): string { - for (const line of content.split("\n")) { - const stripped = cleanLine(line); - if (stripped && !stripped.startsWith("::html{")) - return stripped.slice(0, 120); - } - return fallback; +function markdownHeadingLevel(line: string): number | null { + const match = line.match(/^\s{0,3}(#{1,6})(?:[\t ]+|$)/); + return match ? match[1]!.length : null; } -function derivePreview(content: string, title: string): string { - return content - .split("\n") - .map(cleanLine) - .filter((line) => line && line !== title && !line.startsWith("::html{")) +function summarizeMarkdown( + content: string, + fallback: string, +): { title: string; preview: string } { + const document = parseMarkdownDocument(content); + const lines = document.body.split("\n"); + const cleanedLines = lines.map(cleanLine); + const firstContentIndex = cleanedLines.findIndex( + (line) => line && !line.startsWith("::html{"), + ); + const documentHeadingIndex = document.frontmatter + ? lines.findIndex((line) => markdownHeadingLevel(line) === 1) + : -1; + const titleLineIndex = document.title + ? -1 + : documentHeadingIndex >= 0 + ? documentHeadingIndex + : firstContentIndex; + const title = ( + document.title ?? + (titleLineIndex >= 0 ? cleanedLines[titleLineIndex] : null) ?? + fallback + ).slice(0, 120); + const firstHeadingIndex = lines.findIndex( + (line) => markdownHeadingLevel(line) !== null, + ); + const previewHeadingIndex = + titleLineIndex >= 0 ? titleLineIndex : firstHeadingIndex; + const preview = cleanedLines + .filter( + (line, index) => + index !== previewHeadingIndex && + line && + line !== title && + !line.startsWith("::html{"), + ) .join(" ") .slice(0, PREVIEW_LENGTH); + return { title, preview }; +} + +function deriveTitle(content: string, fallback: string): string { + return summarizeMarkdown(content, fallback).title; } function kebabCase(text: string): string { @@ -833,11 +866,11 @@ export default async function plugin(bb: BbPluginApi) { rootPath: vault.rootPath, }); const fallback = path.posix.basename(notePath).replace(/\.md$/i, ""); - const title = deriveTitle(file.content, fallback); + const summary = summarizeMarkdown(file.content, fallback); notes.push({ path: notePath, - title, - preview: derivePreview(file.content, title), + title: summary.title, + preview: summary.preview, modifiedAtMs: file.modifiedAtMs ?? 0, }); } catch { @@ -1541,6 +1574,9 @@ export default async function plugin(bb: BbPluginApi) { const vaultId = input.vaultId; const currentPath = requireVaultPath(input.path, { extension: ".md" }); const file = await readFile(vaultId, currentPath); + if (parseMarkdownDocument(file.content).frontmatter) { + return { path: currentPath }; + } const base = kebabCase(deriveTitle(file.content, "")); if (!base) return { path: currentPath }; const parent = path.posix.dirname(currentPath); diff --git a/official-plugins/docs/tsconfig.json b/official-plugins/docs/tsconfig.json index efe0f3351..a2f02dfd2 100644 --- a/official-plugins/docs/tsconfig.json +++ b/official-plugins/docs/tsconfig.json @@ -13,6 +13,7 @@ "include": [ "server.ts", "server.test.ts", + "markdown-document.ts", "app.tsx", "app.test.tsx", "vitest.config.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4834e2b..c6c3d1317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1094,6 +1094,9 @@ importers: tiptap-markdown: specifier: ^0.8.10 version: 0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + yaml: + specifier: ^2.8.2 + version: 2.8.2 zod: specifier: 4.3.6 version: 4.3.6