Refactor: reorganize codebase into modular src/ structure with strict TypeScript config.

This commit is contained in:
David V. Kimball 2025-09-10 21:24:42 -07:00
parent ef7269dce1
commit eabfb3dc2a
16 changed files with 1317 additions and 1159 deletions

View file

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",

1148
main.ts

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"id": "astro-composer",
"name": "Astro Composer",
"version": "0.5.3",
"version": "0.5.5",
"minAppVersion": "0.15.0",
"description": "Create and manage notes as Astro blog posts with automatic file renaming, frontmatter insertion, and internal link conversion.",
"author": "David V. Kimball",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "astro-composer",
"version": "0.5.3",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astro-composer",
"version": "0.5.3",
"version": "0.5.5",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View file

@ -1,6 +1,6 @@
{
"name": "astro-composer",
"version": "0.5.3",
"version": "0.5.5",
"description": "Create and manage notes as Astro blog posts with automatic file renaming, frontmatter insertion, and internal link conversion.",
"main": "main.js",
"scripts": {

92
src/commands/index.ts Normal file
View file

@ -0,0 +1,92 @@
import { Plugin, Editor, MarkdownView, TFile, Notice } from "obsidian";
import { AstroComposerSettings } from "../types";
import { FileOperations } from "../utils/file-operations";
import { TemplateParser } from "../utils/template-parsing";
import { LinkConverter } from "../utils/link-conversion";
import { TitleModal } from "../ui/title-modal";
export function registerCommands(plugin: Plugin, settings: AstroComposerSettings): void {
const fileOps = new FileOperations(plugin.app, settings);
const templateParser = new TemplateParser(plugin.app, settings);
const linkConverter = new LinkConverter(settings);
// Standardize Properties command
plugin.addCommand({
id: "standardize-properties",
name: "Standardize Properties",
icon: "file-check",
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
if (ctx.file instanceof TFile) {
standardizeProperties(plugin.app, settings, ctx.file);
}
},
});
// Convert Wikilinks command
plugin.addCommand({
id: "convert-wikilinks-astro",
name: "Convert internal links for Astro",
icon: "link-2",
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
if (ctx.file instanceof TFile) {
linkConverter.convertWikilinksForAstro(editor, ctx.file);
}
},
});
// Rename Note command
plugin.addCommand({
id: "rename-note",
name: "Rename Current Note",
icon: "pencil",
editorCallback: (editor: Editor, ctx: MarkdownView | any) => {
if (ctx.file instanceof TFile) {
const type = fileOps.determineType(ctx.file);
const titleKey = fileOps.getTitleKey(type);
const cache = plugin.app.metadataCache.getFileCache(ctx.file);
if (!cache?.frontmatter || !(titleKey in cache.frontmatter)) {
new Notice("Cannot rename: No title found in properties");
return;
}
new TitleModal(plugin.app, ctx.file, plugin, type, true).open();
}
},
});
}
async function standardizeProperties(app: any, settings: AstroComposerSettings, file: TFile): Promise<void> {
const templateParser = new TemplateParser(app, settings);
// Determine if it's a page or post
const filePath = file.path;
const pagesFolder = settings.pagesFolder || "";
const isPage = settings.enablePages && pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder);
const templateString = isPage ? settings.pageTemplate : settings.defaultTemplate;
// Wait briefly to allow editor state to stabilize
await new Promise(resolve => setTimeout(resolve, 100));
// Re-read content to ensure latest state after editor changes
const content = await app.vault.read(file);
const title = file.basename.replace(/^_/, "");
const parsed = await templateParser.parseFrontmatter(content);
const { templateProps, templateValues } = templateParser.parseTemplate(templateString, title);
// Merge template properties with existing ones, preserving all existing
const finalProps: Record<string, string[]> = { ...parsed.properties };
for (const key of templateProps) {
if (!(key in parsed.properties)) {
finalProps[key] = templateValues[key] || (['tags', 'aliases', 'cssclasses'].includes(key) ? [] : [""]);
} else if (['tags', 'aliases', 'cssclasses'].includes(key) && templateValues[key]?.length > 0) {
// Merge items, ensuring no duplicates
const allItems = [...(parsed.properties[key] || []), ...templateValues[key].filter(item => !(parsed.properties[key] || []).includes(item))];
finalProps[key] = allItems;
}
}
const newContent = templateParser.buildFrontmatterContent(finalProps) + parsed.bodyContent;
await app.vault.modify(file, newContent);
new Notice("Properties standardized using template.");
}

122
src/main.ts Normal file
View file

@ -0,0 +1,122 @@
import {
App,
Plugin,
TFile,
} from "obsidian";
import { AstroComposerSettings, DEFAULT_SETTINGS, CONSTANTS } from "./settings";
import { registerCommands } from "./commands";
import { AstroComposerSettingTab } from "./ui/settings-tab";
import { TitleModal } from "./ui/title-modal";
import { FileOperations } from "./utils/file-operations";
import { TemplateParser } from "./utils/template-parsing";
export default class AstroComposerPlugin extends Plugin {
settings!: AstroComposerSettings;
private createEvent!: (file: TFile) => void;
private fileOps!: FileOperations;
private templateParser!: TemplateParser;
async onload() {
await this.loadSettings();
// Initialize utilities
this.fileOps = new FileOperations(this.app, this.settings);
this.templateParser = new TemplateParser(this.app, this.settings);
// Wait for the vault to be fully loaded before registering the create event
this.app.workspace.onLayoutReady(() => {
this.registerCreateEvent();
});
// Register commands
registerCommands(this, this.settings);
// Add settings tab
this.addSettingTab(new AstroComposerSettingTab(this.app, this));
}
public registerCreateEvent() {
if (this.createEvent) {
this.app.vault.off("create", this.createEvent as any);
}
if (this.settings.automatePostCreation || this.settings.enablePages) {
// Debounce to prevent multiple modals from rapid file creations
let lastProcessedTime = 0;
this.createEvent = async (file: TFile) => {
const now = Date.now();
if (now - lastProcessedTime < CONSTANTS.DEBOUNCE_MS) {
return; // Skip if within debounce period
}
lastProcessedTime = now;
if (file instanceof TFile && file.extension === "md") {
const filePath = file.path;
// Check if file is newly created by user (recent creation time and empty content)
const stat = await this.app.vault.adapter.stat(file.path);
const isNewNote = stat?.mtime && (now - stat.mtime < CONSTANTS.STAT_MTIME_THRESHOLD);
const content = await this.app.vault.read(file);
const isEmpty = content.trim() === "";
if (!isNewNote || !isEmpty) {
return; // Skip if not a user-initiated new note
}
// Check folder restrictions
const postsFolder = this.settings.postsFolder || "";
const pagesFolder = this.settings.enablePages ? (this.settings.pagesFolder || "") : "";
let isPage = false;
if (pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder)) {
isPage = true;
}
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.sections || cache.sections.length === 0) {
if (isPage) {
if (this.settings.enablePages) {
new TitleModal(this.app, file, this, "page").open();
}
} else {
if (this.settings.onlyAutomateInPostsFolder) {
if (
!postsFolder ||
(filePath.startsWith(postsFolder + "/") || filePath === postsFolder)
) {
new TitleModal(this.app, file, this, "post").open();
}
} else {
let excludedDirs = this.settings.excludedDirectories
.split("|")
.map((dir: string) => dir.trim())
.filter((dir: string) => dir.length > 0);
if (pagesFolder) {
excludedDirs.push(pagesFolder);
}
const isExcluded = excludedDirs.some((dir: string) =>
filePath.startsWith(dir + "/") || filePath === dir
);
if (!isExcluded) {
new TitleModal(this.app, file, this, "post").open();
}
}
}
}
}
};
this.registerEvent(this.app.vault.on("create", this.createEvent as any));
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}

23
src/settings.ts Normal file
View file

@ -0,0 +1,23 @@
import { AstroComposerSettings, CONSTANTS } from "./types";
export type { AstroComposerSettings } from "./types";
export { CONSTANTS } from "./types";
export const DEFAULT_SETTINGS: AstroComposerSettings = {
enableUnderscorePrefix: false,
defaultTemplate:
'---\ntitle: "{{title}}"\ndate: {{date}}\ntags: []\n---\n',
linkBasePath: "/blog/",
postsFolder: "posts",
automatePostCreation: true,
autoInsertProperties: true,
creationMode: "file",
indexFileName: "index",
dateFormat: "YYYY-MM-DD",
excludedDirectories: "",
onlyAutomateInPostsFolder: false,
enablePages: false,
pagesFolder: "pages",
pageTemplate:
'---\ntitle: "{{title}}"\ndescription: ""\n---\n',
};

52
src/types.ts Normal file
View file

@ -0,0 +1,52 @@
import { TFile } from "obsidian";
export interface AstroComposerSettings {
enableUnderscorePrefix: boolean;
defaultTemplate: string;
linkBasePath: string;
postsFolder: string;
automatePostCreation: boolean;
autoInsertProperties: boolean;
creationMode: "file" | "folder";
indexFileName: string;
dateFormat: string;
excludedDirectories: string;
onlyAutomateInPostsFolder: boolean;
enablePages: boolean;
pagesFolder: string;
pageTemplate: string;
}
export interface ParsedFrontmatter {
properties: Record<string, string[]>;
propertiesText: string;
propertiesEnd: number;
bodyContent: string;
}
export interface TemplateValues {
[key: string]: string[];
}
export type PostType = "post" | "page";
export interface FileCreationOptions {
file: TFile;
title: string;
type: PostType;
}
export interface RenameOptions {
file: TFile;
title: string;
type: PostType;
}
export const KNOWN_ARRAY_KEYS = ['tags', 'aliases', 'cssclasses'] as const;
export const CONSTANTS = {
DEBOUNCE_MS: 500,
STAT_MTIME_THRESHOLD: 1000,
EDITOR_STABILIZE_DELAY: 100,
FILE_EXPLORER_REVEAL_DELAY: 200,
} as const;

288
src/ui/settings-tab.ts Normal file
View file

@ -0,0 +1,288 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import { Plugin } from "obsidian";
import { AstroComposerSettings } from "../types";
export class AstroComposerSettingTab extends PluginSettingTab {
plugin: Plugin;
autoRenameContainer: HTMLElement | null = null;
postsFolderContainer: HTMLElement | null = null;
onlyAutomateContainer: HTMLElement | null = null;
creationModeContainer: HTMLElement | null = null;
indexFileContainer: HTMLElement | null = null;
excludedDirsContainer: HTMLElement | null = null;
underscorePrefixContainer: HTMLElement | null = null;
autoInsertContainer: HTMLElement | null = null;
pagesFieldsContainer: HTMLElement | null = null;
constructor(app: App, plugin: Plugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const settings = (this.plugin as any).settings as AstroComposerSettings;
new Setting(containerEl)
.setName("Automate post creation")
.setDesc("Automatically show title dialog for new .md files, rename them based on the title, and insert properties if enabled.")
.addToggle((toggle) =>
toggle
.setValue(settings.automatePostCreation)
.onChange(async (value: boolean) => {
settings.automatePostCreation = value;
settings.autoInsertProperties = value;
await (this.plugin as any).saveSettings();
(this.plugin as any).registerCreateEvent();
this.updateConditionalFields();
})
);
this.autoRenameContainer = containerEl.createDiv({ cls: "auto-rename-fields" });
this.autoRenameContainer.style.display = settings.automatePostCreation ? "block" : "none";
this.autoInsertContainer = this.autoRenameContainer.createDiv();
new Setting(this.autoInsertContainer)
.setName("Auto-insert properties")
.setDesc("Automatically insert the properties template when creating new files (requires 'Automate post creation' to be enabled).")
.addToggle((toggle) =>
toggle
.setValue(settings.autoInsertProperties)
.setDisabled(!settings.automatePostCreation)
.onChange(async (value: boolean) => {
settings.autoInsertProperties = value;
await (this.plugin as any).saveSettings();
})
);
this.postsFolderContainer = this.autoRenameContainer.createDiv();
new Setting(this.postsFolderContainer)
.setName("Posts folder")
.setDesc("Folder name for blog posts (leave blank to use the vault folder). You can specify the default location for new notes in Obsidian's 'Files and links' settings.")
.addText((text) =>
text
.setPlaceholder("Enter folder path")
.setValue(settings.postsFolder)
.onChange(async (value: string) => {
settings.postsFolder = value;
await (this.plugin as any).saveSettings();
})
);
this.onlyAutomateContainer = this.autoRenameContainer.createDiv();
new Setting(this.onlyAutomateContainer)
.setName("Only automate in this folder")
.setDesc("When enabled, automation will only trigger for new .md files within the specified Posts folder and subfolders.")
.addToggle((toggle) =>
toggle
.setValue(settings.onlyAutomateInPostsFolder)
.onChange(async (value: boolean) => {
settings.onlyAutomateInPostsFolder = value;
await (this.plugin as any).saveSettings();
this.updateExcludedDirsField();
})
);
this.excludedDirsContainer = this.autoRenameContainer.createDiv({ cls: "excluded-dirs-field" });
this.excludedDirsContainer.style.display = !settings.onlyAutomateInPostsFolder ? "block" : "none";
new Setting(this.excludedDirsContainer)
.setName("Excluded directories")
.setDesc("Directories to exclude from automatic post creation (e.g., pages|posts/example). Excluded directories and their child folders will be ignored. Use '|' to separate multiple directories.")
.addText((text) =>
text
.setPlaceholder("pages|posts/example")
.setValue(settings.excludedDirectories)
.onChange(async (value: string) => {
settings.excludedDirectories = value;
await (this.plugin as any).saveSettings();
})
);
this.creationModeContainer = this.autoRenameContainer.createDiv();
new Setting(this.creationModeContainer)
.setName("Creation mode")
.setDesc("How to create new posts: file-based or folder-based with index.md.")
.addDropdown((dropdown) =>
dropdown
.addOption("file", "File-based (post-title.md)")
.addOption("folder", "Folder-based (post-title/index.md)")
.setValue(settings.creationMode)
.onChange(async (value: string) => {
settings.creationMode = value as "file" | "folder";
await (this.plugin as any).saveSettings();
this.updateIndexFileField();
})
);
this.indexFileContainer = this.autoRenameContainer.createDiv({ cls: "index-file-field" });
this.indexFileContainer.style.display = settings.creationMode === "folder" ? "block" : "none";
new Setting(this.indexFileContainer)
.setName("Index file name")
.setDesc("Name for the main file in folder-based mode (without .md extension).")
.addText((text) =>
text
.setPlaceholder("index")
.setValue(settings.indexFileName)
.onChange(async (value: string) => {
settings.indexFileName = value || "index";
await (this.plugin as any).saveSettings();
})
);
this.underscorePrefixContainer = this.autoRenameContainer.createDiv();
new Setting(this.underscorePrefixContainer)
.setName("Use underscore prefix for drafts")
.setDesc("Add an underscore prefix (_post-title) to new notes by default when enabled. This hides them from Astro, which can be helpful for post drafts. Disable to skip prefixing.")
.addToggle((toggle) =>
toggle
.setValue(settings.enableUnderscorePrefix)
.onChange(async (value: boolean) => {
settings.enableUnderscorePrefix = value;
await (this.plugin as any).saveSettings();
})
);
new Setting(containerEl)
.setName("Link base path")
.setDesc("Base path for converted links (e.g., /blog/, leave blank for root domain).")
.addText((text) =>
text
.setPlaceholder("/blog/")
.setValue(settings.linkBasePath)
.onChange(async (value: string) => {
settings.linkBasePath = value;
await (this.plugin as any).saveSettings();
})
);
new Setting(containerEl)
.setName("Date format")
.setDesc("Format for the date in properties (e.g., YYYY-MM-DD, MMMM D, YYYY, YYYY-MM-DD HH:mm).")
.addText((text) =>
text
.setPlaceholder("YYYY-MM-DD")
.setValue(settings.dateFormat)
.onChange(async (value: string) => {
settings.dateFormat = value || "YYYY-MM-DD";
await (this.plugin as any).saveSettings();
})
);
new Setting(containerEl)
.setName("Properties template")
.addTextArea((text) => {
text
.setPlaceholder(
'---\ntitle: "{{title}}"\ndate: {{date}}\ntags: []\n---\n',
)
.setValue(settings.defaultTemplate)
.onChange(async (value: string) => {
settings.defaultTemplate = value;
await (this.plugin as any).saveSettings();
});
text.inputEl.classList.add("astro-composer-template-textarea");
return text;
})
.then((setting) => {
setting.descEl.empty();
const descDiv = setting.descEl.createEl("div");
descDiv.innerHTML =
"Used for new posts and when standardizing properties.<br />" +
"Variables include {{title}} and {{date}}.<br />" +
"Do not wrap {{date}} in quotes as it represents a datetime value, not a string.<br />" +
"The 'standardize properties' command ignores anything below the second '---' line.";
});
new Setting(containerEl)
.setName("Automate page creation")
.setDesc("Enable automatic page creation in a specified folder.")
.addToggle((toggle) =>
toggle
.setValue(settings.enablePages)
.onChange(async (value: boolean) => {
settings.enablePages = value;
await (this.plugin as any).saveSettings();
(this.plugin as any).registerCreateEvent();
this.updatePagesFields();
})
);
this.pagesFieldsContainer = containerEl.createDiv();
this.pagesFieldsContainer.style.display = settings.enablePages ? "block" : "none";
new Setting(this.pagesFieldsContainer)
.setName("Pages folder")
.setDesc("Folder for pages (leave blank to disable). Posts automation will exclude this folder.")
.addText((text) =>
text
.setPlaceholder("Enter folder path")
.setValue(settings.pagesFolder)
.onChange(async (value: string) => {
settings.pagesFolder = value;
await (this.plugin as any).saveSettings();
})
);
new Setting(this.pagesFieldsContainer)
.setName("Page properties template")
.addTextArea((text) => {
text
.setPlaceholder(
'---\ntitle: "{{title}}"\ndescription: ""\n---\n',
)
.setValue(settings.pageTemplate)
.onChange(async (value: string) => {
settings.pageTemplate = value;
await (this.plugin as any).saveSettings();
});
text.inputEl.classList.add("astro-composer-template-textarea");
return text;
})
.then((setting) => {
setting.descEl.empty();
const descDiv = setting.descEl.createEl("div");
descDiv.innerHTML =
"Used for new pages and when standardizing properties.<br />" +
"Variables include {{title}} and {{date}}.<br />" +
"Do not wrap {{date}} in quotes as it represents a datetime value, not a string.<br />" +
"The 'standardize properties' command ignores anything below the second '---' line.";
});
this.updateConditionalFields();
this.updateIndexFileField();
this.updateExcludedDirsField();
this.updatePagesFields();
}
updateConditionalFields() {
if (this.autoRenameContainer) {
const settings = (this.plugin as any).settings as AstroComposerSettings;
this.autoRenameContainer.style.display = settings.automatePostCreation ? "block" : "none";
}
}
updateIndexFileField() {
if (this.indexFileContainer) {
const settings = (this.plugin as any).settings as AstroComposerSettings;
this.indexFileContainer.style.display = settings.creationMode === "folder" ? "block" : "none";
}
}
updateExcludedDirsField() {
if (this.excludedDirsContainer) {
const settings = (this.plugin as any).settings as AstroComposerSettings;
this.excludedDirsContainer.style.display = !settings.onlyAutomateInPostsFolder ? "block" : "none";
}
}
updatePagesFields() {
if (this.pagesFieldsContainer) {
const settings = (this.plugin as any).settings as AstroComposerSettings;
this.pagesFieldsContainer.style.display = settings.enablePages ? "block" : "none";
}
}
}

130
src/ui/title-modal.ts Normal file
View file

@ -0,0 +1,130 @@
import { App, Modal, TFile, Notice } from "obsidian";
import { Plugin } from "obsidian";
import { PostType } from "../types";
import { FileOperations } from "../utils/file-operations";
import { TemplateParser } from "../utils/template-parsing";
export class TitleModal extends Modal {
file: TFile;
plugin: Plugin;
type: PostType;
isRename: boolean;
titleInput!: HTMLInputElement;
private fileOps: FileOperations;
private templateParser: TemplateParser;
constructor(app: App, file: TFile, plugin: Plugin, type: PostType = "post", isRename: boolean = false) {
super(app);
this.file = file;
this.plugin = plugin;
this.type = type;
this.isRename = isRename;
// Initialize utilities with current settings
const settings = (plugin as any).settings;
this.fileOps = new FileOperations(app, settings);
this.templateParser = new TemplateParser(app, settings);
}
getCurrentTitle(): string {
const titleKey = this.fileOps.getTitleKey(this.type);
const cache = this.app.metadataCache.getFileCache(this.file);
let basename = this.file.basename;
if (this.file.parent && basename === (this.plugin as any).settings.indexFileName) {
basename = this.file.parent.name;
}
if (basename.startsWith("_")) {
basename = basename.slice(1);
}
const fallbackTitle = basename.replace(/-/g, " ").split(" ").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
if (cache?.frontmatter && cache.frontmatter[titleKey]) {
return cache.frontmatter[titleKey];
}
return fallbackTitle;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
if (this.isRename) {
contentEl.createEl("h2", { text: "Rename Note" });
contentEl.createEl("p", { text: "Enter new title for your note:" });
this.titleInput = contentEl.createEl("input", {
type: "text",
placeholder: "My Renamed Note",
cls: "astro-composer-title-input"
});
this.titleInput.value = this.getCurrentTitle();
} else {
contentEl.createEl("h2", { text: this.type === "post" ? "New Blog Post" : "New Page" });
contentEl.createEl("p", { text: `Enter a title for your ${this.type}:` });
this.titleInput = contentEl.createEl("input", {
type: "text",
placeholder: this.type === "post" ? "My Awesome Blog Post" : "My Awesome Page",
cls: "astro-composer-title-input"
});
}
this.titleInput.focus();
const buttonContainer = contentEl.createDiv({ cls: "astro-composer-button-container" });
const cancelButton = buttonContainer.createEl("button", { text: "Cancel", cls: "astro-composer-cancel-button" });
cancelButton.onclick = () => this.close();
const submitButton = buttonContainer.createEl("button", { text: this.isRename ? "Rename" : "Create", cls: ["astro-composer-create-button", "mod-cta"] });
submitButton.onclick = () => this.submit();
this.titleInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") this.submit();
});
}
async submit() {
const title = this.titleInput.value.trim();
if (!title) {
new Notice("Please enter a title.");
return;
}
try {
let newFile: TFile | null = null;
if (this.isRename) {
newFile = await this.fileOps.renameFile({ file: this.file, title, type: this.type });
if (newFile) {
await this.templateParser.updateTitleInFrontmatter(newFile, title, this.type);
}
} else {
newFile = await this.fileOps.createFile({ file: this.file, title, type: this.type });
if (newFile && (this.plugin as any).settings.autoInsertProperties) {
await this.addPropertiesToFile(newFile, title, this.type);
}
}
if (!newFile) {
throw new Error("Failed to process the note.");
}
} catch (error) {
new Notice(`Error ${this.isRename ? "renaming" : "creating"} ${this.type}: ${(error as Error).message}.`);
}
this.close();
}
private async addPropertiesToFile(file: TFile, title: string, type: PostType = "post") {
const now = new Date();
const dateString = window.moment(now).format((this.plugin as any).settings.dateFormat);
let template = type === "post" ? (this.plugin as any).settings.defaultTemplate : (this.plugin as any).settings.pageTemplate;
template = template.replace(/\{\{title\}\}/g, title);
template = template.replace(/\{\{date\}\}/g, dateString);
// Ensure no extra newlines or --- are added beyond the template
await this.app.vault.modify(file, template);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,246 @@
import { App, TFile, TFolder, Notice } from "obsidian";
import { AstroComposerSettings, PostType, FileCreationOptions, RenameOptions } from "../types";
export class FileOperations {
constructor(private app: App, private settings: AstroComposerSettings) {}
toKebabCase(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
determineType(file: TFile): PostType {
const filePath = file.path;
const pagesFolder = this.settings.pagesFolder || "";
const isPage = this.settings.enablePages && pagesFolder && (filePath.startsWith(pagesFolder + "/") || filePath === pagesFolder);
return isPage ? "page" : "post";
}
getTitleKey(type: PostType): string {
const template = type === "post" ? this.settings.defaultTemplate : this.settings.pageTemplate;
const lines = template.split("\n");
let inProperties = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "---") {
inProperties = !inProperties;
continue;
}
if (inProperties) {
const match = trimmed.match(/^(\w+):\s*(.+)$/);
if (match) {
const key = match[1];
const value = match[2];
if (value.includes("{{title}}")) {
return key;
}
}
}
}
return "title";
}
async createFile(options: FileCreationOptions): Promise<TFile | null> {
const { file, title, type } = options;
if (!title) {
new Notice(`Title is required to create a ${type}.`);
return null;
}
const kebabTitle = this.toKebabCase(title);
const prefix = this.settings.enableUnderscorePrefix ? "_" : "";
let targetFolder = type === "post" ? this.settings.postsFolder || "" : this.settings.pagesFolder || "";
if (targetFolder) {
const folder = this.app.vault.getAbstractFileByPath(targetFolder);
if (!(folder instanceof TFolder)) {
await this.app.vault.createFolder(targetFolder);
}
}
if (this.settings.creationMode === "folder") {
return this.createFolderStructure(file, kebabTitle, prefix, targetFolder, type);
} else {
return this.createFileStructure(file, kebabTitle, prefix, targetFolder);
}
}
private async createFolderStructure(file: TFile, kebabTitle: string, prefix: string, targetFolder: string, type: PostType): Promise<TFile | null> {
const folderName = `${prefix}${kebabTitle}`;
const folderPath = targetFolder ? `${targetFolder}/${folderName}` : folderName;
try {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) {
await this.app.vault.createFolder(folderPath);
}
} catch (error) {
// Folder might already exist, proceed
}
const fileName = `${this.settings.indexFileName}.md`;
const newPath = `${folderPath}/${fileName}`;
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
if (existingFile instanceof TFile) {
new Notice(`File already exists at ${newPath}.`);
return null;
}
try {
await this.app.vault.rename(file, newPath);
const newFile = this.app.vault.getAbstractFileByPath(newPath);
if (!(newFile instanceof TFile)) {
return null;
}
setTimeout(() => {
const fileExplorer = this.app.workspace.getLeavesOfType("file-explorer")[0];
if (fileExplorer && fileExplorer.view) {
const fileTree = (fileExplorer.view as any).tree;
if (fileTree && newFile instanceof TFile) {
fileTree.revealFile(newFile);
}
}
}, 200);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(newFile);
return newFile;
} catch (error) {
new Notice(`Failed to create folder structure: ${(error as Error).message}.`);
return null;
}
}
private async createFileStructure(file: TFile, kebabTitle: string, prefix: string, targetFolder: string): Promise<TFile | null> {
const newName = `${prefix}${kebabTitle}.md`;
const newPath = targetFolder ? `${targetFolder}/${newName}` : newName;
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
if (existingFile instanceof TFile && existingFile !== file) {
new Notice(`File with name "${newName}" already exists.`);
return null;
}
try {
await this.app.vault.rename(file, newPath);
const newFile = this.app.vault.getAbstractFileByPath(newPath);
if (!(newFile instanceof TFile)) {
return null;
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(newFile);
return newFile;
} catch (error) {
new Notice(`Failed to rename file: ${(error as Error).message}.`);
return null;
}
}
async renameFile(options: RenameOptions): Promise<TFile | null> {
const { file, title, type } = options;
if (!title) {
new Notice(`Title is required to rename the note.`);
return null;
}
const kebabTitle = this.toKebabCase(title);
let prefix = "";
if (this.settings.creationMode === "folder") {
return this.renameFolderStructure(file, kebabTitle, prefix, type);
} else {
return this.renameFileStructure(file, kebabTitle, prefix);
}
}
private async renameFolderStructure(file: TFile, kebabTitle: string, prefix: string, type: PostType): Promise<TFile | null> {
const isIndex = file.basename === this.settings.indexFileName;
if (isIndex) {
if (!file.parent) {
new Notice("Cannot rename: File has no parent folder.");
return null;
}
prefix = file.parent.name.startsWith("_") ? "_" : "";
const newFolderName = `${prefix}${kebabTitle}`;
const parentFolder = file.parent.parent;
if (!parentFolder) {
new Notice("Cannot rename: Parent folder has no parent.");
return null;
}
const newFolderPath = `${parentFolder.path}/${newFolderName}`;
const existingFolder = this.app.vault.getAbstractFileByPath(newFolderPath);
if (existingFolder instanceof TFolder) {
new Notice(`Folder already exists at ${newFolderPath}.`);
return null;
}
await this.app.vault.rename(file.parent, newFolderPath);
const newFilePath = `${newFolderPath}/${file.name}`;
const newFile = this.app.vault.getAbstractFileByPath(newFilePath);
if (!(newFile instanceof TFile)) {
new Notice("Failed to locate renamed file.");
return null;
}
return newFile;
} else {
if (!file.parent) {
new Notice("Cannot rename: File has no parent folder.");
return null;
}
prefix = file.basename.startsWith("_") ? "_" : "";
const newName = `${prefix}${kebabTitle}.md`;
const newPath = `${file.parent.path}/${newName}`;
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
if (existingFile instanceof TFile && existingFile !== file) {
new Notice(`File already exists at ${newPath}.`);
return null;
}
await this.app.vault.rename(file, newPath);
const newFile = this.app.vault.getAbstractFileByPath(newPath);
if (!(newFile instanceof TFile)) {
new Notice("Failed to locate renamed file.");
return null;
}
return newFile;
}
}
private async renameFileStructure(file: TFile, kebabTitle: string, prefix: string): Promise<TFile | null> {
if (!file.parent) {
new Notice("Cannot rename: File has no parent folder.");
return null;
}
prefix = file.basename.startsWith("_") ? "_" : "";
const newName = `${prefix}${kebabTitle}.md`;
const newPath = `${file.parent.path}/${newName}`;
const existingFile = this.app.vault.getAbstractFileByPath(newPath);
if (existingFile instanceof TFile && existingFile !== file) {
new Notice(`File already exists at ${newPath}.`);
return null;
}
await this.app.vault.rename(file, newPath);
const newFile = this.app.vault.getAbstractFileByPath(newPath);
if (!(newFile instanceof TFile)) {
new Notice("Failed to locate renamed file.");
return null;
}
return newFile;
}
}

View file

@ -0,0 +1,122 @@
import { Editor, TFile, Notice } from "obsidian";
import { AstroComposerSettings, PostType } from "../types";
export class LinkConverter {
constructor(private settings: AstroComposerSettings) {}
toKebabCase(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
getAstroUrlFromInternalLink(link: string): string {
const hashIndex = link.indexOf('#');
let path = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
let anchor = hashIndex >= 0 ? link.slice(hashIndex) : '';
path = path.replace(/\.md$/, "");
// Strip root folder if present
if (path.startsWith(this.settings.postsFolder + '/')) {
path = path.slice(this.settings.postsFolder.length + 1);
} else if (this.settings.enablePages && path.startsWith(this.settings.pagesFolder + '/')) {
path = path.slice(this.settings.pagesFolder.length + 1);
}
let addTrailingSlash = false;
if (this.settings.creationMode === "folder") {
const parts = path.split('/');
if (parts[parts.length - 1] === this.settings.indexFileName) {
parts.pop();
path = parts.join('/');
addTrailingSlash = true;
}
}
const slugParts = path.split('/').map(part => this.toKebabCase(part));
const slug = slugParts.join('/');
let basePath = this.settings.linkBasePath;
if (!basePath.startsWith("/")) basePath = "/" + basePath;
if (!basePath.endsWith("/")) basePath += "/";
return `${basePath}${slug}${addTrailingSlash ? '/' : ''}${anchor}`;
}
async convertWikilinksForAstro(editor: Editor, file: TFile | null): Promise<void> {
if (!(file instanceof TFile)) {
new Notice("No active file.");
return;
}
const content = editor.getValue();
let newContent = content;
// Define common image extensions
const imageExtensions = /\.(png|jpg|jpeg|gif|svg)$/i;
// Handle regular Wikilinks (non-image)
newContent = newContent.replace(
/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g,
(match, linkText, _pipe, displayText) => {
// Check if it's an image Wikilink
if (imageExtensions.test(linkText)) {
return match; // Ignore and return original image Wikilink
}
const display = displayText || linkText.replace(/\.md$/, "");
const url = this.getAstroUrlFromInternalLink(linkText);
return `[${display}](${url})`;
}
);
// Handle standard Markdown links (non-image, non-external)
newContent = newContent.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(match, displayText, link) => {
// Check if it's an image link or external link
if (link.match(/^https?:\/\//) || imageExtensions.test(link)) {
return match; // Ignore external or image links
}
// Check if it's internal .md link
if (!link.includes('.md')) {
return match; // Ignore if not .md
}
const url = this.getAstroUrlFromInternalLink(link);
return `[${displayText}](${url})`;
}
);
// Handle image links in Markdown format (e.g., ![Image](mountains.png))
newContent = newContent.replace(
/!\[(.*?)\]\(([^)]+)\)/g,
(match) => {
return match; // Ignore all image links
}
);
// Handle {{embed}} syntax
newContent = newContent.replace(/\{\{([^}]+)\}\}/g, (match, fileName) => {
if (imageExtensions.test(fileName)) {
return match; // Ignore embedded images
}
const url = this.getAstroUrlFromInternalLink(fileName);
return `[Embedded: ${fileName}](${url})`;
});
editor.setValue(newContent);
new Notice("All internal links converted for Astro.");
}
}

View file

@ -0,0 +1,230 @@
import { App, TFile, Notice } from "obsidian";
import { AstroComposerSettings, PostType, ParsedFrontmatter, TemplateValues, KNOWN_ARRAY_KEYS } from "../types";
export class TemplateParser {
constructor(private app: App, private settings: AstroComposerSettings) {}
async parseFrontmatter(content: string): Promise<ParsedFrontmatter> {
let propertiesEnd = 0;
let propertiesText = "";
const existingProperties: Record<string, string[]> = {};
// Parse existing properties with fallback for missing second ---
if (content.startsWith("---")) {
propertiesEnd = content.indexOf("\n---", 3);
if (propertiesEnd === -1) {
propertiesEnd = content.length; // Treat entire content as frontmatter if no second ---
} else {
propertiesEnd += 4; // Move past the second ---
}
propertiesText = content.slice(4, propertiesEnd - 4).trim();
try {
let currentKey: string | null = null;
propertiesText.split("\n").forEach((line) => {
const match = line.match(/^(\w+):\s*(.+)?$/);
if (match) {
const [, key, value] = match;
currentKey = key;
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
existingProperties[key] = [];
} else {
existingProperties[key] = [value ? value.trim() : ""];
}
} else if (currentKey && KNOWN_ARRAY_KEYS.includes(currentKey as any) && line.trim().startsWith("- ")) {
const item = line.trim().replace(/^-\s*/, "");
if (item) existingProperties[currentKey].push(item);
} else if (line.trim() && !line.trim().startsWith("- ")) {
// Handle unrecognized properties
const keyMatch = line.match(/^(\w+):/);
if (keyMatch) {
const key = keyMatch[1];
const value = line.slice(line.indexOf(":") + 1).trim();
if (!existingProperties[key]) {
existingProperties[key] = [value || ""];
}
}
}
});
// Preserve array keys if they exist without values
KNOWN_ARRAY_KEYS.forEach(key => {
if (propertiesText.includes(key + ':') && !existingProperties[key]) {
existingProperties[key] = [];
}
});
} catch (error) {
// Fallback to template if parsing fails
new Notice("Falling back to template due to parsing error.");
}
}
const bodyContent = content.slice(propertiesEnd);
return {
properties: existingProperties,
propertiesText,
propertiesEnd,
bodyContent
};
}
parseTemplate(templateString: string, title: string): { templateProps: string[]; templateValues: TemplateValues } {
const templateLines = templateString.split("\n");
const templateProps: string[] = [];
const templateValues: TemplateValues = {};
let inProperties = false;
for (let i = 0; i < templateLines.length; i++) {
const line = templateLines[i].trim();
if (line === "---") {
inProperties = !inProperties;
if (!inProperties) {
break; // Stop at second --- to exclude post-property content
}
continue;
}
if (inProperties) {
const match = line.match(/^(\w+):\s*(.+)?$/);
if (match) {
const [, key, value] = match;
templateProps.push(key);
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
// Handle template array keys
if (value && value.startsWith("[")) {
const items = value
.replace(/[\[\]]/g, "")
.split(",")
.map(t => t.trim())
.filter(t => t);
templateValues[key] = items;
} else {
templateValues[key] = [];
// Look ahead for item list
for (let j = i + 1; j < templateLines.length; j++) {
const nextLine = templateLines[j].trim();
if (nextLine.startsWith("- ")) {
const item = nextLine.replace(/^-\s*/, "").trim();
if (item) templateValues[key].push(item);
} else if (nextLine === "---") {
break;
}
}
}
} else {
templateValues[key] = [ (value || "").replace(/\{\{title\}\}/g, title).replace(/\{\{date\}\}/g, window.moment(new Date()).format(this.settings.dateFormat)) ];
}
}
}
}
return { templateProps, templateValues };
}
buildFrontmatterContent(finalProps: Record<string, string[]>): string {
let newContent = "---\n";
for (const key in finalProps) {
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
newContent += `${key}:\n`;
if (finalProps[key].length > 0) {
finalProps[key].forEach(item => {
newContent += ` - ${item}\n`;
});
}
} else {
newContent += `${key}: ${finalProps[key][0] || ""}\n`;
}
}
newContent += "---";
return newContent;
}
async updateTitleInFrontmatter(file: TFile, newTitle: string, type: PostType): Promise<void> {
const titleKey = this.getTitleKey(type);
const content = await this.app.vault.read(file);
let propertiesEnd = 0;
let propertiesText = "";
if (content.startsWith("---")) {
propertiesEnd = content.indexOf("\n---", 3);
if (propertiesEnd === -1) {
propertiesEnd = content.length;
} else {
propertiesEnd += 4;
}
propertiesText = content.slice(4, propertiesEnd - 4).trim();
}
const propOrder: string[] = [];
const existing: Record<string, any> = {};
let currentKey: string | null = null;
propertiesText.split("\n").forEach((line) => {
const match = line.match(/^(\w+):\s*(.+)?$/);
if (match) {
const [, key, value] = match;
propOrder.push(key);
currentKey = key;
if (KNOWN_ARRAY_KEYS.includes(key as any)) {
existing[key] = [];
} else {
existing[key] = value ? value.trim() : "";
}
} else if (currentKey && KNOWN_ARRAY_KEYS.includes(currentKey as any) && line.trim().startsWith("- ")) {
const item = line.trim().replace(/^-\s*/, "");
if (item) (existing[currentKey] as string[]).push(item);
}
});
const escapedTitle = newTitle.replace(/"/g, '\\"');
const titleVal = newTitle.includes(" ") || newTitle.includes('"') ? `"${escapedTitle}"` : newTitle;
existing[titleKey] = titleVal;
if (!propOrder.includes(titleKey)) {
propOrder.push(titleKey);
}
let newContent = "---\n";
for (const key of propOrder) {
const val = existing[key];
if (Array.isArray(val)) {
newContent += `${key}:\n`;
if (val.length > 0) {
val.forEach((item: string) => {
newContent += ` - ${item}\n`;
});
}
} else {
newContent += `${key}: ${val || ""}\n`;
}
}
newContent += "---";
const bodyContent = content.slice(propertiesEnd);
newContent += bodyContent;
await this.app.vault.modify(file, newContent);
}
private getTitleKey(type: PostType): string {
const template = type === "post" ? this.settings.defaultTemplate : this.settings.pageTemplate;
const lines = template.split("\n");
let inProperties = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "---") {
inProperties = !inProperties;
continue;
}
if (inProperties) {
const match = trimmed.match(/^(\w+):\s*(.+)$/);
if (match) {
const key = match[1];
const value = match[2];
if (value.includes("{{title}}")) {
return key;
}
}
}
}
return "title";
}
}

View file

@ -4,21 +4,22 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strict": true,
"strictNullChecks": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2018"
]
},
"include": [
"**/*.ts"
"src/**/*.ts"
]
}

View file

@ -1,3 +1,3 @@
{
"0.5.3": "0.15.0"
"0.5.5": "0.15.0"
}