diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0964aaf..dd1d0cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.9.3 Quality and Security Release
+
+- Added searchable settings definitions for Obsidian 1.13+ while retaining the imperative settings fallback required by the declared Obsidian 1.5 minimum.
+- Removed deprecated destructive-button calls and the lint exemptions that hid them.
+- Replaced regular-expression lookbehind so mobile builds remain compatible with older iOS WebKit.
+- Hardened HTML/EPUB active-content validation, CSS settings escaping, profile imports, download filenames, and author-facing error redaction.
+- Restored the complete version compatibility map and added manifest author and optional funding metadata.
+- Replaced placeholder support actions with explicit GitHub issue links and clarified external-link and funding disclosures.
+- Reduced plugin startup work by persisting settings only when repair or migration changes stored data.
+
## 0.9.2 Release Candidate
### Universal export workflow
diff --git a/MANUAL_TESTING.md b/MANUAL_TESTING.md
index c2d4259..d742ad3 100644
--- a/MANUAL_TESTING.md
+++ b/MANUAL_TESTING.md
@@ -1,4 +1,4 @@
-# Manuscript Compiler 0.9.2 Manual Release Checklist
+# Manuscript Compiler 0.9.3 Manual Release Checklist
All items are intentionally unchecked. Record date, tester, Obsidian version, operating system, and application versions. Automated tests do not complete these gates.
diff --git a/README.md b/README.md
index fc59fd9..ee70548 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Manuscript Compiler
-Manuscript Compiler 0.9.2 turns fiction in an Obsidian vault into DOCX, ODT, EPUB, standalone HTML, Markdown, or structured XML. It works offline, never changes source notes, and requires neither Pandoc nor another community plugin.
+Manuscript Compiler 0.9.3 turns fiction in an Obsidian vault into DOCX, ODT, EPUB, standalone HTML, Markdown, or structured XML. It works offline, never changes source notes, and requires neither Pandoc nor another community plugin.
Only content included in the reviewed manuscript structure is exported. Project metadata, author notes, dashboards, and excluded notes are not manuscript content.
@@ -60,16 +60,16 @@ All formats are generated and structurally validated in memory before delivery.
Completed manuscript exports are never written into the vault. Historical vault-output settings remain storage-only migration data and cannot activate an old output route. The diagnostics command may still create an explicitly requested privacy-safe Markdown diagnostics note in the vault; that is not a manuscript export.
-The plugin has no Electron bridge, Node filesystem export path, network requests, telemetry, cloud service, remote assets, shell command, or external executable. `fflate` is the sole bundled runtime dependency and supplies ZIP creation/inspection.
+The plugin has no Electron bridge, Node filesystem export path, background network requests, telemetry, cloud service, remote assets, shell command, or external executable. Support and funding links open an external website only when the user explicitly selects them. `fflate` is the sole bundled runtime dependency and supplies ZIP creation/inspection.
### Disclosures
| Topic | Disclosure |
| --- | --- |
| External file access | Browser/host-controlled download only; the host chooses the destination. |
-| Network | None. Export, validation, and delivery initiation are offline. |
+| Network | No background requests. Export, validation, and delivery initiation are offline. User-selected support links open in the system browser. |
| Accounts | None. |
-| Payments and advertising | None. |
+| Payments and advertising | No advertising or in-plugin payments. An optional external donation link is identified as **Buy me a coffee**. |
| Telemetry and analytics | None. |
| Closed-source components | None. |
| Runtime dependencies | `fflate` 0.8.3, bundled under its MIT licence. |
@@ -106,7 +106,7 @@ npm audit
git diff --check
```
-The release archive is `release/manuscript-compiler-0.9.2.zip` and contains exactly `main.js`, `manifest.json`, and `styles.css`.
+The release archive is `release/manuscript-compiler-0.9.3.zip` and contains exactly `main.js`, `manifest.json`, and `styles.css`.
Automated structural validation is not a substitute for opening outputs in Word, Vellum, LibreOffice, multiple EPUB readers, text editors, and browsers. See [MANUAL_TESTING.md](MANUAL_TESTING.md).
diff --git a/RELEASE_READINESS.md b/RELEASE_READINESS.md
index 8df3b8e..015d66f 100644
--- a/RELEASE_READINESS.md
+++ b/RELEASE_READINESS.md
@@ -1,8 +1,8 @@
-# 0.9.2 Release Readiness
+# 0.9.3 Release Readiness
## Current status
-Version 0.9.2 remains a prerelease candidate. Automated gates cover the shared prepared Book, six native generators, format validators, browser-download cleanup, privacy, migration, and release allowlist. Live interoperability and platform download testing remain release blockers; this document does not claim they were performed.
+Version 0.9.3 remains a prerelease candidate. Automated gates cover the shared prepared Book, six native generators, format validators, browser-download cleanup, privacy, migration, and release allowlist. Live interoperability and platform download testing remain release blockers; this document does not claim they were performed.
Maintainer and author documentation is provided in `ARCHITECTURE.md`, `DEVELOPER_GUIDE.md`, `USER_GUIDE.md`, `SECURITY.md`, and `CONTRIBUTING.md`. Screenshot placeholders in the user guide remain publication work and are not claims of completed manual testing.
@@ -15,7 +15,7 @@ Maintainer and author documentation is provided in `ARCHITECTURE.md`, `DEVELOPER
- For DOCX, ODT, EPUB, or HTML, choose whether later body paragraphs use the configured first-line indent; first paragraphs after headings and scene breaks remain unindented. Markdown exposes a portability note instead, and XML delegates presentation to its consumer.
- Generate and validate bytes in memory, then start the host browser download/share flow.
-No completed export is written into the Obsidian vault. There is no vault fallback, Electron path, external executable, network request, telemetry, or dependency on another community plugin. The host controls the final destination and the plugin cannot verify that external filesystem copy after download dispatch.
+No completed export is written into the Obsidian vault. There is no vault fallback, Electron path, external executable, background network request, telemetry, or dependency on another community plugin. User-selected support links may open an external website. The host controls the final destination and the plugin cannot verify that external filesystem copy after download dispatch.
## Automated gates
@@ -38,9 +38,9 @@ Run and record current output before publishing:
- `npm audit`
- `git diff --check`
-The release archive must be `release/manuscript-compiler-0.9.2.zip` and contain exactly `main.js`, `manifest.json`, and `styles.css`.
+The release archive must be `release/manuscript-compiler-0.9.3.zip` and contain exactly `main.js`, `manifest.json`, and `styles.css`.
-Attach those three files individually to the GitHub release. The release tag must be exactly `0.9.2`, without a `v` prefix; the ZIP is optional and is not an installation dependency.
+Attach those three files individually to the GitHub release. The release tag must be exactly `0.9.3`, without a `v` prefix; the ZIP is optional and is not an installation dependency.
## Runtime dependencies
diff --git a/SECURITY.md b/SECURITY.md
index defc45e..39c04c6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,7 +6,7 @@ Manuscript Compiler is an offline Obsidian plugin. It reads manuscript notes thr
The production plugin has:
-- no network requests;
+- no background network requests; user-selected support and funding links may open an external website;
- no telemetry, analytics, advertising, accounts, or cloud service;
- no remote fonts, stylesheets, scripts, or assets;
- no Electron bridge or Node filesystem export;
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index 933f34a..700ce18 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -774,7 +774,7 @@ Structure corrections are stored as compiler choices rather than implemented by
**Does it upload my manuscript?**
-No. There are no network requests, accounts, telemetry, or cloud services.
+No manuscript or settings data is sent over the network. There are no accounts, telemetry, cloud services, or background network requests. Support and funding links open an external website only when you select them.
Compilation and package validation happen locally. The operating system or application chosen in the save/share flow may have its own cloud-sync behavior, which is outside the plugin's control.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 959c6be..6b9f5ed 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -32,12 +32,4 @@ export default defineConfig(
}],
},
},
- {
- files: ["src/ui.ts"],
- rules: {
- // Obsidian <1.13 requires display() and lacks setDestructive().
- "@typescript-eslint/no-deprecated": "off",
- "obsidianmd/settings-tab/prefer-setting-definitions": "off",
- },
- },
);
diff --git a/manifest.json b/manifest.json
index 0748383..07ded58 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,9 +1,11 @@
{
"id": "manuscript-compiler",
"name": "Manuscript Compiler",
- "version": "0.9.2",
+ "version": "0.9.3",
"minAppVersion": "1.5.0",
"description": "Compile publishable manuscript content to DOCX, ODT, EPUB, HTML, Markdown, or XML files.",
"author": "Anthony Fitzpatrick",
+ "authorUrl": "https://wolf359.app",
+ "fundingUrl": "https://buymeacoffee.com/wolf359pressab",
"isDesktopOnly": false
}
diff --git a/package-lock.json b/package-lock.json
index c41b33d..f0dcdf8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "obsidian-manuscript-compiler",
- "version": "0.9.2",
+ "version": "0.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-manuscript-compiler",
- "version": "0.9.2",
+ "version": "0.9.3",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.3"
diff --git a/package.json b/package.json
index 2551024..1f30a53 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsidian-manuscript-compiler",
- "version": "0.9.2",
+ "version": "0.9.3",
"description": "Validate, preview, and compile Obsidian book manuscripts to DOCX, ODT, EPUB, HTML, Markdown, or XML.",
"main": "main.js",
"scripts": {
diff --git a/scripts/package.mjs b/scripts/package.mjs
index 94a4c03..e48edd2 100644
--- a/scripts/package.mjs
+++ b/scripts/package.mjs
@@ -55,6 +55,8 @@ function validateManifest() {
assert(semverPattern.test(manifest.version) && semverPattern.test(manifest.minAppVersion), "Manifest versions must use x.y.z semantic versions.");
assert(typeof manifest.description === "string" && manifest.description.trim().length > 0, "Manifest description is required.");
assert(typeof manifest.author === "string" && manifest.author.trim().length > 0, "Manifest author is required.");
+ if (manifest.authorUrl !== undefined) assert(/^https:\/\//.test(manifest.authorUrl), "Manifest authorUrl must use HTTPS.");
+ if (manifest.fundingUrl !== undefined) assert(typeof manifest.fundingUrl === "string" && /^https:\/\//.test(manifest.fundingUrl), "Manifest fundingUrl must use HTTPS.");
assert(typeof manifest.isDesktopOnly === "boolean", "Manifest isDesktopOnly must be a boolean.");
}
@@ -62,7 +64,7 @@ function validateVersions() {
const entries = Object.entries(versions);
assert(entries.length > 0, "versions.json must contain at least one minimum-version boundary.");
for (const [pluginVersion, appVersion] of entries) assert(semverPattern.test(pluginVersion) && typeof appVersion === "string" && semverPattern.test(appVersion), "versions.json contains an invalid version.");
- assert(entries.at(-1)?.[1] === manifest.minAppVersion, `Latest versions.json boundary must match minAppVersion ${manifest.minAppVersion}.`);
+ assert(versions[manifest.version] === manifest.minAppVersion, `versions.json must map ${manifest.version} to minAppVersion ${manifest.minAppVersion}.`);
}
async function jsonFile(filename) { return JSON.parse(await readFile(filename, "utf8")); }
diff --git a/src/docx.ts b/src/docx.ts
index e93f046..9976127 100644
--- a/src/docx.ts
+++ b/src/docx.ts
@@ -256,15 +256,16 @@ function plainParagraph(value: string, style: string): string {
function inlineRuns(value: string, wholeBold = false): string {
if (wholeBold) return run(value, "");
const runs: string[] = [];
- const pattern = /(\*\*\*|___)(.+?)\1|(\*\*|__)(.+?)\3|(? offset) runs.push(run(value.slice(offset, match.index)));
if (match[2]) runs.push(run(match[2], ""));
else if (match[4]) runs.push(run(match[4], ""));
- else if (match[5] || match[6]) runs.push(run(match[5] ?? match[6], ""));
- else runs.push(run(match[7] ?? match[8] ?? ""));
+ else if (match[6]) { if (match[5]) runs.push(run(match[5])); runs.push(run(match[6], "")); }
+ else if (match[7]) runs.push(run(match[7], ""));
+ else runs.push(run(match[8] ?? match[9] ?? ""));
offset = match.index + match[0].length;
}
if (offset < value.length) runs.push(run(value.slice(offset)));
diff --git a/src/main.ts b/src/main.ts
index f3ea540..adbe5ef 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -34,31 +34,34 @@ export default class ManuscriptCompilerPlugin extends Plugin {
private history!: CompileHistoryService;
private exporter!: ExportCoordinator;
private commands!: CompileCommandService;
+ private active = false;
/** Loads durable state, composes services once, and registers all Obsidian entry points. */
async onload(): Promise {
await this.loadSettings();
+ this.active = true;
this.composeServices();
this.addSettingTab(new ManuscriptCompilerSettingTab(this.app, this));
this.registerCommands();
this.registerFolderContextMenu();
this.app.workspace.onLayoutReady(() => {
- if (!this.settings.onboardingCompleted) new FirstRunWizardModal(this.app, this).open();
+ if (this.active && !this.settings.onboardingCompleted) new FirstRunWizardModal(this.app, this).open();
});
}
/** Cancels work that has not crossed its non-cancellable file-finalisation boundary. */
- onunload(): void { this.operations.cancel(); }
+ onunload(): void { this.active = false; this.operations.cancel(); }
/** Repairs persisted data idempotently before any service is allowed to read it. */
async loadSettings(): Promise {
const raw = await this.loadData() as Partial | null;
+ const stored = raw ? JSON.stringify(raw) : null;
const loaded = Object.assign({}, DEFAULT_SETTINGS, raw);
if (raw && raw.onboardingCompleted === undefined) loaded.onboardingCompleted = true;
const previousWarnings = Array.isArray(loaded.configurationWarnings) ? loaded.configurationWarnings.length : 0;
this.settings = repairSettings(loaded);
if (raw && raw.defaultStructurePreset === undefined) this.settings.defaultStructurePreset = inferStructurePreset(this.getActiveProfile());
- await this.saveSettings();
+ if (stored !== null && JSON.stringify(this.settings) !== stored) await this.saveSettings();
if (this.settings.configurationWarnings.length > previousWarnings) new Notice("Manuscript Compiler repaired invalid settings. Run validate manuscript for details.", 8000);
}
@@ -93,10 +96,10 @@ export default class ManuscriptCompilerPlugin extends Plugin {
private registerCommands(): void {
this.addCommand({ id: COMMAND_IDS.compileManuscript, name: "Compile manuscript", callback: () => this.openCompiler() });
- this.addCommand({ id: COMMAND_IDS.compileCurrentBook, name: "Compile current book", callback: () => { void this.commands.compileCurrentBook(); } });
+ this.addCommand({ id: COMMAND_IDS.compileCurrentBook, name: "Compile current book", callback: async () => { await this.commands.compileCurrentBook(); } });
this.addCommand({ id: COMMAND_IDS.compileSelectedFolder, name: "Compile selected folder", callback: () => { new FolderSuggestModal(this.app, (folder) => { void this.commands.compileFolder(folder, undefined, [], "selected-folder"); }).open(); } });
- this.addCommand({ id: COMMAND_IDS.validateManuscript, name: "Validate manuscript", callback: () => { void this.commands.validateManuscript(); } });
- this.addCommand({ id: COMMAND_IDS.generateDiagnostics, name: "Generate diagnostics report", callback: () => { void this.commands.generateDiagnostics(); } });
+ this.addCommand({ id: COMMAND_IDS.validateManuscript, name: "Validate manuscript", callback: async () => { await this.commands.validateManuscript(); } });
+ this.addCommand({ id: COMMAND_IDS.generateDiagnostics, name: "Generate diagnostics report", callback: async () => { await this.commands.generateDiagnostics(); } });
}
private registerFolderContextMenu(): void {
diff --git a/src/parser.ts b/src/parser.ts
index f323dc7..d051ffe 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -33,7 +33,6 @@ export class ManuscriptParser {
*/
async parse(scan: ScannedBook, settings: CompileOptions, signal?: AbortSignal): Promise {
const warnings = [...scan.warnings];
- if (scan.hierarchyDiagnostics?.length) console.warn(`Manuscript Compiler found ${scan.hierarchyDiagnostics.length} hierarchy diagnostic(s).`);
const unreadable = new Map();
const cache = new Map();
this.filterDurationMs = 0;
diff --git a/src/semantic-document.ts b/src/semantic-document.ts
index 16c57cb..7c24327 100644
--- a/src/semantic-document.ts
+++ b/src/semantic-document.ts
@@ -65,8 +65,8 @@ export function bodyBlocks(markdown: string): SemanticBlock[] {
/** Extracts supported emphasis, code text, and readable links without retaining link targets in plain output. */
export function inlineMarkdown(value: string): SemanticInline[] {
- const output: SemanticInline[] = []; const pattern = /(\*\*\*|___)(.+?)\1|(\*\*|__)(.+?)\3|(? offset) output.push({ text: value.slice(offset, match.index) }); if (match[2]) output.push({ text: match[2], bold: true, italic: true }); else if (match[4]) output.push({ text: match[4], bold: true }); else if (match[5] || match[6]) output.push({ text: match[5] ?? match[6], italic: true }); else if (match[7]) output.push({ text: match[7], href: match[8] }); else output.push({ text: match[9] ?? "" }); offset = match.index + match[0].length; } if (offset < value.length) output.push({ text: value.slice(offset) }); return output.length ? output : [{ text: value }];
+ const output: SemanticInline[] = []; const pattern = /(\*\*\*|___)(.+?)\1|(\*\*|__)(.+?)\3|(^|[^*])\*([^*]+?)\*|_([^_]+?)_|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`/g; let offset = 0; let match: RegExpExecArray | null;
+ while ((match = pattern.exec(value))) { if (match.index > offset) output.push({ text: value.slice(offset, match.index) }); if (match[2]) output.push({ text: match[2], bold: true, italic: true }); else if (match[4]) output.push({ text: match[4], bold: true }); else if (match[6]) { if (match[5]) output.push({ text: match[5] }); output.push({ text: match[6], italic: true }); } else if (match[7]) output.push({ text: match[7], italic: true }); else if (match[8]) output.push({ text: match[8], href: match[9] }); else output.push({ text: match[10] ?? "" }); offset = match.index + match[0].length; } if (offset < value.length) output.push({ text: value.slice(offset) }); return output.length ? output : [{ text: value }];
}
/** Returns presentation-free visible text for validation; performs no unescaping or mutation. */
export function plainText(block: SemanticBlock): string { return "inlines" in block ? block.inlines.map((item) => item.text).join("") : block.kind === "scene-break" ? block.text : ""; }
diff --git a/src/ui.ts b/src/ui.ts
index 12d7c65..f248569 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -6,7 +6,7 @@
* workspace. It calls plugin/service callbacks and must not implement scanning,
* parsing, safe-write transactions, or platform filesystem bridges.
*/
-import { App, ButtonComponent, FuzzySuggestModal, Modal, Notice, PluginSettingTab, Setting, TextAreaComponent, TFolder } from "obsidian";
+import { App, ButtonComponent, FuzzySuggestModal, Modal, Notice, PluginSettingTab, Setting, TextAreaComponent, TFolder, type SettingDefinitionItem } from "obsidian";
import type ManuscriptCompilerPlugin from "./main";
import type { Chapter, CompilePreview, CompileWarning, ManuscriptDocument, Part } from "./model";
import { duplicateProfile, validateProfile } from "./profiles";
@@ -62,7 +62,7 @@ export class CompilePreviewModal extends Modal {
export class CompilationProgressModal extends Modal {
private statusEl?: HTMLElement; private cancelButton?: HTMLButtonElement; private cancelled = false; private completed = false;
constructor(app: App, private readonly cancel: () => void) { super(app); }
- onOpen(): void { this.titleEl.setText("Compiling manuscript"); this.statusEl = this.contentEl.createEl("p", { text: "Preparing…" }); this.statusEl.setAttribute("role", "status"); this.statusEl.setAttribute("aria-live", "polite"); new Setting(this.contentEl).addButton((button) => { this.cancelButton = button.buttonEl; button.setButtonText("Cancel compilation").setWarning().onClick(() => { if (this.cancelled) return; this.cancelled = true; button.setDisabled(true).setButtonText("Cancelling…"); this.cancel(); }); }); }
+ onOpen(): void { this.titleEl.setText("Compiling manuscript"); this.statusEl = this.contentEl.createEl("p", { text: "Preparing…" }); this.statusEl.setAttribute("role", "status"); this.statusEl.setAttribute("aria-live", "polite"); new Setting(this.contentEl).addButton((button) => { this.cancelButton = button.buttonEl; destructive(button.setButtonText("Cancel compilation")).onClick(() => { if (this.cancelled) return; this.cancelled = true; button.setDisabled(true).setButtonText("Cancelling…"); this.cancel(); }); }); }
update(stage: string): void { if (!this.cancelled) this.statusEl?.setText(stage); }
lock(stage: string): void { this.completed = true; this.statusEl?.setText(stage); if (this.cancelButton) { this.cancelButton.disabled = true; this.cancelButton.setText("Finalising file…"); } }
finish(): void { this.completed = true; this.close(); }
@@ -86,12 +86,12 @@ class ProfileJsonModal extends Modal {
constructor(app: App, private readonly mode: "import" | "export", profile: CompileProfile, private readonly done: (value?: string) => void | Promise) { super(app); this.value = mode === "export" ? JSON.stringify(profile, null, 2) : ""; }
onOpen(): void { this.titleEl.setText(`${this.mode === "import" ? "Import" : "Export"} compile profile`); const area = new TextAreaComponent(this.contentEl); area.setValue(this.value).onChange((value) => { this.value = value; }); area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); if (this.mode === "import") new Setting(this.contentEl).addButton((button) => button.setButtonText("Import").setCta().onClick(() => { void this.done(this.value); this.close(); })); }
}
-class ExportHistoryModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Export history"); this.plugin.settings.exportHistory.forEach((entry) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${entry.cancelled ? "—" : entry.success ? "✓" : "✗"} ${new Date(entry.timestamp).toLocaleString()} — ${(entry.format ?? "docx").toUpperCase()}` }); details.createEl("p", { text: `${entry.manuscript} · ${entry.wordCount.toLocaleString()} words` }); entry.outputFiles.forEach((filename) => details.createEl("p", { text: filename })); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Clear history and logs").setWarning().onClick(async () => { await this.plugin.clearHistory(); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
+class ExportHistoryModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Export history"); this.plugin.settings.exportHistory.forEach((entry) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${entry.cancelled ? "—" : entry.success ? "✓" : "✗"} ${new Date(entry.timestamp).toLocaleString()} — ${(entry.format ?? "docx").toUpperCase()}` }); details.createEl("p", { text: `${entry.manuscript} · ${entry.wordCount.toLocaleString()} words` }); entry.outputFiles.forEach((filename) => details.createEl("p", { text: filename })); }); new Setting(this.contentEl).addButton((button) => destructive(button.setButtonText("Clear history and logs")).onClick(async () => { await this.plugin.clearHistory(); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
class CompileLogsModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Compile logs"); this.plugin.settings.compileLogs.forEach((log) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${log.cancelled ? "Cancelled" : log.success ? "Success" : "Failure"} — ${new Date(log.timestamp).toLocaleString()} — ${log.profile}` }); const pre = details.createEl("pre"); pre.setText([`Compiler: ${log.compilerVersion}`, `Manuscript: ${log.manuscript}`, `Formats: ${log.exportFormats}`, `Outputs: ${log.outputFiles.join(", ") || "None"}`, `Duration: ${log.durationMs} ms`, `Warnings: ${log.warnings.join(" | ") || "None"}`, log.diagnostics ? `Diagnostics:\n${log.diagnostics}` : ""].filter(Boolean).join("\n")); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
const SUPPORT_ACTIONS = [
- { label: "Report a bug", icon: "bug", notice: "Bug reporting portal coming soon." },
- { label: "Feature request", icon: "lightbulb", notice: "Feature request portal coming soon." },
+ { label: "Report a bug", icon: "bug", url: "https://github.com/anthonyfitzpatrick/manuscript-compiler/issues/new?labels=bug" },
+ { label: "Feature request", icon: "lightbulb", url: "https://github.com/anthonyfitzpatrick/manuscript-compiler/issues/new?labels=enhancement" },
{ label: "wolf359.app", icon: "globe", url: "https://wolf359.app" },
{ label: "Wolf 359 Press", icon: "book-open", url: "https://wolf359.press" },
{ label: "Buy me a coffee", icon: "coffee", url: "https://buymeacoffee.com/wolf359pressab" }
@@ -103,6 +103,30 @@ const SUPPORT_SECTION_TITLE = "Support & Links";
/** Defaults/advanced compatibility settings; not the primary compile workspace. */
export class ManuscriptCompilerSettingTab extends PluginSettingTab {
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app, plugin); }
+ /** Provides searchable settings on Obsidian 1.13+ while display() remains the pre-1.13 fallback. */
+ getSettingDefinitions(): SettingDefinitionItem[] {
+ const settings = this.plugin.settings; const profile = this.plugin.getActiveProfile();
+ return [
+ { name: "Compiler", desc: "Open the guided manuscript compiler.", render: (setting) => { setting.addButton((button) => button.setButtonText("Open compiler").setCta().onClick(() => this.plugin.openCompiler())); } },
+ { type: "group", heading: "Defaults", items: [
+ { name: "Default manuscript folder", aliases: ["Manuscript root", "Book folder"], render: (setting) => { setting.addText((text) => text.setValue(settings.defaultManuscriptFolder).onChange(async (value) => { settings.defaultManuscriptFolder = value.trim(); await this.plugin.saveSettings(); })); } },
+ { name: "Default structure", render: (setting) => { setting.addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => { dropdown.addOption(value, label); }); dropdown.setValue(settings.defaultStructurePreset).onChange(async (value) => { settings.defaultStructurePreset = value as StructurePreset; await this.plugin.saveSettings(); }); }); } },
+ { name: "Default format", render: (setting) => { setting.addDropdown((dropdown) => { EXPORT_FORMATS.forEach((format) => { dropdown.addOption(format, EXPORT_FORMAT_DETAILS[format].label); }); dropdown.setValue(settings.defaultDownloadFormat).onChange(async (value) => { settings.defaultDownloadFormat = value as ExportFormat; await this.plugin.saveSettings(); }); }); } },
+ { name: "Default document style", render: (setting) => { setting.addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum-ready").addOption("standard", "Standard manuscript").setValue(settings.defaultDocxStyle === "custom" ? "standard" : settings.defaultDocxStyle).onChange(async (value) => { settings.defaultDocxStyle = value as DocxStylePreset; await this.plugin.saveSettings(); })); } },
+ { name: "Include title page", render: (setting) => { setting.addToggle((toggle) => toggle.setValue(settings.includeTitlePageByDefault).onChange(async (value) => { settings.includeTitlePageByDefault = value; await this.plugin.saveSettings(); })); } },
+ { name: "Include table of contents", render: (setting) => { setting.addToggle((toggle) => toggle.setValue(settings.includeTableOfContentsByDefault).onChange(async (value) => { settings.includeTableOfContentsByDefault = value; await this.plugin.saveSettings(); })); } }
+ ] },
+ { type: "group", heading: "Advanced profiles, records, and compatibility", items: [
+ { name: "Advanced profile", desc: "Used for customised or older workflows.", render: (setting) => { setting.addDropdown((dropdown) => { settings.profiles.forEach((item) => { dropdown.addOption(item.id, item.name); }); dropdown.setValue(profile.id).onChange(async (id) => { settings.activeProfileId = id; await this.saveAndRender(); }); }); } },
+ { name: "Profile actions", render: (setting) => { setting.addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.saveAndRender(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(async () => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; await this.saveAndRender(); })).addButton((button) => destructive(button.setButtonText("Delete")).setDisabled(settings.profiles.length < 2).onClick(async () => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; await this.saveAndRender(); })); } },
+ { name: "Profile JSON", aliases: ["Import profile", "Export profile"], render: (setting) => { setting.addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, (json) => this.importProfileJson(json)).open())); } },
+ { name: "Export records", aliases: ["History", "Logs"], render: (setting) => { setting.addButton((button) => button.setButtonText(`History (${settings.exportHistory.length})`).onClick(() => new ExportHistoryModal(this.app, this.plugin).open())).addButton((button) => button.setButtonText(`Logs (${settings.compileLogs.length})`).onClick(() => new CompileLogsModal(this.app, this.plugin).open())); } }
+ ] },
+ { type: "group", heading: SUPPORT_SECTION_TITLE, items: [
+ { name: "About and support", desc: "Version, creator, support, website, and optional funding links.", render: (setting) => { setting.settingEl.addClass("manuscript-support-panel"); this.renderSupportContent(setting.settingEl); } }
+ ] }
+ ];
+ }
display(): void { this.renderSettings(); }
private renderSettings(): void {
const container = this.containerEl; const settings = this.plugin.settings; const profile = this.plugin.getActiveProfile(); container.empty(); container.addClass("manuscript-compiler-settings");
@@ -114,15 +138,8 @@ export class ManuscriptCompilerSettingTab extends PluginSettingTab {
this.toggle(container, "Include title page", settings.includeTitlePageByDefault, (value) => { settings.includeTitlePageByDefault = value; }); this.toggle(container, "Include table of contents", settings.includeTableOfContentsByDefault, (value) => { settings.includeTableOfContentsByDefault = value; });
const advanced = container.createEl("details"); advanced.createEl("summary", { text: "Advanced profiles, records, and compatibility" });
new Setting(advanced).setName("Advanced profile").setDesc("Used for customised or older workflows.").addDropdown((dropdown) => { settings.profiles.forEach((item) => { dropdown.addOption(item.id, item.name); }); dropdown.setValue(profile.id).onChange((id) => { settings.activeProfileId = id; void this.saveAndRender(); }); });
- new Setting(advanced).setName("Profile actions").addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.saveAndRender(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(() => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; void this.saveAndRender(); })).addButton((button) => button.setButtonText("Delete").setWarning().setDisabled(settings.profiles.length < 2).onClick(() => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; void this.saveAndRender(); }));
- new Setting(advanced).setName("Profile JSON").addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, async (json) => {
- if (!json) return;
- if (new TextEncoder().encode(json).length > 262_144) { new Notice("Profile JSON is too large. Profiles must be smaller than 256 kb.", 8000); return; }
- let parsed: unknown;
- try { parsed = JSON.parse(json); } catch { new Notice("Profile JSON is invalid. Correct the JSON syntax and try again.", 8000); return; }
- const validation = validateProfile(parsed); if (!validation.profile) { new Notice(validation.errors.join(" "), 8000); return; }
- settings.profiles.push(validation.profile); settings.activeProfileId = validation.profile.id; await this.saveAndRender();
- }).open()));
+ new Setting(advanced).setName("Profile actions").addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.saveAndRender(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(() => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; void this.saveAndRender(); })).addButton((button) => destructive(button.setButtonText("Delete")).setDisabled(settings.profiles.length < 2).onClick(() => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; void this.saveAndRender(); }));
+ new Setting(advanced).setName("Profile JSON").addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, (json) => this.importProfileJson(json)).open()));
new Setting(advanced).setName("Export records").addButton((button) => button.setButtonText(`History (${settings.exportHistory.length})`).onClick(() => new ExportHistoryModal(this.app, this.plugin).open())).addButton((button) => button.setButtonText(`Logs (${settings.compileLogs.length})`).onClick(() => new CompileLogsModal(this.app, this.plugin).open()));
this.renderSupportPanel(container);
}
@@ -130,6 +147,9 @@ export class ManuscriptCompilerSettingTab extends PluginSettingTab {
private renderSupportPanel(parent: HTMLElement): void {
const panel = parent.createDiv({ cls: "manuscript-support-panel" });
new Setting(panel).setName(SUPPORT_SECTION_TITLE).setHeading();
+ this.renderSupportContent(panel);
+ }
+ private renderSupportContent(panel: HTMLElement): void {
const identity = panel.createDiv({ cls: "manuscript-support-identity" });
identity.createEl("img", { cls: "manuscript-support-logo", attr: { src: pluginLogo, alt: "", "aria-hidden": "true" } });
const identityText = identity.createDiv({ cls: "manuscript-support-identity-text" });
@@ -149,14 +169,14 @@ export class ManuscriptCompilerSettingTab extends PluginSettingTab {
button.buttonEl.createSpan({ cls: "manuscript-support-button-label", text: action.label });
button.buttonEl.setAttribute("aria-label", action.label);
button.onClick(() => {
- if ("notice" in action) { new Notice(action.notice); return; }
button.buttonEl.win.open(action.url, "_blank", "noopener,noreferrer");
});
}
}
private text(parent: HTMLElement, name: string, value: string, change: (value: string) => void): void { new Setting(parent).setName(name).addText((text) => text.setValue(value).onChange(async (next) => { change(next.trim()); await this.plugin.saveSettings(); })); }
private toggle(parent: HTMLElement, name: string, value: boolean, change: (value: boolean) => void): void { new Setting(parent).setName(name).addToggle((toggle) => toggle.setValue(value).onChange(async (next) => { change(next); await this.plugin.saveSettings(); })); }
- private async saveAndRender(): Promise { await this.plugin.saveSettings(); this.renderSettings(); }
+ private async importProfileJson(json?: string): Promise { if (!json) return; if (new TextEncoder().encode(json).length > 262_144) { new Notice("Profile JSON is too large. Profiles must be smaller than 256 kb.", 8000); return; } let parsed: unknown; try { parsed = JSON.parse(json); } catch { new Notice("Profile JSON is invalid. Correct the JSON syntax and try again.", 8000); return; } const validation = validateProfile(parsed); if (!validation.profile) { new Notice(validation.errors.join(" "), 8000); return; } this.plugin.settings.profiles.push(validation.profile); this.plugin.settings.activeProfileId = validation.profile.id; await this.saveAndRender(); }
+ private async saveAndRender(): Promise { await this.plugin.saveSettings(); const update = (this as unknown as Record)["update"]; if (typeof update === "function") update.call(this); else this.renderSettings(); }
}
function row(list: HTMLElement, label: string, value: string): void { list.createEl("dt", { text: label }); list.createEl("dd", { text: value }); }
@@ -166,3 +186,6 @@ function row(list: HTMLElement, label: string, value: string): void { list.creat
* errors that might contain private paths or metadata are never emitted.
*/
export function showError(error: unknown): void { const message = redactTechnicalMessage(error); new Notice(`Manuscript Compiler: ${message}`, 8000); console.error(`Manuscript Compiler: ${message}`); }
+
+/** Uses the current destructive API while retaining compatibility with Obsidian before 1.13. */
+function destructive(button: ButtonComponent): ButtonComponent { const method = (button as unknown as Record)["setDestructive"]; if (typeof method === "function") { method.call(button); return button; } button.buttonEl.addClass("mod-warning"); return button; }
diff --git a/tests/run.ts b/tests/run.ts
index 62a45c9..301bfbb 100644
--- a/tests/run.ts
+++ b/tests/run.ts
@@ -381,7 +381,7 @@ test("advanced controls remain available and settings lead with Defaults", async
const create = await readFile(path.join("src", "workspace", "create-docx-step.ts"), "utf8"); const settings = await readFile(path.join("src", "ui.ts"), "utf8");
for (const label of ["Book title override", "Author override", "Font", "Font size", "Line spacing", "First-line indent (cm)", "Page size", "Custom scene break", "Part heading style", "Chapter heading style", "Manuscript body headings", "Filename template"]) assert.match(create, new RegExp(label.replace(/[()]/g, "\\$&")));
for (const removed of ["Vault folder", "Warn before overwriting", "Open after export", "Reveal after export", "Save to vault"]) assert.doesNotMatch(create, new RegExp(removed));
- assert.match(settings, /setName\("Defaults"\)\.setHeading\(\)/); assert.match(settings, /setName\("Advanced profile"\)/); assert.match(settings, /Used for customised or older workflows/); assert.match(settings, /Advanced profiles, records, and compatibility/); assert.doesNotMatch(settings, /Active legacy profile|Saved defaults/);
+ assert.match(settings, /getSettingDefinitions\(\): SettingDefinitionItem\[\]/); assert.match(settings, /type: "group", heading: "Defaults"/); assert.match(settings, /type: "group", heading: "Advanced profiles, records, and compatibility"/); assert.match(settings, /setName\("Defaults"\)\.setHeading\(\)/); assert.match(settings, /setName\("Advanced profile"\)/); assert.match(settings, /Used for customised or older workflows/); assert.match(settings, /Advanced profiles, records, and compatibility/); assert.doesNotMatch(settings, /Active legacy profile|Saved defaults/);
});
test("settings end with an accessible responsive Support & Links panel", async () => {
@@ -389,8 +389,8 @@ test("settings end with an accessible responsive Support & Links panel", async (
const records = ui.indexOf('setName("Export records")'); const support = ui.indexOf("this.renderSupportPanel(container);"); assert.ok(records >= 0 && support > records, "support must render after every setting");
assert.match(ui, /container\.addClass\("manuscript-compiler-settings"\)/); assert.match(css, /\.manuscript-compiler-settings > \.setting-item:not\(\.setting-item-heading\)[\s\S]*padding-block: var\(--size-4-2\)/); assert.match(css, /\.manuscript-compiler-settings > details > \.setting-item/);
assert.match(ui, /const SUPPORT_SECTION_TITLE = "Support & Links"/); assert.match(ui, /import pluginLogo from "\.\.\/logo\.svg"/); assert.match(ui, /manuscript-support-logo/); assert.match(ui, /text: `Version \$\{this\.plugin\.manifest\.version\}`/); assert.equal((ui.match(/\{ label: /g) ?? []).length, 5);
- for (const value of ["Report a bug", "Feature request", "wolf359.app", "Wolf 359 Press", "Buy me a coffee", "https://wolf359.app", "https://wolf359.press", "https://buymeacoffee.com/wolf359pressab"]) assert.match(ui, new RegExp(value.replace(/[.]/g, "\\.")));
- assert.match(ui, /Bug reporting portal coming soon\./); assert.match(ui, /Feature request portal coming soon\./); assert.match(ui, /new ButtonComponent\(actions\)/); assert.match(ui, /setIcon\(action\.icon\)/); assert.match(ui, /buyMeACoffeeArtwork/); assert.match(ui, /manuscript-support-bmc-icon/); assert.match(ui, /manuscript-support-button-label/); assert.match(ui, /setAttribute\("aria-label", action\.label\)/); assert.match(ui, /button\.buttonEl\.win\.open\(action\.url, "_blank", "noopener,noreferrer"\)/);
+ for (const value of ["Report a bug", "Feature request", "wolf359.app", "Wolf 359 Press", "Buy me a coffee", "https://github.com/anthonyfitzpatrick/manuscript-compiler/issues/new?labels=bug", "https://wolf359.app", "https://wolf359.press", "https://buymeacoffee.com/wolf359pressab"]) assert.ok(ui.includes(value), value);
+ assert.match(ui, /new ButtonComponent\(actions\)/); assert.match(ui, /setIcon\(action\.icon\)/); assert.match(ui, /buyMeACoffeeArtwork/); assert.match(ui, /manuscript-support-bmc-icon/); assert.match(ui, /manuscript-support-button-label/); assert.match(ui, /setAttribute\("aria-label", action\.label\)/); assert.match(ui, /button\.buttonEl\.win\.open\(action\.url, "_blank", "noopener,noreferrer"\)/);
assert.match(css, /\.manuscript-support-panel \{[^}]*width: 100%;[^}]*padding: var\(--size-4-3\);[^}]*var\(--background-modifier-border\)[^}]*var\(--background-secondary\)/s); assert.match(css, /\.manuscript-support-identity \{[^}]*display: flex;[^}]*align-items: center;/s); assert.match(css, /\.manuscript-support-logo \{[^}]*width: 48px;[^}]*height: 48px;/s); assert.match(css, /\.manuscript-support-identity p \{[^}]*font-size: var\(--font-ui-smaller\)/s); assert.match(css, /\.manuscript-support-actions \{[^}]*display: flex;[^}]*flex-wrap: wrap;[^}]*justify-content: center;/s); assert.match(css, /\.manuscript-support-button \{[^}]*display: inline-flex;[^}]*flex: 0 1 auto;[^}]*min-height: 36px;[^}]*border: 2px solid var\(--interactive-accent\);[^}]*box-shadow: var\(--shadow-s\);[^}]*font-size: var\(--font-ui-smaller\)/s); assert.match(css, /\.manuscript-support-bmc-icon \{[^}]*overflow: hidden;/s); assert.match(css, /\.manuscript-support-button:focus-visible/); assert.doesNotMatch(css, /\.manuscript-support-button \{ flex-basis: 100%; \}/);
});
@@ -513,7 +513,7 @@ test("stale prepared source keeps the workspace openable for Refresh Preview", a
test("main delegates orchestration and ExportCoordinator is the sole exporter owner", async () => { const mainSource = await readFile(path.join("src", "main.ts"), "utf8"); const coordinatorSource = await readFile(path.join("src", "export-coordinator.ts"), "utf8"); const otherProduction = (await Promise.all(["compile-command-service.ts", "compile-modal.ts", "validation.ts"].map((file) => readFile(path.join("src", file), "utf8")))).join("\n"); assert.doesNotMatch(mainSource, /new (DocxMemoryExporter|OdtExporter|EpubExporter|HtmlExporter|MarkdownExporter|XmlExporter)|recordSuccess\(|recordFailure\(|calculateSourceFingerprint/); assert.match(mainSource, /new ExportCoordinator/); assert.match(coordinatorSource, /exportPreparedSession\(session: PreparedCompileSession/); assert.match(coordinatorSource, /EXPORTERS\[format\]/); assert.doesNotMatch(otherProduction, /EXPORTERS\[/); assert.doesNotMatch(coordinatorSource, /ScannedBook/); });
-test("0.9.2 production source has one preparation/export route and no external execution, network, telemetry, or plugin API access", async () => {
+test("0.9.3 production source has one preparation/export route and no external execution, network, telemetry, or plugin API access", async () => {
const files = await sourceFiles("src"); const entries = await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)); const source = entries.map(([, content]) => content).join("\n");
assert.deepEqual(entries.filter(([, content]) => /new VaultScanner\(/.test(content)).map(([file]) => file), [path.join("src", "compile-preparation.ts")]);
assert.deepEqual(entries.filter(([, content]) => /new ExportCoordinator\(/.test(content)).map(([file]) => file), [path.join("src", "main.ts")]);
@@ -526,9 +526,10 @@ test("production source remains mobile-safe, popout-safe, and lifecycle-register
const files = await sourceFiles("src"); const source = (await Promise.all(files.map((file) => readFile(file, "utf8")))).join("\n"); const main = await readFile(path.join("src", "main.ts"), "utf8");
assert.doesNotMatch(source, /from\s+["'](?:node:|fs["'/]|path["'/]|os["'/]|electron["'/]|child_process["'/])/);
assert.doesNotMatch(source, /\bglobalThis\b|\bprocess\.platform\b|document\.createElement|document\.body|\bwindow\.set(?:Timeout|Interval)\b/);
+ assert.doesNotMatch(source, /\(\?<[=!]/, "Production regexes must not use lookbehind on older iOS WebKit.");
assert.doesNotMatch(source, /\.innerHTML\b|\.style\.(?:display|color|background|width|height)|setAttribute\(["']style["']/);
assert.doesNotMatch(source, /["']\.obsidian(?:\/|["'])|\bas any\b|\bas TFile\b|\bas TFolder\b/);
- assert.match(main, /registerEvent\(this\.app\.workspace\.on\("file-menu"/); assert.match(main, /onunload\(\): void \{ this\.operations\.cancel\(\); \}/); assert.doesNotMatch(main, /hotkeys\s*:/);
+ assert.match(main, /registerEvent\(this\.app\.workspace\.on\("file-menu"/); assert.match(main, /onunload\(\): void \{ this\.active = false; this\.operations\.cancel\(\); \}/); assert.doesNotMatch(main, /hotkeys\s*:/);
});
test("plugin CSS selectors are owned, theme-safe, and retain accessibility modes", async () => {
const css = await readFile("styles.css", "utf8"); const selectorBlocks = [...css.matchAll(/(?:^|})(?!\s*@)([^{}]+)\{/g)].map((match) => match[1].trim()).filter((selector) => selector && !selector.startsWith("@"));
@@ -536,7 +537,7 @@ test("plugin CSS selectors are owned, theme-safe, and retain accessibility modes
assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b|!important/i); assert.match(css, /@media \(forced-colors: active\)/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); assert.match(css, /:focus-visible/);
});
-test("0.9.2 version metadata and release package allowlist are consistent", async () => { const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8")); const packaging = await readFile(path.join("scripts", "package.mjs"), "utf8"); assert.equal(manifest.version, "0.9.2"); assert.equal(packageJson.version, manifest.version); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[""].version, manifest.version); assert.equal(Object.values(versions).at(-1), manifest.minAppVersion); assert.match(packaging, /const required = \["main\.js", "manifest\.json", "styles\.css"\]/); });
+test("0.9.3 version metadata and release package allowlist are consistent", async () => { const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8")); const packaging = await readFile(path.join("scripts", "package.mjs"), "utf8"); assert.equal(manifest.version, "0.9.3"); assert.equal(packageJson.version, manifest.version); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[""].version, manifest.version); assert.equal(versions[manifest.version], manifest.minAppVersion); assert.equal(manifest.authorUrl, "https://wolf359.app"); assert.equal(manifest.fundingUrl, "https://buymeacoffee.com/wolf359pressab"); assert.match(packaging, /const required = \["main\.js", "manifest\.json", "styles\.css"\]/); });
test("repository hygiene ignores runtime state and rejects tracked plugin data", async () => {
for (const candidate of ["data.json", ".obsidian/plugins/manuscript-compiler/data.json", "nested/manuscript-compiler/data.json"]) {
await execFileAsync("git", ["check-ignore", "--no-index", "--quiet", candidate]);
diff --git a/versions.json b/versions.json
index 708016d..a7faf1b 100644
--- a/versions.json
+++ b/versions.json
@@ -1,3 +1,13 @@
{
- "0.1.0": "1.5.0"
+ "0.1.0": "1.5.0",
+ "0.2.0": "1.5.0",
+ "0.3.0": "1.5.0",
+ "0.4.0": "1.5.0",
+ "0.5.0": "1.5.0",
+ "0.6.0": "1.5.0",
+ "0.7.0": "1.5.0",
+ "0.8.0": "1.5.0",
+ "0.9.1": "1.5.0",
+ "0.9.2": "1.5.0",
+ "0.9.3": "1.5.0"
}