From 9daa3d47e05a382773fef7eb0b489edbaedf663d Mon Sep 17 00:00:00 2001 From: restuta Date: Fri, 27 Mar 2026 19:46:38 +0700 Subject: [PATCH] feat: add vault-aware TOML publish manifest --- README.md | 23 ++++ docs/progress.md | 1 + src/cli/main.ts | 65 ++++++++-- src/cli/vault-manifest.ts | 191 ++++++++++++++++++++++++++++++ tests/integration/cli.test.ts | 85 ++++++++++++- tests/unit/vault-manifest.test.ts | 57 +++++++++ 6 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 src/cli/vault-manifest.ts create mode 100644 tests/unit/vault-manifest.test.ts diff --git a/README.md b/README.md index abcb443..509d805 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,29 @@ node dist/src/cli/main.js remove [--namespace ] Del Config stored in `~/.config/pub/config.json`. File-to-URL mappings stored in `.pub` in the working directory. +### Obsidian Vaults And Repo-Owned Publish State + +When `pubmd publish` targets a file inside an Obsidian vault (detected via the nearest ancestor containing `.obsidian/`), it also writes a committed-friendly manifest at: + +```text +.pubmd/pages.toml +``` + +The manifest stores the vault-relative source path plus the canonical publish mapping: + +```toml +[[pages]] +source = "10-product/00-vision-v1.md" +namespace = "a" +slug = "orba-vision" +page_id = "123e4567-e89b-12d3-a456-426614174000" +url = "https://bul.sh/a/orba-vision" +title = "Product Vision v1" +updated_at = "2026-03-27T00:00:00.000Z" +``` + +This manifest is designed for repository knowledge and can be committed. The older `.pub` file remains a local working-state cache. + ## Development ```bash diff --git a/docs/progress.md b/docs/progress.md index 6a825a6..00da380 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -15,3 +15,4 @@ - 2026-03-19: Deployed production successfully and verified the live domain with a real smoke test: claim -> publish -> HTML read -> raw read -> list -> delete on `https://bul.sh`. - 2026-03-19: Investigated true custom-domain external rewrites on Vercel. Redirects propagate to `bul.sh`, but rewrite routes did not behave as required on the custom domain. - 2026-03-19: Adopted the pragmatic Vercel production read path: serve pre-rendered HTML through Hono with aggressive edge-cache headers so subsequent reads are CDN hits while content remains stored in Blob. +- 2026-03-27: Added vault-aware publish state for Obsidian repos. `pubmd publish` now detects the nearest `.obsidian/` root, writes `.pubmd/pages.toml` with vault-relative source mappings, and reuses that manifest to keep republish behavior stable across working directories. diff --git a/src/cli/main.ts b/src/cli/main.ts index e3591de..bbbe21f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -15,6 +15,15 @@ import { saveConfig, saveMapping, } from "./config.js"; +import { + findVaultManifestEntry, + findVaultRoot, + getVaultManifestPath, + loadVaultManifest, + normalizeVaultRelativePath, + saveVaultManifest, + upsertVaultManifestEntry, +} from "./vault-manifest.js"; interface CommandContext { args: string[]; @@ -89,17 +98,38 @@ async function runClaim(context: CommandContext): Promise { async function runPublish(context: CommandContext): Promise { const { positional, options } = splitArgs(context.args); const filePath = positional[0]; + const absoluteFilePath = + filePath === undefined ? undefined : path.resolve(filePath); const config = await loadConfig(); const apiBase = resolveApiBase(config, options["api-base"]); const mapping = await loadMapping(); + const vaultRoot = + absoluteFilePath === undefined + ? null + : await findVaultRoot(path.dirname(absoluteFilePath)); + const vaultManifestPath = + vaultRoot === null ? null : getVaultManifestPath(vaultRoot); + const vaultSourcePath = + absoluteFilePath === undefined || vaultRoot === null + ? undefined + : normalizeVaultRelativePath(path.relative(vaultRoot, absoluteFilePath)); + const vaultManifest = + vaultManifestPath === null + ? null + : await loadVaultManifest(vaultManifestPath); + const manifestEntry = + vaultManifest === null || vaultSourcePath === undefined + ? undefined + : findVaultManifestEntry(vaultManifest, vaultSourcePath); const mappingKey = - filePath === undefined + absoluteFilePath === undefined ? undefined - : path.relative(process.cwd(), path.resolve(filePath)); + : path.relative(process.cwd(), absoluteFilePath); const existingMapping = mappingKey === undefined ? undefined : mapping.files[mappingKey]; + const existingPage = manifestEntry ?? existingMapping; const namespace = - options.namespace ?? existingMapping?.namespace ?? config.defaultNamespace; + options.namespace ?? existingPage?.namespace ?? config.defaultNamespace; if (namespace === undefined) { throw new Error( @@ -114,9 +144,9 @@ async function runPublish(context: CommandContext): Promise { } const markdown = - filePath === undefined + absoluteFilePath === undefined ? await readStdin() - : await readFile(path.resolve(filePath), "utf8"); + : await readFile(absoluteFilePath, "utf8"); const response = await fetch( `${apiBase}/api/namespaces/${encodeURIComponent(namespace)}/pages/publish`, { @@ -127,10 +157,12 @@ async function runPublish(context: CommandContext): Promise { }, body: JSON.stringify({ markdown, - ...(options.slug === undefined ? {} : { slug: options.slug }), - ...(existingMapping?.pageId === undefined + ...((options.slug ?? existingPage?.slug) === undefined ? {} - : { pageId: existingMapping.pageId }), + : { slug: options.slug ?? existingPage?.slug }), + ...(existingPage?.pageId === undefined + ? {} + : { pageId: existingPage.pageId }), }), }, ); @@ -151,6 +183,23 @@ async function runPublish(context: CommandContext): Promise { await saveMapping(mapping); } + if ( + vaultManifest !== null && + vaultManifestPath !== null && + vaultSourcePath !== undefined + ) { + const nextManifest = upsertVaultManifestEntry(vaultManifest, { + namespace: published.namespace, + pageId: published.pageId, + slug: published.slug, + source: vaultSourcePath, + title: published.title, + updatedAt: new Date().toISOString(), + url: published.url, + }); + await saveVaultManifest(nextManifest, vaultManifestPath); + } + console.log(published.url); } diff --git a/src/cli/vault-manifest.ts b/src/cli/vault-manifest.ts new file mode 100644 index 0000000..14d62ec --- /dev/null +++ b/src/cli/vault-manifest.ts @@ -0,0 +1,191 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +const VaultPageSchema = z.object({ + namespace: z.string().min(1), + pageId: z.string().uuid(), + slug: z.string().min(1), + source: z.string().min(1), + title: z.string().min(1), + updatedAt: z.string().datetime(), + url: z.string().url(), +}); + +const VaultManifestSchema = z.object({ + pages: z.array(VaultPageSchema), +}); + +export type VaultManifest = z.infer; +export type VaultManifestEntry = z.infer; + +const OBSIDIAN_DIR_NAME = ".obsidian"; + +export async function findVaultRoot(startPath: string): Promise { + let currentPath = path.resolve(startPath); + + while (true) { + if (await pathExists(path.join(currentPath, OBSIDIAN_DIR_NAME))) { + return currentPath; + } + + const parentPath = path.dirname(currentPath); + + if (parentPath === currentPath) { + return null; + } + + currentPath = parentPath; + } +} + +export function getVaultManifestPath(vaultRoot: string): string { + return path.join(vaultRoot, ".pubmd", "pages.toml"); +} + +export async function loadVaultManifest( + manifestPath: string, +): Promise { + try { + const raw = await readFile(manifestPath, "utf8"); + return VaultManifestSchema.parse(parseVaultManifestToml(raw)); + } catch (error) { + if (isMissingFile(error)) { + return { pages: [] }; + } + + throw error; + } +} + +export async function saveVaultManifest( + manifest: VaultManifest, + manifestPath: string, +): Promise { + await mkdir(path.dirname(manifestPath), { recursive: true }); + const sortedManifest = { + pages: [...manifest.pages].sort((left, right) => + left.source.localeCompare(right.source), + ), + }; + await writeFile( + manifestPath, + `${serializeVaultManifestToml(sortedManifest)}\n`, + ); +} + +export function normalizeVaultRelativePath(relativePath: string): string { + return relativePath.split(path.sep).join("/"); +} + +export function upsertVaultManifestEntry( + manifest: VaultManifest, + entry: VaultManifestEntry, +): VaultManifest { + const pages = manifest.pages.filter((page) => page.source !== entry.source); + pages.push(entry); + return { pages }; +} + +export function findVaultManifestEntry( + manifest: VaultManifest, + source: string, +): VaultManifestEntry | undefined { + return manifest.pages.find((page) => page.source === source); +} + +function parseVaultManifestToml(raw: string): VaultManifest { + const lines = raw.split(/\r?\n/); + const pages: Array> = []; + let currentPage: Record | null = null; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (line.length === 0 || line.startsWith("#")) { + continue; + } + + if (line === "[[pages]]") { + currentPage = {}; + pages.push(currentPage); + continue; + } + + if (currentPage === null) { + throw new Error("Invalid vault manifest: expected [[pages]] block."); + } + + const match = line.match(/^([a-z_]+)\s*=\s*"((?:[^"\\]|\\.)*)"$/); + + if (match === null) { + throw new Error(`Invalid vault manifest line: ${line}`); + } + + const [, key, value] = match; + + if (key === undefined || value === undefined) { + throw new Error(`Invalid vault manifest line: ${line}`); + } + + currentPage[key] = unescapeTomlString(value); + } + + return { pages: pages.map((page) => mapParsedPage(page)) }; +} + +function serializeVaultManifestToml(manifest: VaultManifest): string { + return manifest.pages + .map((page) => + [ + "[[pages]]", + `source = "${escapeTomlString(page.source)}"`, + `namespace = "${escapeTomlString(page.namespace)}"`, + `slug = "${escapeTomlString(page.slug)}"`, + `page_id = "${escapeTomlString(page.pageId)}"`, + `url = "${escapeTomlString(page.url)}"`, + `title = "${escapeTomlString(page.title)}"`, + `updated_at = "${escapeTomlString(page.updatedAt)}"`, + ].join("\n"), + ) + .join("\n\n"); +} + +function mapParsedPage(page: Record): VaultManifestEntry { + return VaultPageSchema.parse({ + namespace: page["namespace"], + pageId: page["page_id"], + slug: page["slug"], + source: page["source"], + title: page["title"], + updatedAt: page["updated_at"], + url: page["url"], + }); +} + +function escapeTomlString(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +function unescapeTomlString(value: string): string { + return value.replaceAll('\\"', '"').replaceAll("\\\\", "\\"); +} + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +function isMissingFile(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ); +} diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 3fdedd3..d630ae0 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -109,6 +109,83 @@ Updated body. }; expect(Object.keys(mapping.files)).toHaveLength(0); }); + + it("writes a vault manifest and reuses it across working directories", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "publish-it-vault-")); + const configDir = path.join(root, "config"); + const firstMappingPath = path.join(root, "first.pub"); + const secondMappingPath = path.join(root, "second.pub"); + const vaultRoot = path.join(root, "vault"); + const firstCwd = path.join(vaultRoot, "notes"); + const secondCwd = vaultRoot; + const notePath = path.join(firstCwd, "vision.md"); + const manifestPath = path.join(vaultRoot, ".pubmd", "pages.toml"); + + server = await startTestServer(root); + await mkdir(path.join(vaultRoot, ".obsidian"), { recursive: true }); + await mkdir(firstCwd, { recursive: true }); + await writeFile( + notePath, + `--- +title: Product Vision +--- + +First version. +`, + "utf8", + ); + + await runCli(["claim", "restuta", "--api-base", server.origin], { + cwd: firstCwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: firstMappingPath, + }, + }); + + const firstUrl = ( + await runCli(["publish", notePath, "--api-base", server.origin], { + cwd: firstCwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: firstMappingPath, + }, + }) + ).stdout.trim(); + + const manifestContents = await readFile(manifestPath, "utf8"); + expect(manifestContents).toContain('source = "notes/vision.md"'); + expect(manifestContents).toContain('slug = "product-vision"'); + expect(manifestContents).toContain(firstUrl); + + await writeFile( + notePath, + `--- +title: Product Vision Renamed +--- + +Updated body. +`, + "utf8", + ); + + const secondPublish = await runCli( + ["publish", path.join("notes", "vision.md"), "--api-base", server.origin], + { + cwd: secondCwd, + env: { + PUB_CONFIG_DIR: configDir, + PUB_MAPPING_PATH: secondMappingPath, + }, + }, + ); + const secondUrl = secondPublish.stdout.trim(); + + expect(secondUrl).toBe(firstUrl); + + const pageResponse = await fetch(firstUrl); + expect(await pageResponse.text()).toContain("Updated body."); + }); }); async function runCli( @@ -123,9 +200,13 @@ async function runCli( return new Promise((resolve, reject) => { const child = spawn( process.execPath, - ["./node_modules/tsx/dist/cli.mjs", "src/cli/main.ts", ...args], + [ + path.join(repoRoot, "node_modules/tsx/dist/cli.mjs"), + path.join(repoRoot, "src/cli/main.ts"), + ...args, + ], { - cwd: repoRoot, + cwd: options.cwd, env: { ...process.env, ...options.env, diff --git a/tests/unit/vault-manifest.test.ts b/tests/unit/vault-manifest.test.ts new file mode 100644 index 0000000..9f58342 --- /dev/null +++ b/tests/unit/vault-manifest.test.ts @@ -0,0 +1,57 @@ +import { mkdir, mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + findVaultManifestEntry, + findVaultRoot, + loadVaultManifest, + normalizeVaultRelativePath, + saveVaultManifest, + upsertVaultManifestEntry, +} from "../../src/cli/vault-manifest.js"; + +describe("vault manifest", () => { + it("finds the nearest Obsidian vault root", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-vault-root-")); + const vaultRoot = path.join(root, "vault"); + const nestedDir = path.join(vaultRoot, "notes", "deep"); + + await mkdir(path.join(vaultRoot, ".obsidian"), { recursive: true }); + await mkdir(nestedDir, { recursive: true }); + + expect(await findVaultRoot(nestedDir)).toBe(vaultRoot); + }); + + it("round-trips TOML manifest entries", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "pubmd-vault-manifest-")); + const manifestPath = path.join(root, ".pubmd", "pages.toml"); + const manifest = upsertVaultManifestEntry( + { pages: [] }, + { + namespace: "a", + pageId: "123e4567-e89b-12d3-a456-426614174000", + slug: "orba-vision", + source: "10-product/00-vision-v1.md", + title: "Product Vision v1", + updatedAt: "2026-03-27T00:00:00.000Z", + url: "https://bul.sh/a/orba-vision", + }, + ); + + await saveVaultManifest(manifest, manifestPath); + const loaded = await loadVaultManifest(manifestPath); + + expect( + findVaultManifestEntry(loaded, "10-product/00-vision-v1.md"), + ).toEqual(manifest.pages[0]); + }); + + it("normalizes vault-relative paths to forward slashes", () => { + expect(normalizeVaultRelativePath(`notes${path.sep}doc.md`)).toBe( + "notes/doc.md", + ); + }); +});