feat(git-sync): native-Obsidian format — phase 1 = page-file (frontmatter gitmost_id)

Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean
markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels
with the file so identity survives any move); folders mirror the page tree with
the parent's body as a folder-note `<Folder>/<Folder>.md` (LostPaul Folder Notes
convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a
link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/`
and non-page files left untouched (no .gitignore). Verified the conventions
against the Obsidian/Folder-Notes docs.

Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to
git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts.
Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean
body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine
suite green. Not yet wired into pull/push — no behavior change. Design doc
rewritten to the native-Obsidian format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 04:25:04 +03:00
parent d163f43e12
commit d8007480ac
7 changed files with 226 additions and 389 deletions

View File

@@ -25,3 +25,4 @@ export {
canonicalizeContent,
docsCanonicallyEqual,
} from "./canonicalize";
export { parsePageFile, serializePageFile } from "./page-file";

View File

@@ -0,0 +1,86 @@
import { parseDocmostMarkdown } from "./markdown-document";
/**
* The THIN page-file format (design: docs/backlog/git-sync-thin-meta.md, option
* C). A page file is CLEAN markdown with a minimal YAML frontmatter carrying ONLY
* the page's durable identity:
*
* ---
* id: 019ef6fc-2638-7ce1-9ce3-2756ce038480
* ---
* <clean markdown body>
*
* Everything else is derived (title = filename, parentPageId = enclosing folder,
* spaceId = the vault, updatedAt = git). The `id` (a Docmost pageId) is the only
* non-derivable bit and travels WITH the file so identity survives any move,
* even one git's rename detection misses. Third-party editors (Obsidian, …) see
* clean markdown; the frontmatter is hidden in their preview.
*
* MIGRATION: a file may still carry the LEGACY `<!-- docmost:meta {…} -->` block
* (the pre-thin format). `parsePageFile` reads the id from the frontmatter first,
* then falls back to the legacy meta — so old vaults keep working and a re-sync
* rewrites them into the thin format.
*/
/**
* The frontmatter key carrying the Docmost pageId. NAMESPACED (not a bare `id`)
* so it never collides with a user's own frontmatter fields.
*/
export const ID_KEY = "gitmost_id";
/** Leading YAML frontmatter block: `---\n…\n---` at the very start of the file. */
const FRONTMATTER_RE = /^?---\n([\s\S]*?)\n---\n?/;
/** The top-level `<ID_KEY>: <value>` line inside the frontmatter (quotes optional). */
function readIdFromYaml(yaml: string): string | null {
const re = new RegExp(`^${ID_KEY}:\\s*(.+?)\\s*$`);
for (const line of yaml.split("\n")) {
const m = line.match(re);
if (m) {
const v = m[1].trim().replace(/^["']|["']$/g, "");
return v === "" ? null : v;
}
}
return null;
}
/**
* Parse a page file into its identity (`id`) and clean markdown `body`. Tolerant:
* a file with neither frontmatter nor legacy meta (a hand-written third-party
* file) returns `id: null` and the whole text as the body — the caller then
* ADOPTS it (creates a page, writes the id back).
*/
export function parsePageFile(full: string): {
id: string | null;
body: string;
} {
const text = (full ?? "").replace(/\r\n/g, "\n");
// 1. Thin format: YAML frontmatter.
const fm = text.match(FRONTMATTER_RE);
if (fm) {
return { id: readIdFromYaml(fm[1]), body: text.slice(fm[0].length).trim() };
}
// 2. Legacy format: `<!-- docmost:meta -->` block (migration fallback).
if (/^\s*<!--\s*docmost:meta/.test(text)) {
try {
const { meta, body } = parseDocmostMarkdown(text);
return { id: meta?.pageId ?? null, body };
} catch {
// a corrupt legacy block -> treat as an un-tracked plain file (adopt).
}
}
// 3. Plain markdown — un-tracked (no identity yet).
return { id: null, body: text.trim() };
}
/**
* Serialize a page into the thin format: `id` frontmatter + a blank line + the
* clean body + a trailing newline. Deterministic so an unchanged page re-syncs to
* byte-identical output (no churn — the loop-guard relies on it).
*/
export function serializePageFile(id: string, body: string): string {
return `---\n${ID_KEY}: ${id}\n---\n\n${body.trim()}\n`;
}