init commit

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-02-12 19:27:36 -05:00
commit 9a4b5d4254
13 changed files with 1765 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
main.js
main.js.map
.DS_Store
*.log

35
README.md Normal file
View file

@ -0,0 +1,35 @@
# Duckmage Obsidian Plugin
A template Obsidian plugin. Customize `manifest.json`, `main.ts`, and `styles.css` for your vault.
## Development
1. **Install dependencies**
```bash
npm install
```
2. **Build**
- `npm run dev` — watch mode (rebuilds on save)
- `npm run build` — production build
3. **Install in Obsidian**
- Copy the entire `plugin` folder into your vaults `.obsidian/plugins/duckmage-plugin/` (create the folder if needed), **or** symlink this folder there.
- Ensure these files are present in that folder: `main.js`, `manifest.json`, `styles.css`.
- In Obsidian: **Settings → Community plugins → Turn on “Duckmage Plugin”** (enable Developer mode if the plugin doesnt appear).
## Troubleshooting
- **"Failed to load plugin duckmage-plugin"** — Usually means `main.js` is missing. Run `npm run build` in this folder so that `main.js` is generated from `main.ts`. Obsidian loads the compiled JS, not the TypeScript source.
- **Viewing logs** — In Obsidian, press **Ctrl+Shift+I** (Windows/Linux) or **Cmd+Option+I** (Mac) to open Developer tools. Check the **Console** tab for red error messages when you enable the plugin or use its commands.
## Files
| File | Purpose |
|------|--------|
| `main.ts` | Plugin logic (ribbon, commands, settings) |
| `manifest.json` | Plugin id, name, version, min Obsidian version |
| `styles.css` | Plugin styles |
| `versions.json` | Version history for community plugin releases |
After editing `main.ts`, run `npm run dev` or `npm run build` to produce `main.js`.

11
_Index_of_plugin.md Normal file
View file

@ -0,0 +1,11 @@
%% Zoottelkeeper: Beginning of the autogenerated index file list %%
[[RPG/duckmage/plugin/esbuild.config.mjs|esbuild.config.mjs]]
[[RPG/duckmage/plugin/main.ts|main.ts]]
[[RPG/duckmage/plugin/manifest.json|manifest.json]]
[[RPG/duckmage/plugin/package.json|package.json]]
[[RPG/duckmage/plugin/README|README]]
[[RPG/duckmage/plugin/styles.css|styles.css]]
[[RPG/duckmage/plugin/tsconfig.json|tsconfig.json]]
[[RPG/duckmage/plugin/version-bump.mjs|version-bump.mjs]]
[[RPG/duckmage/plugin/versions.json|versions.json]]
%% Zoottelkeeper: End of the autogenerated index file list %%

5
data.json Normal file
View file

@ -0,0 +1,5 @@
{
"mySetting": "default",
"hexFolder": "RPG/duckmage/world/hexes",
"templatePath": "RPG/duckmage/world/hextemplate.md"
}

41
esbuild.config.mjs Normal file
View file

@ -0,0 +1,41 @@
import esbuild from "esbuild";
import process from "process";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("./package.json");
const fs = require("fs");
const production = process.argv[2] === "production";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = production;
const outDir = ".";
const outFile = "main.js";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: ["obsidian"],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: `${outDir}/${outFile}`,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

395
main.ts Normal file
View file

@ -0,0 +1,395 @@
import { App, ItemView, Menu, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, WorkspaceLeaf } from "obsidian";
const VIEW_TYPE_HEX_MAP = "duckmage-hex-map";
/** Normalize folder path: no leading/trailing slashes. */
function normalizeFolder(path: string): string {
return path.replace(/^\/+|\/+$/g, "") || "";
}
/** Get terrain key from a note's frontmatter (from cache or by reading). */
function getTerrainFromFile(app: App, path: string): string | null {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return null;
const cache = app.metadataCache.getFileCache(file);
const terrain = cache?.frontmatter?.terrain;
return typeof terrain === "string" ? terrain : null;
}
/** Set terrain in a note's frontmatter. File must already exist. */
async function setTerrainInFile(app: App, path: string, terrainKey: string | null): Promise<boolean> {
const file = app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) return false;
const content = await app.vault.read(file);
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
let newContent: string;
if (fmMatch) {
const fmBlock = fmMatch[1];
const rest = content.slice(fmMatch[0].length);
let newFm: string;
if (terrainKey === null) {
// Remove any terrain line (including optional newline); support quoted values
newFm = fmBlock.replace(/^\s*terrain:\s*[^\r\n]*(?:\r?\n)?/gm, "").trimEnd();
} else {
const terrainLine = /^\s*terrain:\s*.*$/m;
newFm = terrainLine.test(fmBlock)
? fmBlock.replace(terrainLine, `terrain: ${terrainKey}`)
: fmBlock.trimEnd() + (fmBlock.endsWith("\n") ? "" : "\n") + `terrain: ${terrainKey}\n`;
}
newContent = `---\n${newFm}\n---\n${rest}`;
} else {
if (terrainKey === null) return true;
newContent = `---\nterrain: ${terrainKey}\n---\n\n${content}`;
}
await app.vault.modify(file, newContent);
return true;
}
export interface TerrainColor {
name: string;
color: string;
}
interface DuckmagePluginSettings {
mySetting: string;
hexFolder: string;
templatePath: string;
hexGap: string;
terrainPalette: TerrainColor[];
}
const DEFAULT_TERRAIN_PALETTE: TerrainColor[] = [
{ name: "mountain", color: "#9ca3af" },
{ name: "water", color: "#60a5fa" },
{ name: "grass", color: "#84cc16" },
{ name: "forest", color: "#15803d" },
{ name: "cliffs", color: "#a16207" },
{ name: "desert", color: "#eab308" },
{ name: "snow", color: "#e0f2fe" },
];
const DEFAULT_SETTINGS: DuckmagePluginSettings = {
mySetting: "default",
hexFolder: "world/hexes",
templatePath: "",
hexGap: "0.15",
terrainPalette: DEFAULT_TERRAIN_PALETTE,
};
export default class DuckmagePlugin extends Plugin {
settings: DuckmagePluginSettings;
async onload() {
await this.loadSettings();
this.registerView(VIEW_TYPE_HEX_MAP, (leaf) => new HexMapView(leaf, this));
this.addRibbonIcon("map", "Duckmage: Open hex map", () => this.openHexMap());
this.addCommand({
id: "open-hex-map",
name: "Open Duckmage hex map",
callback: () => this.openHexMap(),
});
// Settings tab
this.addSettingTab(new DuckmageSettingTab(this.app, this));
}
private openHexMap(): void {
const leaf = this.app.workspace.getLeaf(false);
leaf.setViewState({ type: VIEW_TYPE_HEX_MAP });
}
onunload() {}
async loadSettings() {
const data = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
if (!Array.isArray(this.settings.terrainPalette) || this.settings.terrainPalette.length === 0) {
this.settings.terrainPalette = [...DEFAULT_TERRAIN_PALETTE];
}
}
async saveSettings() {
await this.saveData(this.settings);
}
/** Path for hex note at (x, y). */
hexPath(x: number, y: number): string {
const folder = normalizeFolder(this.settings.hexFolder);
return folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`;
}
}
class HexMapView extends ItemView {
static readonly GRID_COLS = 20;
static readonly GRID_ROWS = 16;
plugin: DuckmagePlugin;
constructor(leaf: WorkspaceLeaf, plugin: DuckmagePlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE_HEX_MAP;
}
getDisplayText(): string {
return "Hex map";
}
async onOpen(): Promise<void> {
this.renderGrid();
}
/**
* @param terrainOverrides - If set, use these terrain keys for the given paths instead of cache (for immediate UI update after set/clear).
*/
private renderGrid(terrainOverrides?: Map<string, string | null>): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClasses(["duckmage-hex-map-container"]);
const gap = this.plugin.settings.hexGap?.trim() || "0.15";
contentEl.style.setProperty("--duckmage-hex-gap", /^\d*\.?\d+$/.test(gap) ? `${gap}em` : gap);
const folder = normalizeFolder(this.plugin.settings.hexFolder);
const palette = this.plugin.settings.terrainPalette ?? [];
for (let y = 0; y < HexMapView.GRID_ROWS; y++) {
const rowEl = contentEl.createDiv({ cls: "duckmage-hex-row" });
for (let x = 0; x < HexMapView.GRID_COLS; x++) {
const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`;
const file = this.app.vault.getAbstractFileByPath(path);
const exists = file instanceof TFile;
const terrainKey =
terrainOverrides?.has(path) ? terrainOverrides.get(path)! : getTerrainFromFile(this.app, path);
const terrainColor =
terrainKey != null ? palette.find((p) => p.name === terrainKey)?.color : undefined;
const hexEl = rowEl.createDiv({
cls: `duckmage-hex ${exists ? "duckmage-hex-exists" : ""}`,
attr: { "data-x": String(x), "data-y": String(y) },
});
hexEl.tabIndex = -1;
if (terrainColor) hexEl.style.backgroundColor = terrainColor;
hexEl.createSpan({ cls: "duckmage-hex-label", text: `${x},${y}` });
if (exists) hexEl.createSpan({ cls: "duckmage-hex-dot" });
hexEl.addEventListener("click", () => this.onHexClick(x, y));
hexEl.addEventListener("contextmenu", (e) => this.onHexContextMenu(e, x, y));
}
}
}
private onHexContextMenu(evt: MouseEvent, x: number, y: number): void {
evt.preventDefault();
const path = this.plugin.hexPath(x, y);
const palette = this.plugin.settings.terrainPalette ?? [];
const hexEl = evt.currentTarget as HTMLElement;
const clearHighlight = () => {
hexEl.blur();
window.getSelection()?.removeAllRanges();
};
const menu = new Menu();
for (const entry of palette) {
menu.addItem((item) =>
item
.setTitle(entry.name)
.setIcon("palette")
.onClick(async () => {
clearHighlight();
let file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
file = await this.createHexNote(x, y, path);
if (!file) return;
}
await setTerrainInFile(this.app, path, entry.name);
this.renderGrid(new Map([[path, entry.name]]));
})
);
}
const fileExists = this.app.vault.getAbstractFileByPath(path) instanceof TFile;
if (fileExists) {
menu.addSeparator();
menu.addItem((item) =>
item
.setTitle("Clear color")
.onClick(async () => {
clearHighlight();
await setTerrainInFile(this.app, path, null);
this.renderGrid(new Map([[path, null]]));
})
);
}
menu.showAtMouseEvent(evt);
}
private async onHexClick(x: number, y: number): Promise<void> {
const path = this.plugin.hexPath(x, y);
const abstract = this.app.vault.getAbstractFileByPath(path);
let fileToOpen: TFile | null = abstract instanceof TFile ? abstract : null;
if (!fileToOpen) {
const created = await this.createHexNote(x, y, path);
if (created) {
fileToOpen = created;
this.renderGrid();
} else return;
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(fileToOpen);
}
private async createHexNote(x: number, y: number, path: string): Promise<TFile | null> {
const templatePath = (this.plugin.settings.templatePath ?? "").replace(/^\/+|\/+$/g, "");
const DEFAULT_TEMPLATE = `# Hex {{x}}, {{y}}\n\n`;
let content: string;
if (templatePath) {
try {
content = await this.app.vault.adapter.read(templatePath);
} catch {
new Notice("Template not found: " + templatePath);
return null;
}
} else {
content = DEFAULT_TEMPLATE;
}
content = content
.replace(/\{\{x\}\}/g, String(x))
.replace(/\{\{y\}\}/g, String(y))
.replace(/\{\{title\}\}/g, `Hex ${x}, ${y}`);
const folder = normalizeFolder(this.plugin.settings.hexFolder);
if (folder) {
const folderObj = this.app.vault.getAbstractFileByPath(folder);
if (!folderObj) await this.app.vault.createFolder(folder);
}
try {
const created = await this.app.vault.create(path, content);
return created;
} catch (e) {
new Notice("Could not create note: " + (e instanceof Error ? e.message : String(e)));
return null;
}
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.setText("Sample modal — edit main.ts to customize.");
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
class DuckmageSettingTab extends PluginSettingTab {
plugin: DuckmagePlugin;
constructor(app: App, plugin: DuckmagePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Hex notes folder")
.setDesc("Vault-relative path where hex notes (x_y.md) are stored.")
.addText((text) =>
text
.setPlaceholder("world/hexes")
.setValue(this.plugin.settings.hexFolder)
.onChange(async (value) => {
this.plugin.settings.hexFolder = normalizeFolder(value ?? "");
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Template path")
.setDesc("Vault-relative path to the template file for new hex notes. Use {{x}}, {{y}}, {{title}} as placeholders.")
.addText((text) =>
text
.setPlaceholder("templates/hex.md")
.setValue(this.plugin.settings.templatePath)
.onChange(async (value) => {
this.plugin.settings.templatePath = (value ?? "").replace(/^\/+|\/+$/g, "");
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Hex cell spacing")
.setDesc("Gap between hex cells (e.g. 0.15 for a small gap, 0 for none). Use a number for em units, or add 'em'/'px' (e.g. 0.2em).")
.addText((text) =>
text
.setPlaceholder("0.15")
.setValue(this.plugin.settings.hexGap ?? "0.15")
.onChange(async (value) => {
this.plugin.settings.hexGap = (value ?? "0.15").trim() || "0";
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Terrain palette" });
const paletteDesc = containerEl.createDiv({ cls: "setting-item-description" });
paletteDesc.setText("Right-click a hex to set its color. Add or edit terrain types and colors below.");
paletteDesc.style.marginBottom = "0.75em";
const listEl = containerEl.createDiv({ cls: "duckmage-palette-list" });
const palette = this.plugin.settings.terrainPalette ?? [];
for (let i = 0; i < palette.length; i++) {
const entry = palette[i];
const itemEl = listEl.createDiv({ cls: "duckmage-palette-item" });
new Setting(itemEl)
.addText((text) =>
text
.setPlaceholder("Name (e.g. mountain)")
.setValue(entry.name)
.onChange(async (value) => {
entry.name = (value ?? "").trim() || entry.name;
await this.plugin.saveSettings();
})
)
.addColorPicker((color) => {
color.setValue(entry.color).onChange(async (value) => {
entry.color = value;
await this.plugin.saveSettings();
});
})
.addExtraButton((btn) =>
btn.setIcon("trash-2").onClick(async () => {
this.plugin.settings.terrainPalette.splice(i, 1);
await this.plugin.saveSettings();
this.display();
})
);
}
new Setting(containerEl).addButton((btn) =>
btn.setButtonText("Add terrain color").onClick(async () => {
this.plugin.settings.terrainPalette.push({ name: "New", color: "#888888" });
await this.plugin.saveSettings();
this.display();
})
);
}
}

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "duckmage-plugin",
"name": "Duckmage Plugin",
"version": "0.1.0",
"minAppVersion": "1.0.0",
"description": "A template Obsidian plugin for the Duckmage vault.",
"author": "Your Name",
"authorUrl": ""
}

1117
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "duckmage-plugin",
"version": "0.1.0",
"description": "A template Obsidian plugin for the Duckmage vault.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": ["obsidian", "plugin"],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"@codemirror/lang-markdown": "^6.2.2",
"@codemirror/language-data": "^6.5.1",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.25.1",
"@types/node": "^22.10.1",
"builtin-modules": "^3.3.0",
"esbuild": "0.24.0",
"obsidian": "latest",
"tslib": "2.8.1",
"typescript": "5.6.3"
}
}

85
styles.css Normal file
View file

@ -0,0 +1,85 @@
/* Duckmage plugin styles */
/* Hex map view */
.duckmage-hex-map-container {
padding: 1em;
overflow: auto;
}
.duckmage-hex-row {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
/* Flat-top hex: vertical step is 3/4 of hex height, so pull next row up by 1/4 of full height = half of --hex-size */
margin-bottom: calc(var(--hex-size, 2.2em) * -0.5);
}
.duckmage-hex-row:nth-child(even) {
/* Offset even rows by exactly half hex width so they tessellate */
margin-left: calc(var(--hex-size, 2.2em) * 1.732 / 2);
}
.duckmage-hex:focus {
outline: none;
}
.duckmage-hex {
--hex-size: 2.2em;
/* Flat-top regular hex: width = sqrt(3)*size, height = 2*size */
width: calc(var(--hex-size) * 1.732);
height: calc(var(--hex-size) * 2);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: var(--background-secondary);
border: 2px solid var(--background-modifier-border);
margin: var(--duckmage-hex-gap, 0.15em);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
position: relative;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.duckmage-hex:hover {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
}
/* Has-note state: accent border and dot only (no purple fill so terrain/clear stays clear) */
.duckmage-hex-exists {
border-color: var(--interactive-accent);
}
.duckmage-hex-exists:hover {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
}
.duckmage-hex-label {
font-size: 0.65em;
font-weight: 600;
color: var(--text-muted);
pointer-events: none;
user-select: none;
}
.duckmage-hex-dot {
position: absolute;
bottom: 0.35em;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--interactive-accent);
pointer-events: none;
}
/* Terrain-colored hex: keep label readable (text shadow if needed) */
.duckmage-hex[style*="background-color"] .duckmage-hex-label {
text-shadow: 0 0 2px var(--background-primary), 0 0 4px var(--background-primary);
}
/* Settings: terrain palette list */
.duckmage-palette-list .duckmage-palette-item {
margin-bottom: 0.5em;
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"]
}

16
version-bump.mjs Normal file
View file

@ -0,0 +1,16 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
const manifestPath = "manifest.json";
const versionsPath = "versions.json";
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.version = targetVersion;
writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t"));
const versions = JSON.parse(readFileSync(versionsPath, "utf8"));
versions[targetVersion] = manifest.minAppVersion;
writeFileSync(versionsPath, JSON.stringify(versions, null, "\t"));
console.log("Bumped version to", targetVersion);

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.0.0"
}