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