From 53c2550097b709383bbf10da798b43253660cb83 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 02:16:48 +0000 Subject: [PATCH] feat: publish modal, settings tab, plugin entry Co-Authored-By: Claude --- src/PublishModal.ts | 112 ++++++++++++++++++++++++++++++++++++++++++++ src/SettingsTab.ts | 46 ++++++++++++++++++ src/main.ts | 99 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 src/PublishModal.ts create mode 100644 src/SettingsTab.ts create mode 100644 src/main.ts diff --git a/src/PublishModal.ts b/src/PublishModal.ts new file mode 100644 index 0000000..673c6fb --- /dev/null +++ b/src/PublishModal.ts @@ -0,0 +1,112 @@ +import { App, Modal, Setting } from "obsidian"; +import { Pair, parseNote, resolveFrontmatter } from "./frontmatter"; +import { Strategy } from "./images"; +import { JekyllPublishSettings } from "./settings"; +import { deriveDate, deriveSlug } from "./slug"; + +export interface ModalResult { + frontmatterPairs: Pair[]; + slug: string; + date: string; + strategy: Strategy; + commitMessage: string; +} + +export class PublishModal extends Modal { + private result: ModalResult; + private mergeDoc = true; + private custom: Pair[] = []; + + constructor( + app: App, + private settings: JekyllPublishSettings, + private noteText: string, + private filename: string, + private onSubmit: (r: ModalResult) => void + ) { + super(app); + const { frontmatter } = parseNote(noteText); + const title = typeof frontmatter.title === "string" ? frontmatter.title : ""; + this.result = { + frontmatterPairs: [], + slug: deriveSlug({ title, filename }), + date: deriveDate({ frontmatterDate: frontmatter.date, now: new Date() }), + strategy: settings.defaultImageStrategy, + commitMessage: settings.commitMessageTemplate.replace("{{title}}", title || filename), + }; + } + + private recompute() { + const { frontmatter } = parseNote(this.noteText); + this.result.frontmatterPairs = resolveFrontmatter( + this.settings.presetFrontmatter, + frontmatter, + this.custom, + this.mergeDoc + ); + } + + onOpen() { + this.recompute(); + const { contentEl } = this; + contentEl.createEl("h2", { text: "Publish to Jekyll" }); + + new Setting(contentEl).setName("Slug").addText((t) => + t.setValue(this.result.slug).onChange((v) => (this.result.slug = v)) + ); + new Setting(contentEl).setName("Date").addText((t) => + t.setValue(this.result.date).onChange((v) => (this.result.date = v)) + ); + new Setting(contentEl).setName("Image strategy").addDropdown((d) => + d.addOption("flat-slug", "Flat, renamed to slug") + .addOption("per-post-folder", "Per-post subfolder") + .setValue(this.result.strategy) + .onChange((v) => (this.result.strategy = v as Strategy)) + ); + new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) => + t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); }) + ); + + this.fmEl = contentEl.createDiv(); + this.renderFrontmatter(); + + new Setting(contentEl).addButton((b) => + b.setButtonText("Add property").onClick(() => { + this.custom.push({ key: "", value: "" }); + this.renderFrontmatter(); + }) + ); + new Setting(contentEl).setName("Commit message").addText((t) => + t.setValue(this.result.commitMessage).onChange((v) => (this.result.commitMessage = v)) + ); + new Setting(contentEl).addButton((b) => + b.setButtonText("Publish").setCta().onClick(() => { + this.recompute(); + this.close(); + this.onSubmit(this.result); + }) + ); + } + + private fmEl!: HTMLElement; + private renderFrontmatter() { + this.recompute(); + this.fmEl.empty(); + this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" }); + for (const p of this.result.frontmatterPairs) { + this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` }); + } + if (this.custom.length) { + this.fmEl.createEl("h4", { text: "Custom properties" }); + this.custom.forEach((row, i) => { + const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" }); + const k = div.createEl("input", { value: row.key, placeholder: "key" }); + const v = div.createEl("input", { value: row.value, placeholder: "value" }); + k.oninput = () => { this.custom[i].key = k.value; this.recompute(); }; + v.oninput = () => { this.custom[i].value = v.value; this.recompute(); }; + }); + } + } + + onClose() { this.contentEl.empty(); } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts new file mode 100644 index 0000000..5b49704 --- /dev/null +++ b/src/SettingsTab.ts @@ -0,0 +1,46 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import type JekyllPublishPlugin from "./main"; + +export class JekyllPublishSettingTab extends PluginSettingTab { + constructor(app: App, private plugin: JekyllPublishPlugin) { + super(app, plugin); + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + const s = this.plugin.settings; + const save = () => this.plugin.saveSettings(); + + new Setting(containerEl).setName("Remote URL").setDesc("https:// or ssh:// git URL") + .addText((t) => t.setValue(s.remoteUrl).onChange((v) => { s.remoteUrl = v; save(); })); + new Setting(containerEl).setName("Branch") + .addText((t) => t.setValue(s.branch).onChange((v) => { s.branch = v; save(); })); + new Setting(containerEl).setName("Posts directory") + .addText((t) => t.setValue(s.postsDir).onChange((v) => { s.postsDir = v; save(); })); + new Setting(containerEl).setName("Images directory") + .addText((t) => t.setValue(s.imagesDir).onChange((v) => { s.imagesDir = v; save(); })); + new Setting(containerEl).setName("Default image strategy") + .addDropdown((d) => d.addOption("flat-slug", "Flat, renamed to slug") + .addOption("per-post-folder", "Per-post subfolder") + .setValue(s.defaultImageStrategy) + .onChange((v) => { s.defaultImageStrategy = v as typeof s.defaultImageStrategy; save(); })); + new Setting(containerEl).setName("Commit message template").setDesc("{{title}} is substituted") + .addText((t) => t.setValue(s.commitMessageTemplate).onChange((v) => { s.commitMessageTemplate = v; save(); })); + new Setting(containerEl).setName("Author name (optional)") + .addText((t) => t.setValue(s.authorName).onChange((v) => { s.authorName = v; save(); })); + new Setting(containerEl).setName("Author email (optional)") + .addText((t) => t.setValue(s.authorEmail).onChange((v) => { s.authorEmail = v; save(); })); + + containerEl.createEl("h3", { text: "Preset frontmatter" }); + s.presetFrontmatter.forEach((row, i) => { + new Setting(containerEl) + .addText((t) => t.setPlaceholder("key").setValue(row.key).onChange((v) => { s.presetFrontmatter[i].key = v; save(); })) + .addText((t) => t.setPlaceholder("value").setValue(row.value).onChange((v) => { s.presetFrontmatter[i].value = v; save(); })) + .addExtraButton((b) => b.setIcon("trash").onClick(() => { s.presetFrontmatter.splice(i, 1); save(); this.display(); })); + }); + new Setting(containerEl).addButton((b) => + b.setButtonText("Add preset property").onClick(() => { s.presetFrontmatter.push({ key: "", value: "" }); save(); this.display(); }) + ); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..08989cc --- /dev/null +++ b/src/main.ts @@ -0,0 +1,99 @@ +import { Modal, Notice, Plugin, Setting, TFile, normalizePath } from "obsidian"; +import { DEFAULT_SETTINGS, JekyllPublishSettings } from "./settings"; +import { JekyllPublishSettingTab } from "./SettingsTab"; +import { ModalResult, PublishModal } from "./PublishModal"; +import { ChildProcessGitClient } from "./git"; +import { AskpassBridge } from "./askpass"; +import { publish } from "./publish"; + +export default class JekyllPublishPlugin extends Plugin { + settings!: JekyllPublishSettings; + + async onload() { + await this.loadSettings(); + this.addSettingTab(new JekyllPublishSettingTab(this.app, this)); + this.addCommand({ + id: "publish-current-note", + name: "Publish current note to Jekyll", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + if (!checking) void this.openPublishModal(file); + return true; + }, + }); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + async saveSettings() { + await this.saveData(this.settings); + } + + private async openPublishModal(file: TFile) { + const noteText = await this.app.vault.read(file); + new PublishModal(this.app, this.settings, noteText, file.basename, (r) => + void this.runPublish(noteText, r) + ).open(); + } + + private async runPublish(noteText: string, r: ModalResult) { + if (!this.settings.remoteUrl.trim()) { + new Notice("Jekyll Publish: set a remote URL in settings first."); + return; + } + const dir = normalizePath(this.app.vault.configDir + "/plugins/jekyll-publish"); + const bridge = new AskpassBridge({ + dir: (this.app.vault.adapter as any).getFullPath + ? (this.app.vault.adapter as any).getFullPath(dir) + : dir, + onPrompt: (prompt) => this.promptCredential(prompt), + }); + try { + const env = await bridge.start(); + const git = new ChildProcessGitClient({ env }); + const result = await publish( + { noteText, ...r }, + this.settings, + git, + (linktext) => this.resolveImage(linktext) + ); + const warn = result.unresolved.length ? ` (${result.unresolved.length} image(s) unresolved)` : ""; + new Notice(`Published ${result.postPath} with ${result.imageCount} image(s)${warn}`); + } catch (e) { + new Notice(`Publish failed: ${(e as Error).message}`); + } finally { + bridge.stop(); + } + } + + private async resolveImage(linktext: string): Promise { + const dest = this.app.metadataCache.getFirstLinkpathDest(linktext, ""); + if (!dest) return null; + const ab = await this.app.vault.readBinary(dest); + return Buffer.from(ab); + } + + private promptCredential(prompt: string): Promise { + return new Promise((resolve) => { + const modal = new Modal(this.app); + modal.titleEl.setText("Git credentials"); + modal.contentEl.createEl("p", { text: prompt }); + let value = ""; + const masked = /pass|secret|token/i.test(prompt); + new Setting(modal.contentEl).addText((t) => { + if (masked) t.inputEl.type = "password"; + t.onChange((v) => (value = v)); + t.inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { modal.close(); resolve(value); } + }); + }); + new Setting(modal.contentEl).addButton((b) => + b.setButtonText("OK").setCta().onClick(() => { modal.close(); resolve(value); }) + ); + modal.onClose = () => resolve(value); + modal.open(); + }); + } +}