diff --git a/src/PublishModal.ts b/src/PublishModal.ts index 673c6fb..a0c24d3 100644 --- a/src/PublishModal.ts +++ b/src/PublishModal.ts @@ -1,5 +1,5 @@ import { App, Modal, Setting } from "obsidian"; -import { Pair, parseNote, resolveFrontmatter } from "./frontmatter"; +import { Pair, composeFrontmatter, docFrontmatterToPairs, parseNote } from "./frontmatter"; import { Strategy } from "./images"; import { JekyllPublishSettings } from "./settings"; import { deriveDate, deriveSlug } from "./slug"; @@ -15,18 +15,26 @@ export interface ModalResult { export class PublishModal extends Modal { private result: ModalResult; private mergeDoc = true; - private custom: Pair[] = []; + /** Editable rows = preset fields (seeded from settings) + custom additions. */ + private editableRows: Pair[]; + /** The note's own frontmatter, shown read-only when merge is on. */ + private readonly docPairs: Pair[]; + private fmEl!: HTMLElement; constructor( app: App, - private settings: JekyllPublishSettings, + settings: JekyllPublishSettings, private noteText: string, - private filename: string, + filename: string, private onSubmit: (r: ModalResult) => void ) { super(app); const { frontmatter } = parseNote(noteText); const title = typeof frontmatter.title === "string" ? frontmatter.title : ""; + this.docPairs = docFrontmatterToPairs(frontmatter); + // Deep-copy the presets so editing/deleting rows here never mutates the + // saved settings. + this.editableRows = settings.presetFrontmatter.map((p) => ({ key: p.key, value: p.value })); this.result = { frontmatterPairs: [], slug: deriveSlug({ title, filename }), @@ -37,13 +45,7 @@ export class PublishModal extends Modal { } private recompute() { - const { frontmatter } = parseNote(this.noteText); - this.result.frontmatterPairs = resolveFrontmatter( - this.settings.presetFrontmatter, - frontmatter, - this.custom, - this.mergeDoc - ); + this.result.frontmatterPairs = composeFrontmatter(this.docPairs, this.editableRows, this.mergeDoc); } onOpen() { @@ -63,16 +65,23 @@ export class PublishModal extends Modal { .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(); }) - ); + new Setting(contentEl) + .setName("Merge document frontmatter") + .setDesc("Include the note's own frontmatter (shown read-only below).") + .addToggle((t) => + t.setValue(this.mergeDoc).onChange((v) => { + this.mergeDoc = v; + this.renderFrontmatter(); + }) + ); + contentEl.createEl("h3", { text: "Frontmatter" }); this.fmEl = contentEl.createDiv(); this.renderFrontmatter(); new Setting(contentEl).addButton((b) => b.setButtonText("Add property").onClick(() => { - this.custom.push({ key: "", value: "" }); + this.editableRows = [...this.editableRows, { key: "", value: "" }]; this.renderFrontmatter(); }) ); @@ -88,25 +97,52 @@ export class PublishModal extends Modal { ); } - 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(); }; - }); + + const editableKeys = new Set( + this.editableRows.filter((r) => r.key.trim() !== "").map((r) => r.key) + ); + + // Document fields (read-only) first — only those not overridden by an + // editable row, so the modal never shows a key twice. + if (this.mergeDoc) { + const docRows = this.docPairs.filter((p) => !editableKeys.has(p.key)); + for (const p of docRows) { + const row = this.fmEl.createDiv({ cls: "jekyll-publish-row" }); + const k = row.createEl("input", { cls: "jekyll-publish-doc", value: p.key }); + const v = row.createEl("input", { cls: "jekyll-publish-doc", value: p.value }); + k.disabled = true; + v.disabled = true; + k.title = "From the note's frontmatter (edit the note to change)"; + v.title = k.title; + } } + + // Editable rows (presets + custom) — same style, all editable and deletable. + this.editableRows.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.editableRows = this.editableRows.map((r, j) => (j === i ? { ...r, key: k.value } : r)); + this.recompute(); + }; + v.oninput = () => { + this.editableRows = this.editableRows.map((r, j) => (j === i ? { ...r, value: v.value } : r)); + this.recompute(); + }; + const del = div.createEl("button", { cls: "jekyll-publish-del", text: "×" }); + del.setAttr("aria-label", "Delete property"); + del.onclick = () => { + this.editableRows = this.editableRows.filter((_, j) => j !== i); + this.renderFrontmatter(); + }; + }); } - onClose() { this.contentEl.empty(); } + onClose() { + this.contentEl.empty(); + } } diff --git a/src/frontmatter.test.ts b/src/frontmatter.test.ts index 626e287..2eb384d 100644 --- a/src/frontmatter.test.ts +++ b/src/frontmatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter"; +import { composeFrontmatter, docFrontmatterToPairs, parseNote, serializeFrontmatter } from "./frontmatter"; describe("parseNote", () => { test("splits frontmatter and body, strips BOM", () => { @@ -12,27 +12,70 @@ describe("parseNote", () => { }); }); -describe("resolveFrontmatter", () => { - const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }]; - test("presets only when mergeDoc is false", () => { - expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets); - }); - test("merge appends doc keys and doc overrides preset value", () => { - const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true); - expect(r).toEqual([ - { key: "layout", value: "post" }, - { key: "kind", value: "note" }, - { key: "title", value: "X" }, +describe("docFrontmatterToPairs", () => { + test("converts a record to ordered string pairs", () => { + const pairs = docFrontmatterToPairs({ + title: "Hello", + date: new Date("2026-02-06T00:00:00Z"), + draft: true, + }); + expect(pairs).toEqual([ + { key: "title", value: "Hello" }, + { key: "date", value: "2026-02-06" }, + { key: "draft", value: "true" }, ]); }); - test("custom rows come last and override everything", () => { - const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false); - expect(r[0]).toEqual({ key: "layout", value: "page" }); - expect(r).toHaveLength(2); + test("empty record yields an empty array", () => { + expect(docFrontmatterToPairs({})).toEqual([]); }); - test("date object is rendered as YYYY-MM-DD", () => { - const r = resolveFrontmatter([], { date: new Date("2026-06-11T00:00:00Z") }, [], true); - expect(r).toEqual([{ key: "date", value: "2026-06-11" }]); +}); + +describe("composeFrontmatter", () => { + const doc = [ + { key: "title", value: "Hello" }, + { key: "date", value: "2026-02-06" }, + ]; + const editable = [ + { key: "layout", value: "post" }, + { key: "kind", value: "essay" }, + ]; + + test("merge on: document fields first, then editable rows", () => { + expect(composeFrontmatter(doc, editable, true)).toEqual([ + { key: "title", value: "Hello" }, + { key: "date", value: "2026-02-06" }, + { key: "layout", value: "post" }, + { key: "kind", value: "essay" }, + ]); + }); + test("merge off: only the editable rows", () => { + expect(composeFrontmatter(doc, editable, false)).toEqual(editable); + }); + test("an editable row overrides a document field with the same key (doc field dropped)", () => { + const ed = [ + { key: "title", value: "Override" }, + { key: "layout", value: "post" }, + ]; + expect(composeFrontmatter(doc, ed, true)).toEqual([ + { key: "date", value: "2026-02-06" }, + { key: "title", value: "Override" }, + { key: "layout", value: "post" }, + ]); + }); + test("blank-key editable rows are skipped", () => { + const ed = [ + { key: "", value: "" }, + { key: "layout", value: "post" }, + { key: " ", value: "x" }, + ]; + expect(composeFrontmatter([], ed, true)).toEqual([{ key: "layout", value: "post" }]); + }); + test("duplicate editable keys: last value wins at first position", () => { + const ed = [ + { key: "tag", value: "a" }, + { key: "tag", value: "b" }, + ]; + expect(composeFrontmatter([], ed, false)).toEqual([{ key: "tag", value: "b" }]); }); }); diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 4f514cb..422b96a 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -14,12 +14,26 @@ export function parseNote(text: string): { frontmatter: Record; return { frontmatter, body: stripped.slice(m[0].length) }; } -export function resolveFrontmatter( - presets: Pair[], - docFrontmatter: Record, - custom: Pair[], - mergeDoc: boolean -): Pair[] { +/** + * Convert a parsed note's frontmatter object into ordered, stringified pairs + * suitable for display (read-only) and composition. + */ +export function docFrontmatterToPairs(docFrontmatter: Record): Pair[] { + return Object.entries(docFrontmatter).map(([key, v]) => ({ key, value: stringifyScalar(v) })); +} + +/** + * Build the final frontmatter for the post. + * + * Editable rows (the preset fields seeded from settings plus any custom rows + * the user added) are authoritative: on a key collision they win over the + * document's own merged fields, which appear first (and are shown read-only in + * the modal). Blank-key rows are dropped; duplicate editable keys keep the last + * value at the first position. + */ +export function composeFrontmatter(docPairs: Pair[], editableRows: Pair[], mergeDoc: boolean): Pair[] { + const editable = editableRows.filter((r) => r.key.trim() !== ""); + const editableKeys = new Set(editable.map((r) => r.key)); const out: Pair[] = []; const idx = new Map(); const put = (key: string, value: string) => { @@ -27,9 +41,8 @@ export function resolveFrontmatter( if (at !== undefined) out[at] = { key, value }; else { idx.set(key, out.length); out.push({ key, value }); } }; - for (const p of presets) put(p.key, p.value); - if (mergeDoc) for (const [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v)); - for (const c of custom) put(c.key, c.value); + if (mergeDoc) for (const p of docPairs) if (!editableKeys.has(p.key)) put(p.key, p.value); + for (const r of editable) put(r.key, r.value); return out; } diff --git a/styles.css b/styles.css index d5616a7..5baa953 100644 --- a/styles.css +++ b/styles.css @@ -1,2 +1,17 @@ -.jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; } +.jekyll-publish-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } .jekyll-publish-row input { flex: 1; } + +/* Read-only document frontmatter fields shown when "Merge document + frontmatter" is on — visually muted to signal they cannot be edited here. */ +.jekyll-publish-row input.jekyll-publish-doc { + opacity: 0.65; + cursor: not-allowed; +} + +/* Delete button on an editable property row. */ +.jekyll-publish-del { + flex: 0 0 auto; + width: 28px; + padding: 0; + line-height: 1; +}