Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,29 @@ node dist/src/cli/main.js remove <slug> [--namespace <n>] 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
Expand Down
1 change: 1 addition & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
65 changes: 57 additions & 8 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -89,17 +98,38 @@ async function runClaim(context: CommandContext): Promise<void> {
async function runPublish(context: CommandContext): Promise<void> {
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(
Expand All @@ -114,9 +144,9 @@ async function runPublish(context: CommandContext): Promise<void> {
}

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`,
{
Expand All @@ -127,10 +157,12 @@ async function runPublish(context: CommandContext): Promise<void> {
},
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 }),
}),
},
);
Expand All @@ -151,6 +183,23 @@ async function runPublish(context: CommandContext): Promise<void> {
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);
}

Expand Down
191 changes: 191 additions & 0 deletions src/cli/vault-manifest.ts
Original file line number Diff line number Diff line change
@@ -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<typeof VaultManifestSchema>;
export type VaultManifestEntry = z.infer<typeof VaultPageSchema>;

const OBSIDIAN_DIR_NAME = ".obsidian";

export async function findVaultRoot(startPath: string): Promise<string | null> {
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<VaultManifest> {
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<void> {
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<Record<string, string>> = [];
let currentPage: Record<string, string> | 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<string, string>): 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<boolean> {
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"
);
}
Loading