feat: unified, editable frontmatter editor in publish modal
All checks were successful
CI / test (push) Successful in 16s
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:
@@ -1,5 +1,5 @@
|
|||||||
import { App, Modal, Setting } from "obsidian";
|
import { App, Modal, Setting } from "obsidian";
|
||||||
import { Pair, parseNote, resolveFrontmatter } from "./frontmatter";
|
import { Pair, composeFrontmatter, docFrontmatterToPairs, parseNote } from "./frontmatter";
|
||||||
import { Strategy } from "./images";
|
import { Strategy } from "./images";
|
||||||
import { JekyllPublishSettings } from "./settings";
|
import { JekyllPublishSettings } from "./settings";
|
||||||
import { deriveDate, deriveSlug } from "./slug";
|
import { deriveDate, deriveSlug } from "./slug";
|
||||||
@@ -15,18 +15,26 @@ export interface ModalResult {
|
|||||||
export class PublishModal extends Modal {
|
export class PublishModal extends Modal {
|
||||||
private result: ModalResult;
|
private result: ModalResult;
|
||||||
private mergeDoc = true;
|
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(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
private settings: JekyllPublishSettings,
|
settings: JekyllPublishSettings,
|
||||||
private noteText: string,
|
private noteText: string,
|
||||||
private filename: string,
|
filename: string,
|
||||||
private onSubmit: (r: ModalResult) => void
|
private onSubmit: (r: ModalResult) => void
|
||||||
) {
|
) {
|
||||||
super(app);
|
super(app);
|
||||||
const { frontmatter } = parseNote(noteText);
|
const { frontmatter } = parseNote(noteText);
|
||||||
const title = typeof frontmatter.title === "string" ? frontmatter.title : "";
|
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 = {
|
this.result = {
|
||||||
frontmatterPairs: [],
|
frontmatterPairs: [],
|
||||||
slug: deriveSlug({ title, filename }),
|
slug: deriveSlug({ title, filename }),
|
||||||
@@ -37,13 +45,7 @@ export class PublishModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private recompute() {
|
private recompute() {
|
||||||
const { frontmatter } = parseNote(this.noteText);
|
this.result.frontmatterPairs = composeFrontmatter(this.docPairs, this.editableRows, this.mergeDoc);
|
||||||
this.result.frontmatterPairs = resolveFrontmatter(
|
|
||||||
this.settings.presetFrontmatter,
|
|
||||||
frontmatter,
|
|
||||||
this.custom,
|
|
||||||
this.mergeDoc
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpen() {
|
onOpen() {
|
||||||
@@ -63,16 +65,23 @@ export class PublishModal extends Modal {
|
|||||||
.setValue(this.result.strategy)
|
.setValue(this.result.strategy)
|
||||||
.onChange((v) => (this.result.strategy = v as Strategy))
|
.onChange((v) => (this.result.strategy = v as Strategy))
|
||||||
);
|
);
|
||||||
new Setting(contentEl).setName("Merge document frontmatter").addToggle((t) =>
|
new Setting(contentEl)
|
||||||
t.setValue(this.mergeDoc).onChange((v) => { this.mergeDoc = v; this.renderFrontmatter(); })
|
.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.fmEl = contentEl.createDiv();
|
||||||
this.renderFrontmatter();
|
this.renderFrontmatter();
|
||||||
|
|
||||||
new Setting(contentEl).addButton((b) =>
|
new Setting(contentEl).addButton((b) =>
|
||||||
b.setButtonText("Add property").onClick(() => {
|
b.setButtonText("Add property").onClick(() => {
|
||||||
this.custom.push({ key: "", value: "" });
|
this.editableRows = [...this.editableRows, { key: "", value: "" }];
|
||||||
this.renderFrontmatter();
|
this.renderFrontmatter();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -88,25 +97,52 @@ export class PublishModal extends Modal {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fmEl!: HTMLElement;
|
|
||||||
private renderFrontmatter() {
|
private renderFrontmatter() {
|
||||||
this.recompute();
|
this.recompute();
|
||||||
this.fmEl.empty();
|
this.fmEl.empty();
|
||||||
this.fmEl.createEl("h3", { text: "Frontmatter (resolved)" });
|
|
||||||
for (const p of this.result.frontmatterPairs) {
|
const editableKeys = new Set(
|
||||||
this.fmEl.createDiv({ cls: "jekyll-publish-row", text: `${p.key}: ${p.value}` });
|
this.editableRows.filter((r) => r.key.trim() !== "").map((r) => r.key)
|
||||||
}
|
);
|
||||||
if (this.custom.length) {
|
|
||||||
this.fmEl.createEl("h4", { text: "Custom properties" });
|
// Document fields (read-only) first — only those not overridden by an
|
||||||
this.custom.forEach((row, i) => {
|
// editable row, so the modal never shows a key twice.
|
||||||
const div = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
if (this.mergeDoc) {
|
||||||
const k = div.createEl("input", { value: row.key, placeholder: "key" });
|
const docRows = this.docPairs.filter((p) => !editableKeys.has(p.key));
|
||||||
const v = div.createEl("input", { value: row.value, placeholder: "value" });
|
for (const p of docRows) {
|
||||||
k.oninput = () => { this.custom[i].key = k.value; this.recompute(); };
|
const row = this.fmEl.createDiv({ cls: "jekyll-publish-row" });
|
||||||
v.oninput = () => { this.custom[i].value = v.value; this.recompute(); };
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { parseNote, resolveFrontmatter, serializeFrontmatter } from "./frontmatter";
|
import { composeFrontmatter, docFrontmatterToPairs, parseNote, serializeFrontmatter } from "./frontmatter";
|
||||||
|
|
||||||
describe("parseNote", () => {
|
describe("parseNote", () => {
|
||||||
test("splits frontmatter and body, strips BOM", () => {
|
test("splits frontmatter and body, strips BOM", () => {
|
||||||
@@ -12,27 +12,70 @@ describe("parseNote", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveFrontmatter", () => {
|
describe("docFrontmatterToPairs", () => {
|
||||||
const presets = [{ key: "layout", value: "post" }, { key: "kind", value: "essay" }];
|
test("converts a record to ordered string pairs", () => {
|
||||||
test("presets only when mergeDoc is false", () => {
|
const pairs = docFrontmatterToPairs({
|
||||||
expect(resolveFrontmatter(presets, { title: "X" }, [], false)).toEqual(presets);
|
title: "Hello",
|
||||||
});
|
date: new Date("2026-02-06T00:00:00Z"),
|
||||||
test("merge appends doc keys and doc overrides preset value", () => {
|
draft: true,
|
||||||
const r = resolveFrontmatter(presets, { kind: "note", title: "X" }, [], true);
|
});
|
||||||
expect(r).toEqual([
|
expect(pairs).toEqual([
|
||||||
{ key: "layout", value: "post" },
|
{ key: "title", value: "Hello" },
|
||||||
{ key: "kind", value: "note" },
|
{ key: "date", value: "2026-02-06" },
|
||||||
{ key: "title", value: "X" },
|
{ key: "draft", value: "true" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
test("custom rows come last and override everything", () => {
|
test("empty record yields an empty array", () => {
|
||||||
const r = resolveFrontmatter(presets, {}, [{ key: "layout", value: "page" }], false);
|
expect(docFrontmatterToPairs({})).toEqual([]);
|
||||||
expect(r[0]).toEqual({ key: "layout", value: "page" });
|
|
||||||
expect(r).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
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" }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,26 @@ export function parseNote(text: string): { frontmatter: Record<string, unknown>;
|
|||||||
return { frontmatter, body: stripped.slice(m[0].length) };
|
return { frontmatter, body: stripped.slice(m[0].length) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFrontmatter(
|
/**
|
||||||
presets: Pair[],
|
* Convert a parsed note's frontmatter object into ordered, stringified pairs
|
||||||
docFrontmatter: Record<string, unknown>,
|
* suitable for display (read-only) and composition.
|
||||||
custom: Pair[],
|
*/
|
||||||
mergeDoc: boolean
|
export function docFrontmatterToPairs(docFrontmatter: Record<string, unknown>): Pair[] {
|
||||||
): 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 out: Pair[] = [];
|
||||||
const idx = new Map<string, number>();
|
const idx = new Map<string, number>();
|
||||||
const put = (key: string, value: string) => {
|
const put = (key: string, value: string) => {
|
||||||
@@ -27,9 +41,8 @@ export function resolveFrontmatter(
|
|||||||
if (at !== undefined) out[at] = { key, value };
|
if (at !== undefined) out[at] = { key, value };
|
||||||
else { idx.set(key, out.length); out.push({ 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 p of docPairs) if (!editableKeys.has(p.key)) put(p.key, p.value);
|
||||||
if (mergeDoc) for (const [k, v] of Object.entries(docFrontmatter)) put(k, stringifyScalar(v));
|
for (const r of editable) put(r.key, r.value);
|
||||||
for (const c of custom) put(c.key, c.value);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
styles.css
17
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; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user