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 1ace904221
commit 074ef25db5
7 changed files with 226 additions and 389 deletions

View File

@@ -1,180 +1,132 @@
# git-sync: thin meta + third-party-editor support
# git-sync: native-Obsidian vault format
Статус: **дизайн (согласован с владельцем 2026-06-24), к реализации.**
## Зачем
## Цель
Сейчас каждый `.md` в волте несёт служебный блок
`<!-- docmost:meta {version,pageId,slugId,title,spaceId,parentPageId} -->` и
`<!-- docmost:comments [...] -->`. Сторонние редакторы (Obsidian + obsidian-git —
основной кейс) про это не знают: они кладут «голые» markdown-файлы и папки, а
движок их МОЛЧА игнорирует (нет pageId → не страница), плюс мета сорит в каждом
файле.
Цель: `.md` остаются **чистым markdown** (любой редактор доволен), вся служебка
живёт в одном сайдкаре `.gitmost/index.json`, а дерево страниц **зеркалится
структурой папок**. Голые файлы/папки от стороннего редактора **адоптируются** в
Волт спейса должен быть **настоящим Obsidian-волтом**: владелец открывает папку в
Obsidian (с плагином Folder Notes) и получает ровно ту же структуру страниц, не
замечая разницы. Никаких служебных артефактов, которые бы выглядели чужеродно.
Сторонние редакторы кладут «голые» файлы/папки — движок их **адоптирует** в
страницы Docmost.
## Решённые принципы
Сейчас каждый `.md` несёт жирный `<!-- docmost:meta {…} -->` блок — это уезжает.
1. **Идентичность — стабильный id, не имя файла и не путь.** (Подпёрто фиксом
5133bb34: страница с известным id, чей файл переехал, никогда не удаляется —
только move.) `pageId` нужен как durable мост файл↔Docmost-страница: title,
путь, parent — всё мутабельно, id — нет; без него re-clone/rename плодят дубли.
2. **id ЕДЕТ ВМЕСТЕ С ФАЙЛОМ.** Критично: path-keyed сайдкар (`index.json` по
пути) НЕ годится источником истины — если git не распознал rename (контент
менялся вместе с именем, наш кейс), путь-ключ протухает и страница теряется.
Поэтому id хранится так, что переживает любой move:
- **Вариант B** — slugId суффиксом в ИМЕНИ файла (`Заметка ~Cj7YX7.md`).
Ноль меты в контенте; имена «грязные»; адопция переименовывает файл юзера.
- **Вариант C** — минимальный YAML-frontmatter `id:` в начале файла. Имена
чистые; frontmatter скрыт в Obsidian; адопция имя не трогает (только дописывает).
- **ОТКРЫТО:** B или C (см. ниже). Обе устойчивы. Сайдкар может остаться как
опциональный КЭШ path→id, но не источник истины.
Всё остальное (title/parent/spaceId/version) из файла убираем — выводимо.
3. **Папка = страница-родитель**, дерево зеркальное 1:1.
4. **Контент родителя — внутри его папки**, файлом-индексом (folder-note).
## Открытое решение: B vs C (носитель id)
Различие свелось к UX, устойчивость равная (id в обоих едет с файлом):
- **B (id в имени):** проще в реализации (парс суффикса), ноль меты в контенте, но
Obsidian показывает ` ~slug` как заголовок заметки, и адопция переименовывает
голый файл (→ может поехать `[[ссылка]]`).
- **C (frontmatter `id:`):** чистые имена, скрытый нативный YAML, адопция имя не
трогает (дописывает строку). Цена — одна строка «меты» в файле.
- Рекомендация автора — **C** (чистые имена важнее, frontmatter идиоматичен), но
это вкусовщина, решает владелец.
- NB: ссылки между заметками ломаются при rename в ЛЮБОМ варианте (title=имя
файла) — это не различает B/C, это отдельная фаза (см. ниже).
## Формат волта
## Формат
```
<Space-vault>/
.gitmost/
index.json # вся служебная мета (см. ниже)
Заметка.md # лист без детей: <title>.md, чистый markdown
Проект/ # страница-родитель = папка
index.md # ТЕЛО самой страницы «Проект» (folder-note)
Заметка.md # лист: чистый markdown + frontmatter id
Проект/ # страница-родитель = ПАПКА
Проект.md # folder-note: ТЕЛО самой страницы «Проект»
Задача.md # ребёнок
Подпроект/
index.md # тело «Подпроект»
Подпроект.md # тело «Подпроект»
...
.obsidian/ # конфиг Obsidian — движок НЕ ТРОГАЕТ
```
Каждый файл страницы:
```
---
gitmost_id: 019ef6fc-2638-7ce1-9ce3-2756ce038480
---
<чистый markdown — тело страницы (wiki-ссылки, всё как в Obsidian)>
```
- **Лист** (нет детей) → `<title>.md`.
- **Родитель** (есть дети) → папка `<title>/`, тело в `<title>/index.md`.
Когда у листа появляется первый ребёнок — `<title>.md` превращается в
`<title>/index.md` (это безопасный move по pageId).
- **Имя файла** = санитизированный title + `.md`. Родитель чьё имя = имя папки.
- **Коллизии** (2+ сиблинга с одним title): всем коллидирующим — суффикс
` ~<slugId>` (`Заметка ~Cj7YX7.md`). slugId короткий и стабильный (из Docmost),
суффикс детерминирован по самой странице, не по порядку. Смена суффикса —
безопасный rename (идентичность в индексе по pageId).
- `index.md` внутри папки «занят» телом родителя; ребёнок с title «index»
получает `index ~slug.md`.
- **Родитель** (есть дети) → папка `<title>/`, его тело в `<title>/<title>.md`
(folder-note по конвенции плагина LostPaul Folder Notes — заметка с именем
папки внутри неё). Лист, у которого появился первый ребёнок, превращается из
`<title>.md` в `<title>/<title>.md` (безопасный move по id).
- **title** = имя файла (для папки — имя папки). **parentPageId** = ближайшая
родительская папка (её folder-note). **spaceId** = эта репа. Всё выводимо.
- **Идентичность** — `gitmost_id` (= Docmost pageId) во frontmatter. Невыводима,
едет ВМЕСТЕ с файлом → переживает любой move, даже не распознанный git как
rename. (Ключ namespaced `gitmost_id`, не голый `id`, чтобы не конфликтовать с
пользовательскими frontmatter-полями. Имя ключа — последнее на подтверждении.)
- **Коллизии имён** (2+ сиблинга с одним title): как делает сам Obsidian —
добавляем натуральный суффикс ` 2`, ` 3`. id во frontmatter, так что имя файла
чисто косметическое; смена суффикса — безопасный rename (идентичность по id).
### `.gitmost/index.json`
Никакого `.gitmost/index.json` (сайдкар отвергнут: path-keyed индекс хрупок к
rename; id во frontmatter самодостаточен). Никаких `docmost:meta`/`docmost:comments`
блоков (комменты и так живут инлайн-марками `<span data-comment-id>` в теле).
```jsonc
{
"version": 1,
"spaceId": "019…", // спейс = эта репа (одна на спейс)
"pages": {
// ключ — vault-relative путь файла (forward-slash)
"Проект/index.md": { "pageId": "019…", "slugId": "Ab12Cd" },
"Проект/Задача.md": { "pageId": "019…", "slugId": "Ef34Gh" },
"Заметка ~Cj7YX7.md": { "pageId": "019…", "slugId": "Cj7YX7" }
}
}
```
## Ссылки между заметками (`[[wikilinks]]`)
- `title` — выводим из имени файла (stem), для папки — из имени папки.
- `parentPageId` — выводим из ПУТИ (родитель = `index.md` ближайшей родительской
папки; корневой файл → parent=null). Это уже path-as-truth на пуше сегодня.
- `version`/`updatedAt` — не храним: факт правки берётся из git.
- В индексе — только то, что НЕ выводимо: `pageId` (идентичность) и `slugId`
(дизамбигуация коллизий, write-back при create).
Obsidian резолвит `[[Заметка]]` по **basename** (не по полному пути), нормализуя
пробелы/`-`/`_`, с приоритетом короткого пути при неоднозначности.
- В Docmost ссылки — по pageId (mention/reference node), rename переживают.
- В волте — обсидиановские `[[basename]]`.
- Следствие: **reparent (смена папки) ссылку НЕ ломает** (basename тот же),
ломает только **retitle**. Значит переписывать `[[…]]` надо только при смене
имени страницы — узкий случай. (Obsidian сам умеет «update links on rename».)
- Конвертер Docmost-mention ↔ `[[wikilink]]` (обе стороны) + переписывание при
retitle — **отдельная фаза** (см. план), не блокирует формат.
## PULL (Docmost → vault)
1. Прочитать дерево спейса (как сейчас) + текущий `.gitmost/index.json`.
2. Построить layout: лист→`<t>.md`, родитель→`<t>/index.md`, коллизии→` ~slug`.
3. Записать ЧИСТЫЕ `.md` (только тело) + обновить `index.json` (path→{pageId,slugId}).
4. Старые `index.md`/файлы переехавших страниц — move (по pageId), не delete.
5. Коммит на `docmost`, merge в `main` (как сейчас).
1. Прочитать дерево спейса.
2. Layout: лист→`<t>.md`, родитель→`<t>/<t>.md`, коллизии→` 2`/` 3`.
3. Записать `---\ngitmost_id: …\n---\n<тело>` (чистый markdown).
4. Переехавшие файлы — move (по id), не delete.
5. Коммит на `docmost`, merge в `main`.
## PUSH (vault → Docmost)
1. Дифф `last-pushed..main` (как сейчас).
2. Идентичность файла берём из `index.json` (path→pageId), НЕ из меты в файле.
3. Родитель — из пути (enclosing `<folder>/index.md` → его pageId из индекса).
4. Классификация:
- файл/папка ЕСТЬ в индексе → update/move/rename по pageId (как сейчас, но
идентичность из индекса).
- файла НЕТ в индексе (новый голый файл) → **adopt**: create page
(title=stem, parent=из пути), записать pageId/slugId в индекс + (если
коллизия) переименовать файл с суффиксом.
- голая папка с детьми, но без своей записи → создать страницу-родитель
(пустое тело), завести `<folder>/index.md`, добавить в индекс.
- файл пропал, а pageId ещё в дереве/индексе под другим путём → move (фикс уже
есть). Реально пропал отовсюду → delete (как сейчас, под delete-cap).
1. Дифф `last-pushed..main`.
2. Идентичность файла — из frontmatter `gitmost_id`. Родитель — из пути (folder-note
родительской папки).
3. Классификация:
- есть `gitmost_id` в дереве → update/move/rename по id (страховка 5133bb34).
- нет id (новый голый файл от Obsidian) → **adopt**: create page (title=имя,
parent=папка), дописать `gitmost_id` во frontmatter.
- голая папка с детьми без folder-note → создать страницу-родитель, завести
`<folder>/<folder>.md`.
- файл пропал, а id ещё в дереве под другим путём → move. Реально пропал →
delete (под delete-cap).
## Адопция (третья-сторона → Docmost)
- голый `.md` без frontmatter id → create page.
- голая папка с `.md` внутри без folder-note → create страницу-родитель + folder-note.
- `.obsidian/`, аттачменты, dot-файлы, любые не-`.md`**игнор** (не страницы),
лежат в гите как есть, Obsidian ими владеет. Без `.gitignore`.
## Миграция со старого формата
Существующие волты несут `docmost:meta` в файлах и НЕ имеют `.gitmost/index.json`.
Существующие волты несут `docmost:meta` в файлах.
- На первом цикле нового движка: если `.gitmost/index.json` нет — построить его из
`docmost:meta`, прочитанных по файлам, затем **переписать** файлы без меты
(один разовый «normalize» коммит) и разложить в folder-note layout.
- **Фолбэк навсегда**: если у файла нашлась `docmost:meta`, а в индексе записи нет
— уважаем мету (на случай файла, принесённого со старой системой).
- На первом цикле нового движка: если у файла нет frontmatter, но есть
`docmost:meta` → читаем pageId оттуда, переписываем файл в native-формат
(frontmatter id + чистое тело + folder-note layout), разовый «normalize» коммит.
- **Фолбэк навсегда**: `docmost:meta` всё ещё парсится как источник id, если
frontmatter нет (файл со старой системы). (Реализовано в `parsePageFile`.)
## Краевые случаи
- Git не хранит пустые папки → «омит файла родителя» работает только пока есть
дети (папка жива за счёт детей). Childless пустая страница → свой `<t>.md`.
- `.gitmost/` и `.obsidian/` (и любые dot-папки) — игнор движком; добавить
дефолтный `.gitignore` для `.obsidian/` при инициализации волта.
- Конфликт `index.md` (folder-note) с ребёнком title «index» → ребёнку суффикс.
- Переименование папки (= rename родителя) → move всего поддерева, идентичность по
pageId из индекса, без delete+create.
- Git не хранит пустые папки → «родитель без своего файла» невозможен: тело
родителя — это folder-note `<t>/<t>.md`, он и держит папку (плюс дети). Childless
пустая страница → просто `<t>.md`.
- Конфликт folder-note `Папка/Папка.md` с ребёнком title «Папка» → ребёнку суффикс.
- Переименование папки (= rename родителя) → move всего поддерева по id, без
delete+create; ссылки `[[…]]` на сам родитель переписать (basename сменился).
## План фаз (каждая — тесты на одноразовом стенде + юниты движка)
## План фаз (каждая — юниты движка + браузерный e2e + изолированные shell-e2e)
1. **Индекс-модуль**: чтение/запись `.gitmost/index.json` (pure + IO-инъекция),
юниты. Без смены поведения.
2. **PULL пишет чистые файлы + индекс** (folder-note layout), миграция со старого
формата. Юниты layout + e2e: Docmost→git даёт чистый markdown, folder-note.
3. **PUSH читает индекс** для идентичности/родителя (вместо меты в файле). e2e:
push-правка/move/rename без потерь.
4. **Адопция**: голый `.md`/папка → страница. e2e: положить файл/папку через
Obsidian-подобный клиент → появляются страницы.
5. **Чистка**: удалить `docmost:meta` из формата (оставить только фолбэк-парсер),
`.gitignore` для `.obsidian/`.
6. **Синк ссылок между заметками** (см. ниже) — отдельный кусок, после формата.
## Ссылки между заметками (фаза 6)
Поднял владелец: `title = имя файла`, значит при переименовании страницы ссылки на
неё в волте протухают.
- **В Docmost** ссылки — по pageId (mention/reference node редактора), переименование
ПЕРЕЖИВАЮТ, не ломаются. Эта сторона безопасна.
- **В волте** markdown-ссылки — по пути/имени (`[t](rel/path.md)` или `[[Имя]]`),
при rename/move файла протухают.
- Решение: при rename/move файла движок ПЕРЕПИСЫВАЕТ цель ссылок во всех файлах,
что на него ссылались (Obsidian «update links on rename»). Альтернатива —
хранить ссылки в волте по стабильному id (но это уже не человекочитаемый md;
отвергаем для дружбы со сторонними редакторами).
- Конвертер Docmost-mention ↔ markdown-link (обе стороны) — часть этой фазы.
- Не влияет на выбор B/C и на фазы формата 1–5; выносим отдельно.
1. ✅ Формат файла: `parsePageFile`/`serializePageFile` (frontmatter id + тело,
фолбэк на legacy `docmost:meta`). Юниты. Без смены поведения. (готово)
2. PULL пишет native-формат (frontmatter + folder-note layout) + миграция.
3. PUSH берёт идентичность из frontmatter, родителя из пути.
4. Адопция голых файлов/папок.
5. Чистка: убрать `docmost:meta` из генерации (оставить фолбэк-парсер).
6. Ссылки: конвертер Docmost-mention ↔ `[[wikilink]]` + переписывание при retitle.
## Риски
Это смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня уже ловили
тяжёлый баг с трашем живых страниц). Поэтому: каждая фаза — за фиче-флагом/инкрем.,
с юнит-тестами движка И браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированные
shell-e2e на одноразовом спейсе. Никаких «миграций на месте» без бэкапа волта.
Смена ФОРМАТА волта на data-loss-чувствительном движке (сегодня ловили тяжёлый баг
с трашем живых страниц). Каждая фаза — за инкрементом, с юнит-тестами движка И
браузерным e2e (`git-sync-browser-e2e.cjs`) + изолированными shell-e2e на
одноразовом спейсе. Без in-place миграций без бэкапа волта.

View File

@@ -1,154 +0,0 @@
/**
* The vault SIDECAR index — `.gitmost/index.json`. It holds the ONLY service
* metadata that is not derivable from the vault itself: a page's stable identity
* (`pageId`) and its collision-disambiguation token (`slugId`), keyed by the
* file's vault-relative (forward-slash) path. Everything else is derived:
* - title -> the file/folder name (stem),
* - parentPageId-> the enclosing folder's `index.md` (path-as-truth),
* - spaceId -> the vault is the space,
* - updatedAt -> git history.
*
* Keeping identity here (not in a `docmost:meta` block inside every file) lets
* the `.md` files stay CLEAN markdown that any third-party editor (Obsidian, …)
* reads and writes directly. This module is PURE (parse/serialize/lookup); all
* file IO is the caller's (injected), matching the rest of the engine.
*/
/** Where the sidecar lives inside a space vault (vault-relative, forward-slash). */
export const VAULT_INDEX_PATH = ".gitmost/index.json";
/** Per-file identity record. `slugId` is optional (a freshly adopted file has
* none until Docmost assigns one on create). */
export interface VaultIndexEntry {
pageId: string;
slugId?: string;
}
export interface VaultIndex {
version: number;
/** The space this vault mirrors (one repo per space). Informational. */
spaceId?: string;
/** file path (forward-slash, vault-relative) -> identity. */
pages: Map<string, VaultIndexEntry>;
}
const CURRENT_VERSION = 1;
export function emptyVaultIndex(spaceId?: string): VaultIndex {
return { version: CURRENT_VERSION, spaceId, pages: new Map() };
}
/**
* Parse `.gitmost/index.json`. TOLERANT by construction — a missing file
* (`null`), invalid JSON, or a malformed entry must never crash a sync cycle, so
* those degrade to an empty index / skipped entries (the engine then treats the
* affected files as un-tracked and re-derives identity, rather than losing data).
*/
export function parseVaultIndex(text: string | null | undefined): VaultIndex {
if (text == null || text.trim() === "") return emptyVaultIndex();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return emptyVaultIndex();
}
if (typeof raw !== "object" || raw === null) return emptyVaultIndex();
const obj = raw as Record<string, unknown>;
const index = emptyVaultIndex(
typeof obj.spaceId === "string" ? obj.spaceId : undefined,
);
if (typeof obj.version === "number") index.version = obj.version;
const pages = obj.pages;
if (typeof pages === "object" && pages !== null) {
for (const [path, value] of Object.entries(pages as Record<string, unknown>)) {
if (typeof value !== "object" || value === null) continue;
const entry = value as Record<string, unknown>;
if (typeof entry.pageId !== "string" || entry.pageId === "") continue;
index.pages.set(path, {
pageId: entry.pageId,
...(typeof entry.slugId === "string" ? { slugId: entry.slugId } : {}),
});
}
}
return index;
}
/**
* Serialize to STABLE JSON: object keys sorted so the file produces minimal,
* deterministic git diffs (a re-sync that changes nothing yields byte-identical
* output — no churn, which the loop-guard relies on). Trailing newline.
*/
export function serializeVaultIndex(index: VaultIndex): string {
const pages: Record<string, VaultIndexEntry> = {};
for (const path of [...index.pages.keys()].sort()) {
const e = index.pages.get(path)!;
pages[path] = e.slugId
? { pageId: e.pageId, slugId: e.slugId }
: { pageId: e.pageId };
}
const out: Record<string, unknown> = { version: index.version };
if (index.spaceId) out.spaceId = index.spaceId;
out.pages = pages;
return JSON.stringify(out, null, 2) + "\n";
}
// --- lookups (pure) --------------------------------------------------------
/** The pageId tracked at `path`, or undefined. */
export function pageIdAt(index: VaultIndex, path: string): string | undefined {
return index.pages.get(path)?.pageId;
}
/** The slugId tracked at `path`, or undefined. */
export function slugIdAt(index: VaultIndex, path: string): string | undefined {
return index.pages.get(path)?.slugId;
}
/**
* Reverse lookup: the CURRENT path of a pageId, or undefined. Used by push to
* decide identity — a vanished file whose pageId still resolves to a (different)
* tracked path is a MOVE, not a delete.
*/
export function pathForPageId(
index: VaultIndex,
pageId: string,
): string | undefined {
for (const [path, entry] of index.pages) {
if (entry.pageId === pageId) return path;
}
return undefined;
}
/** The set of all pageIds currently tracked in the index. */
export function trackedPageIds(index: VaultIndex): Set<string> {
const ids = new Set<string>();
for (const entry of index.pages.values()) ids.add(entry.pageId);
return ids;
}
// --- mutations (in place; the index is a builder during a cycle) -----------
export function setEntry(
index: VaultIndex,
path: string,
entry: VaultIndexEntry,
): void {
index.pages.set(path, entry);
}
export function removeAt(index: VaultIndex, path: string): void {
index.pages.delete(path);
}
/** Move a tracked entry from one path to another (a rename/reparent), keeping
* its identity. No-op if `fromPath` is not tracked. */
export function moveEntry(
index: VaultIndex,
fromPath: string,
toPath: string,
): void {
const entry = index.pages.get(fromPath);
if (!entry) return;
index.pages.delete(fromPath);
index.pages.set(toPath, entry);
}

View File

@@ -120,17 +120,4 @@ export type {
CycleFs,
} from "./engine/cycle";
export {
VAULT_INDEX_PATH,
emptyVaultIndex,
parseVaultIndex,
serializeVaultIndex,
pageIdAt,
slugIdAt,
pathForPageId,
trackedPageIds,
setEntry,
removeAt,
moveEntry,
} from "./engine/vault-index";
export type { VaultIndex, VaultIndexEntry } from "./engine/vault-index";
export { parsePageFile, serializePageFile } from "./lib/page-file";

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`;
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { parsePageFile, serializePageFile } from "../src/lib/page-file";
import { serializeDocmostMarkdownBody } from "../src/lib/index";
describe("page-file thin format", () => {
it("round-trips id frontmatter + clean body", () => {
const text = serializePageFile("019ef6fc-2638", "# Hello\n\nbody text");
expect(text.startsWith("---\ngitmost_id: 019ef6fc-2638\n---\n")).toBe(true);
const { id, body } = parsePageFile(text);
expect(id).toBe("019ef6fc-2638");
expect(body).toBe("# Hello\n\nbody text");
});
it("serialization is deterministic (byte-identical for the same input)", () => {
expect(serializePageFile("p", "x")).toBe(serializePageFile("p", "x"));
});
it("reads id from frontmatter with quotes / extra fields", () => {
expect(parsePageFile('---\ngitmost_id: "abc"\ntitle: ignored\n---\nbody').id).toBe("abc");
expect(parsePageFile("---\ngitmost_id: 'xyz'\n---\nbody").id).toBe("xyz");
});
it("MIGRATION: falls back to a legacy docmost:meta block for the id", () => {
const legacy = serializeDocmostMarkdownBody(
{ version: 1, pageId: "legacy-1", title: "T", spaceId: "sp" },
"old body",
);
const { id, body } = parsePageFile(legacy);
expect(id).toBe("legacy-1");
expect(body).toContain("old body");
});
it("ADOPT: a plain hand-written file has no id and keeps its whole body", () => {
const { id, body } = parsePageFile("# Just a note\n\nwritten in Obsidian");
expect(id).toBeNull();
expect(body).toBe("# Just a note\n\nwritten in Obsidian");
});
it("tolerates empty / whitespace input", () => {
expect(parsePageFile("").id).toBeNull();
expect(parsePageFile(" \n ").body).toBe("");
});
});

View File

@@ -1,78 +0,0 @@
import { describe, it, expect } from "vitest";
import {
emptyVaultIndex,
parseVaultIndex,
serializeVaultIndex,
pageIdAt,
slugIdAt,
pathForPageId,
trackedPageIds,
setEntry,
removeAt,
moveEntry,
} from "../src/engine/vault-index";
describe("vault-index parse/serialize", () => {
it("round-trips a populated index", () => {
const idx = emptyVaultIndex("sp1");
setEntry(idx, "Проект/index.md", { pageId: "p1", slugId: "Ab12" });
setEntry(idx, "Заметка.md", { pageId: "p2" });
const text = serializeVaultIndex(idx);
const back = parseVaultIndex(text);
expect(back.spaceId).toBe("sp1");
expect(pageIdAt(back, "Проект/index.md")).toBe("p1");
expect(slugIdAt(back, "Проект/index.md")).toBe("Ab12");
expect(pageIdAt(back, "Заметка.md")).toBe("p2");
expect(slugIdAt(back, "Заметка.md")).toBeUndefined();
});
it("serializes deterministically (sorted keys -> stable diffs)", () => {
const a = emptyVaultIndex("s");
setEntry(a, "b.md", { pageId: "2" });
setEntry(a, "a.md", { pageId: "1" });
const b = emptyVaultIndex("s");
setEntry(b, "a.md", { pageId: "1" });
setEntry(b, "b.md", { pageId: "2" });
// insertion order differs; serialized output must be identical.
expect(serializeVaultIndex(a)).toBe(serializeVaultIndex(b));
// keys are sorted in the output
expect(serializeVaultIndex(a).indexOf('"a.md"')).toBeLessThan(
serializeVaultIndex(a).indexOf('"b.md"'),
);
});
it("is tolerant: null / garbage / bad entries -> empty or skipped", () => {
expect(parseVaultIndex(null).pages.size).toBe(0);
expect(parseVaultIndex("").pages.size).toBe(0);
expect(parseVaultIndex("not json{").pages.size).toBe(0);
expect(parseVaultIndex("[1,2,3]").pages.size).toBe(0);
// a page entry missing pageId is skipped, valid ones kept
const idx = parseVaultIndex(
JSON.stringify({ version: 1, pages: { "ok.md": { pageId: "p" }, "bad.md": { slugId: "x" } } }),
);
expect(idx.pages.size).toBe(1);
expect(pageIdAt(idx, "ok.md")).toBe("p");
});
});
describe("vault-index lookups + mutations", () => {
it("reverse lookup + tracked set", () => {
const idx = emptyVaultIndex();
setEntry(idx, "x.md", { pageId: "px" });
setEntry(idx, "y/index.md", { pageId: "py" });
expect(pathForPageId(idx, "py")).toBe("y/index.md");
expect(pathForPageId(idx, "missing")).toBeUndefined();
expect([...trackedPageIds(idx)].sort()).toEqual(["px", "py"]);
});
it("moveEntry relocates identity; removeAt drops it", () => {
const idx = emptyVaultIndex();
setEntry(idx, "Old.md", { pageId: "p", slugId: "s" });
moveEntry(idx, "Old.md", "New/index.md");
expect(pageIdAt(idx, "Old.md")).toBeUndefined();
expect(pageIdAt(idx, "New/index.md")).toBe("p");
expect(slugIdAt(idx, "New/index.md")).toBe("s"); // identity preserved
removeAt(idx, "New/index.md");
expect(pageIdAt(idx, "New/index.md")).toBeUndefined();
});
});