feat: unified, editable frontmatter editor in publish modal
All checks were successful
CI / test (push) Successful in 16s

- Preset fields (from settings) are now seeded as editable/deletable rows in
  the modal alongside custom ones, using one consistent row style.
- The note's own merged frontmatter is shown as read-only (disabled) rows at
  the top of the list (only when 'Merge document frontmatter' is on, and only
  for keys not overridden by an editable row).
- Drops the separate read-only 'resolved' text preview; the rows are the
  WYSIWYG result.

Adds pure, unit-tested composeFrontmatter()/docFrontmatterToPairs() and removes
the superseded resolveFrontmatter(). Editable rows are authoritative: on a key
collision they override the merged document field. Presets are deep-copied so
editing rows never mutates saved settings.

Co-Authored-By: Claude
This commit is contained in:
2026-06-19 03:20:36 +00:00
parent f2a4040ae8
commit fb5d11dd6a
4 changed files with 166 additions and 59 deletions

View File

@@ -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();
}
}