Release 0.1.1

This commit is contained in:
github-actions[bot] 2026-07-21 23:23:51 +00:00
parent 23e51a867f
commit 1b2a69b437
10 changed files with 457 additions and 3 deletions

1
chartdown Submodule

@ -0,0 +1 @@
Subproject commit cc691858d0788634000aba838ee6ec7e3373f32c

View file

@ -1,10 +1,10 @@
{
"id": "chartdown",
"name": "Chartdown",
"version": "0.1.0",
"version": "0.1.1",
"minAppVersion": "1.5.0",
"description": "Render Chartdown map blocks (battlemaps, hex charts, region maps) as SVG, right in your notes.",
"author": "Nossimonov",
"authorUrl": "https://github.com/Nossimonov/Chartdown",
"authorUrl": "https://github.com/Nossimonov",
"isDesktopOnly": false
}

116
src/block.test.ts Normal file
View file

@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mountChartdownBlock, type BlockIO } from "./block";
const SOURCE = [
"# Test Map",
"map: battlemap",
"grid: square 6x6",
"[structures]",
'building shed "Shed" : B2..D4',
" door : C4.s",
"[tokens]",
'ogre "Gruk" : E5 hidden',
].join("\n");
interface Written {
name: string;
contents: string;
}
function makeIO(withRaster = false): { io: BlockIO; written: Written[]; notices: string[]; revealed: string[] } {
const written: Written[] = [];
const notices: string[] = [];
const revealed: string[] = [];
const io: BlockIO = {
writeFile: async (name, contents) => {
written.push({ name, contents });
},
notify: (m) => notices.push(m),
reveal: (name) => revealed.push(name),
...(withRaster
? {
rasterize: async () => "FAKEPNGBASE64",
}
: {}),
};
return { io, written, notices, revealed };
}
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
describe("the per-map toolbar", () => {
it("toggles GM view in place, re-rendering the map", () => {
const el = document.createElement("div");
mountChartdownBlock(SOURCE, el, { initialMode: "player", baseName: "test-map", folderLabel: "", io: makeIO().io });
const toggle = el.querySelector<HTMLButtonElement>(".chartdown-mode-toggle")!;
expect(toggle.textContent).toBe("Player view");
expect(el.innerHTML).not.toContain("Gruk");
toggle.click();
expect(toggle.textContent).toBe("GM view");
expect(el.innerHTML).toContain("Gruk");
toggle.click();
expect(el.innerHTML).not.toContain("Gruk");
});
it("exports SVG next to the note, says where, and reveals the file", async () => {
const el = document.createElement("div");
const { io, written, notices, revealed } = makeIO();
mountChartdownBlock(SOURCE, el, { initialMode: "gm", baseName: "test-map", folderLabel: "Maps/", io });
const buttons = [...el.querySelectorAll("button")];
buttons.find((b) => b.textContent === "Export SVG")!.click();
await flush();
expect(written).toHaveLength(1);
expect(written[0]!.name).toBe("test-map-gm.svg");
expect(written[0]!.contents).toContain("<svg");
expect(notices[0]).toContain("Maps/test-map-gm.svg"); // the notice names the vault path
expect(revealed).toEqual(["test-map-gm.svg"]); // and the file opens in the system explorer
});
it("exports UVTT with the rasterized image plugged in", async () => {
const el = document.createElement("div");
const { io, written, revealed, notices } = makeIO(true);
mountChartdownBlock(SOURCE, el, { initialMode: "player", baseName: "test-map", folderLabel: "", io });
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
await flush();
expect(written).toHaveLength(1);
expect(written[0]!.name).toBe("test-map.dd2vtt");
const uvtt = JSON.parse(written[0]!.contents) as Record<string, unknown>;
expect((uvtt["resolution"] as { map_size: { x: number } }).map_size.x).toBe(6);
expect(uvtt["image"]).toBe("FAKEPNGBASE64");
expect((uvtt["portals"] as unknown[]).length).toBe(1);
expect(revealed).toEqual(["test-map.dd2vtt"]);
expect(notices[0]).toContain("hidden in Obsidian's explorer"); // .dd2vtt is an unknown extension in-app
});
it("exports one UVTT file per level for multi-level maps", async () => {
const multi = [
"# Tower",
"map: battlemap",
"grid: square 6x6",
"levels: top base",
"level: base",
"[structures]",
'building tower "Tower" : B2..D4',
"[terrain top]",
"air : area A1..F6",
].join("\n");
const el = document.createElement("div");
const { io, written } = makeIO(true);
mountChartdownBlock(multi, el, { initialMode: "player", baseName: "tower", folderLabel: "", io });
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
await flush();
expect(written.map((w) => w.name).sort()).toEqual(["tower-base.dd2vtt", "tower-top.dd2vtt"]);
});
it("refuses UVTT for non-battlemaps with a notice, writing nothing", async () => {
const hex = ["map: hexcrawl", "grid: hex 3x3 pointy odd-row", "[hexes]", "A1 sea"].join("\n");
const el = document.createElement("div");
const { io, written, notices } = makeIO();
mountChartdownBlock(hex, el, { initialMode: "player", baseName: "hexy", folderLabel: "", io });
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
await flush();
expect(written).toHaveLength(0);
expect(notices.some((n) => n.includes("battlemap-only"))).toBe(true);
});
});

111
src/block.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* The per-map block UI (issue #41): a toolbar GM/player toggle that
* re-renders in place, SVG export, UVTT export (one .dd2vtt per level)
* above the rendered map. All side effects (writing files, notices, raster)
* arrive injected through BlockIO, so this whole module tests against a
* plain DOM.
*/
import { parse } from "@chartdown/core";
import { exportUvttSource, renderSource } from "@chartdown/render-svg";
import { renderChartdownBlock, type RenderMode } from "./render";
export interface BlockIO {
/** Write a text file next to the note; overwrite silently. */
writeFile(name: string, contents: string): Promise<void>;
notify(message: string): void;
/** Reveal an exported file in the system file explorer (desktop only). */
reveal?(name: string): void;
/** Rasterize `region` of an SVG to a base64 PNG (no data-URI prefix). */
rasterize?(svg: string, region: { x: number; y: number; w: number; h: number }, outW: number, outH: number): Promise<string>;
}
export interface BlockOptions {
initialMode: RenderMode;
/** File base for exports, normally the map's doc id. */
baseName: string;
/** Vault-relative folder exports land in ("" = vault root) — notices say where. */
folderLabel: string;
io: BlockIO;
}
const PIXELS_PER_GRID = 70;
export function mountChartdownBlock(source: string, el: HTMLElement, opts: BlockOptions): void {
const doc = el.ownerDocument;
let mode: RenderMode = opts.initialMode;
const wrapper = doc.createElement("div");
wrapper.className = "chartdown-block";
const toolbar = doc.createElement("div");
toolbar.className = "chartdown-toolbar";
const modeBtn = doc.createElement("button");
modeBtn.className = "chartdown-mode-toggle";
const svgBtn = doc.createElement("button");
svgBtn.textContent = "Export SVG";
const uvttBtn = doc.createElement("button");
uvttBtn.textContent = "Export UVTT";
toolbar.append(modeBtn, svgBtn, uvttBtn);
const mapHost = doc.createElement("div");
mapHost.className = "chartdown-map-host";
wrapper.append(toolbar, mapHost);
el.appendChild(wrapper);
const rerender = (): void => {
mapHost.replaceChildren();
renderChartdownBlock(source, mapHost, mode);
modeBtn.textContent = mode === "gm" ? "GM view" : "Player view";
modeBtn.setAttribute("aria-pressed", String(mode === "gm"));
};
modeBtn.addEventListener("click", () => {
mode = mode === "gm" ? "player" : "gm";
rerender();
});
svgBtn.addEventListener("click", () => {
void (async () => {
const { svg } = renderSource(source, { mode });
const name = `${opts.baseName}${mode === "gm" ? "-gm" : ""}.svg`;
await opts.io.writeFile(name, svg);
opts.io.notify(`Chartdown: exported ${opts.folderLabel}${name} (a real file in your vault folder)`);
opts.io.reveal?.(name);
})();
});
uvttBtn.addEventListener("click", () => {
void (async () => {
const parsed = parse(source).document;
const levels = parsed.levels.length > 0 ? parsed.levels : [""];
for (const level of levels) {
const result = exportUvttSource(source, {
mode,
...(level ? { level } : {}),
pixelsPerGrid: PIXELS_PER_GRID,
});
if (!result.uvtt) {
const error = result.diagnostics.find((d) => d.severity === "error");
opts.io.notify(`Chartdown: ${error?.message ?? "UVTT export failed"}`);
return;
}
if (opts.io.rasterize && result.svg && result.imageRegion) {
const size = (result.uvtt["resolution"] as { map_size: { x: number; y: number } }).map_size;
result.uvtt["image"] = await opts.io.rasterize(
result.svg,
result.imageRegion,
size.x * PIXELS_PER_GRID,
size.y * PIXELS_PER_GRID,
);
}
const name = `${opts.baseName}${level ? `-${level}` : ""}.dd2vtt`;
await opts.io.writeFile(name, JSON.stringify(result.uvtt));
// Obsidian's file explorer hides unknown extensions — the file is
// real but invisible in-app, so say where it went and reveal it.
opts.io.notify(`Chartdown: exported ${opts.folderLabel}${name} (hidden in Obsidian's explorer; it's in your vault folder)`);
opts.io.reveal?.(name);
}
})();
});
rerender();
}

106
src/main.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* Obsidian plugin entry (issues #38/#41): registers the `chartdown`
* code-block processor each block mounts with a toolbar (GM/player toggle,
* SVG and UVTT export) and one setting: the DEFAULT view for newly
* rendered blocks. Default player, fail-closed per spec 01 §6.
*/
import { Notice, Plugin, PluginSettingTab, Setting, type App } from "obsidian";
import { parse } from "@chartdown/core";
import { mountChartdownBlock } from "./block";
import type { RenderMode } from "./render";
interface ChartdownSettings {
mode: RenderMode;
}
const DEFAULT_SETTINGS: ChartdownSettings = { mode: "player" };
/** Rasterize a region of an SVG to base64 PNG via an offscreen canvas. */
async function rasterize(
svg: string,
region: { x: number; y: number; w: number; h: number },
outW: number,
outH: number,
): Promise<string> {
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("could not rasterize the map SVG"));
img.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("no 2d canvas available");
ctx.drawImage(img, region.x, region.y, region.w, region.h, 0, 0, outW, outH);
return canvas.toDataURL("image/png").split(",")[1] ?? "";
} finally {
URL.revokeObjectURL(url);
}
}
export default class ChartdownPlugin extends Plugin {
settings: ChartdownSettings = DEFAULT_SETTINGS;
override async onload(): Promise<void> {
this.settings = { ...DEFAULT_SETTINGS, ...((await this.loadData()) as Partial<ChartdownSettings> | null) };
this.registerMarkdownCodeBlockProcessor("chartdown", (source, el, ctx) => {
const slash = ctx.sourcePath.lastIndexOf("/");
const folder = slash >= 0 ? ctx.sourcePath.slice(0, slash + 1) : "";
mountChartdownBlock(source, el, {
initialMode: this.settings.mode,
baseName: parse(source).document.docId,
folderLabel: folder,
io: {
writeFile: async (name, contents) => {
await this.app.vault.adapter.write(folder + name, contents);
},
notify: (message) => {
new Notice(message, 8000);
},
reveal: (name) => {
// Desktop API; opens the system file explorer with the file
// selected — the "get it out as a file" affordance.
(this.app as unknown as { showInFolder?: (path: string) => void }).showInFolder?.(folder + name);
},
rasterize,
},
});
});
this.addSettingTab(new ChartdownSettingTab(this.app, this));
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}
class ChartdownSettingTab extends PluginSettingTab {
private readonly plugin: ChartdownPlugin;
constructor(app: App, plugin: ChartdownPlugin) {
super(app, plugin);
this.plugin = plugin;
}
override display(): void {
this.containerEl.empty();
new Setting(this.containerEl)
.setName("Default to GM view")
.setDesc(
"New map blocks start in GM view (hidden tokens, [gm] notes, triggers). " +
"Each map also has its own toolbar toggle. Off, maps start as the " +
"player view — secrets stripped fail-closed.",
)
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.mode === "gm").onChange(async (value) => {
this.plugin.settings.mode = value ? "gm" : "player";
await this.plugin.saveSettings();
}),
);
}
}

40
src/render.test.ts Normal file
View file

@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { renderChartdownBlock } from "./render";
const SOURCE = [
"map: battlemap",
"grid: square 6x6",
"[tokens]",
'ogre "Gruk" : C3 size=2 hidden',
"hero : B2",
].join("\n");
describe("obsidian block rendering", () => {
it("renders an SVG that scales to the note (no fixed width/height)", () => {
const el = document.createElement("div");
renderChartdownBlock(SOURCE, el, "player");
const svg = el.querySelector("svg.chartdown-map");
expect(svg).toBeTruthy();
expect(svg!.hasAttribute("viewBox")).toBe(true);
expect(svg!.hasAttribute("width")).toBe(false);
expect(svg!.hasAttribute("height")).toBe(false);
});
it("player mode strips secrets; gm mode shows them (fail-closed default)", () => {
const player = document.createElement("div");
renderChartdownBlock(SOURCE, player, "player");
expect(player.innerHTML).not.toContain("Gruk");
const gm = document.createElement("div");
renderChartdownBlock(SOURCE, gm, "gm");
expect(gm.innerHTML).toContain("Gruk");
});
it("errors surface beneath the map instead of vanishing", () => {
const el = document.createElement("div");
renderChartdownBlock("map: battlemap\ngrid: square 4x4\n[features]\ntable : on ghost at B2", el, "player");
const box = el.querySelector(".chartdown-diagnostics");
expect(box).toBeTruthy();
expect(box!.textContent).toContain("line");
});
});

35
src/render.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* The pure half of the plugin: turn a chartdown fence's text into DOM inside
* the element Obsidian hands us. Kept free of the `obsidian` module so it
* tests against a plain DOM (the Obsidian-specific surface stays one thin
* adapter in main.ts).
*/
import { renderSource } from "@chartdown/render-svg";
export type RenderMode = "player" | "gm";
export function renderChartdownBlock(source: string, el: HTMLElement, mode: RenderMode): void {
const doc = el.ownerDocument;
const { svg, diagnostics } = renderSource(source, { mode });
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
const node = doc.importNode(parsed.documentElement, true) as unknown as SVGSVGElement;
// Scale to the note's width; the viewBox keeps the aspect ratio.
node.removeAttribute("width");
node.removeAttribute("height");
node.classList.add("chartdown-map");
el.appendChild(node);
const errors = diagnostics.filter((d) => d.severity === "error");
if (errors.length > 0) {
const box = doc.createElement("div");
box.className = "chartdown-diagnostics";
for (const d of errors) {
const line = doc.createElement("div");
line.textContent = `line ${d.line}: ${d.message}`;
box.appendChild(line);
}
el.appendChild(box);
}
}

32
styles.css Normal file
View file

@ -0,0 +1,32 @@
.chartdown-toolbar {
display: flex;
gap: 6px;
margin-bottom: 4px;
}
.chartdown-toolbar button {
font-size: var(--font-ui-smaller);
padding: 2px 10px;
cursor: pointer;
}
.chartdown-mode-toggle[aria-pressed="true"] {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.chartdown-map {
width: 100%;
height: auto;
display: block;
}
.chartdown-diagnostics {
color: var(--text-error);
font-size: 0.85em;
font-family: var(--font-monospace);
margin-top: 0.5em;
padding: 0.5em 0.75em;
border-left: 2px solid var(--text-error);
background: var(--background-secondary);
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@chartdown/core": ["../core/src/index"],
"@chartdown/render-svg": ["../render-svg/src/index"]
}
},
"include": [
"src"
]
}

View file

@ -1,3 +1,4 @@
{
"0.1.0": "1.5.0"
"0.1.0": "1.5.0",
"0.1.1": "1.5.0"
}