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:
@@ -25,3 +25,4 @@ export {
|
||||
canonicalizeContent,
|
||||
docsCanonicallyEqual,
|
||||
} from "./canonicalize";
|
||||
export { parsePageFile, serializePageFile } from "./page-file";
|
||||
|
||||
86
packages/git-sync/src/lib/page-file.ts
Normal file
86
packages/git-sync/src/lib/page-file.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user