fix(css,preview): scope utilities under the view root, preview with real boards and foreign css

This commit is contained in:
hh committed 2026-08-29 19:39:19 +02:00
1 parent 0a7742eb5e
commit 8c971e431a
10 files changed
+458 -14

No files matched your search

+54 -10
View File
@@ -2,17 +2,46 @@ import { Component, TFile } from "obsidian";
import { mount } from "svelte";
import App from "../src/ui/app.svelte";
import { DEFAULT_SETTINGS } from "../src/settings";
import type { BeaverCalendarSettings } from "../src/settings";
import { BoardStore } from "../src/vault/store.svelte";
const BOARDS = ["personal", "work", "reading", "home"];
const FOLDER = "Boards";
const FIXTURES = ["personal", "work", "reading", "home"];
/**
* Settings come off the query string, so a screenshot can start from the
* same preferences a real vault has: ?groupBy=due&includeArchive=1&railCollapsed=1
*/
function prefsFromQuery(): Partial<BeaverCalendarSettings> {
const params = new URLSearchParams(location.search);
const out: Record<string, unknown> = {};
const flag = (value: string): boolean => value === "1" || value === "true";
for (const [key, value] of params) {
if (!(key in DEFAULT_SETTINGS)) continue;
const current = DEFAULT_SETTINGS[key as keyof BeaverCalendarSettings];
out[key] = typeof current === "boolean" ? flag(value) : value;
}
return out as Partial<BeaverCalendarSettings>;
}
async function main(): Promise<void> {
// Real boards when the server was given a folder, the fixtures otherwise.
const index = (await (await fetch("/boards.json")).json()) as {
folder: string;
names: string[];
} | null;
const folder = index?.folder ?? "Boards";
const sources = index
? index.names.map((name) => ({ path: `${folder}/${name}`, url: `/boards/${name}` }))
: FIXTURES.map((name) => ({
path: `${folder}/${name}.md`,
url: `/tests/fixtures/${name}.md`,
}));
const files = new Map<string, string>();
await Promise.all(
BOARDS.map(async (name) => {
const response = await fetch(`/tests/fixtures/${name}.md`);
files.set(`${FOLDER}/${name}.md`, await response.text());
sources.map(async ({ path, url }) => {
const response = await fetch(url);
files.set(path, await response.text());
}),
);
const handles = new Map(
@@ -37,7 +66,7 @@ async function main(): Promise<void> {
plugins: { plugins: {} },
};
const settings = { ...DEFAULT_SETTINGS, boardsFolder: FOLDER };
const settings = { ...DEFAULT_SETTINGS, boardsFolder: folder, ...prefsFromQuery() };
const store = new BoardStore(app as never, settings, () => {});
await store.reloadAll();
@@ -58,6 +87,14 @@ async function main(): Promise<void> {
await wait(50);
store.keyboard = true;
const first = store.spheres[0];
const tab = async (times: number): Promise<void> => {
for (let i = 0; i < times; i += 1) {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", code: "Tab", bubbles: true }),
);
await wait(20);
}
};
if (scene.startsWith("rail")) {
store.toggleRail();
store.railExpanded = new Set([first.path, store.spheres[1].path]);
@@ -85,10 +122,17 @@ async function main(): Promise<void> {
if (scene === "date") {
store.write(null, "[data-composer='toolbar']");
await wait(50);
for (let i = 0; i < 3; i += 1) {
document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await wait(20);
}
await tab(3);
}
// The editor over an existing task, tabbed through to its date field.
if (scene === "edit-date") {
const task = store.visible[0];
store.zone = "task";
store.cursorTask = task.id;
await wait(20);
store.edit(task.id);
await wait(50);
await tab(3);
}
}
+2
View File
@@ -6,6 +6,8 @@
<link rel="stylesheet" href="/app.css" />
<link rel="stylesheet" href="/theme.css" />
<link rel="stylesheet" href="/styles.css" />
<!-- Other plugins' stylesheets (EXTRA_CSS), after ours like a later plugin. -->
<link rel="stylesheet" href="/extra.css" />
<style>
body.theme-dark,
body.theme-light {
+63 -1
View File
@@ -3,12 +3,13 @@ import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { homedir, tmpdir } from "node:os";
import { extname, join, normalize } from "node:path";
import { basename, extname, join, normalize, resolve } from "node:path";
import { extractFile } from "@electron/asar";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
@@ -65,6 +66,26 @@ if (!theme) {
);
}
/**
* Other plugins' stylesheets, loaded after ours the way Obsidian would load a
* plugin enabled later: EXTRA_CSS=path[,path…]. This is how a cascade fight
* with another Tailwind build is reproduced outside Obsidian.
*/
const extraCss = (process.env.EXTRA_CSS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => resolve(item));
for (const file of extraCss) {
if (!existsSync(file)) console.warn(`preview: EXTRA_CSS not found: ${file}`);
}
/** Real boards instead of the fixtures: BOARDS=<folder of .md boards>. */
const boards = process.env.BOARDS ? resolve(process.env.BOARDS) : null;
if (boards && !existsSync(boards)) {
console.warn(`preview: BOARDS folder not found: ${boards}`);
}
const ctx = await esbuild.context({
entryPoints: [join(root, "preview/entry.ts")],
bundle: true,
@@ -85,9 +106,17 @@ const TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".md": "text/plain; charset=utf-8",
};
function boardNames() {
if (!boards || !existsSync(boards)) return null;
return readdirSync(boards)
.filter((name) => name.endsWith(".md"))
.sort();
}
createServer((request, response) => {
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
const external = { "/app.css": appCss, "/theme.css": theme };
@@ -103,6 +132,37 @@ createServer((request, response) => {
return;
}
if (url === "/extra.css") {
const body = extraCss
.filter((file) => existsSync(file))
.map((file) => `/* ${file} */\n${readFileSync(file, "utf8")}`)
.join("\n");
response
.writeHead(200, { "content-type": TYPES[".css"], "cache-control": "no-store" })
.end(body);
return;
}
if (url === "/boards.json") {
const names = boardNames();
const body = names ? { folder: basename(boards), names } : null;
response
.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" })
.end(JSON.stringify(body));
return;
}
if (url.startsWith("/boards/") && boards) {
const file = join(boards, normalize(url.slice("/boards/".length)));
if (!file.startsWith(boards) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
return;
}
response.writeHead(200, { "content-type": TYPES[".md"], "cache-control": "no-store" });
createReadStream(file).pipe(response);
return;
}
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
response.writeHead(404).end("not found");
@@ -115,4 +175,6 @@ createServer((request, response) => {
createReadStream(file).pipe(response);
}).listen(port, () => {
console.log(`preview: http://localhost:${port}/`);
if (boards) console.log(`preview: boards from ${boards}`);
if (extraCss.length > 0) console.log(`preview: extra css ${extraCss.join(", ")}`);
});
+137
View File
@@ -0,0 +1,137 @@
// node preview/shot.mjs <out.png> <url> [width] [height]
//
// Headless Chrome over the DevTools protocol: a screenshot plus everything the
// page said on the console, and a note if the page stopped answering — which
// a plain --screenshot run would only report as a silent timeout.
// DUMP=1 also print the page text
// PROBE=js evaluate an expression at the end and print its value
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
const [out, url, w = "1100", h = "720"] = process.argv.slice(2);
if (!out || !url) {
console.error("usage: node preview/shot.mjs <out.png> <url> [width] [height]");
process.exit(2);
}
const port = 9334;
const chrome =
process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const proc = spawn(
chrome,
[
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--remote-debugging-port=${port}`,
`--window-size=${w},${h}`,
"--user-data-dir=/tmp/bcal-chrome-profile",
"about:blank",
],
{ stdio: "ignore" },
);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let targets = [];
for (let i = 0; i < 40; i += 1) {
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
if (targets.length > 0) break;
} catch {
/* not up yet */
}
await wait(250);
}
const page = targets.find((target) => target.type === "page");
if (!page) {
console.error("shot: chrome did not come up");
proc.kill();
process.exit(1);
}
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve) => {
ws.onopen = resolve;
});
let id = 0;
const pending = new Map();
const logs = [];
ws.onmessage = (message) => {
const data = JSON.parse(String(message.data));
if (data.id && pending.has(data.id)) {
pending.get(data.id)(data);
pending.delete(data.id);
}
if (data.method === "Runtime.consoleAPICalled") {
logs.push(
`${data.params.type}: ${data.params.args
.map((arg) => arg.value ?? arg.description)
.join(" ")}`,
);
}
if (data.method === "Runtime.exceptionThrown") {
const details = data.params.exceptionDetails;
logs.push(`EXC: ${details.exception?.description ?? details.text}`);
}
};
const send = (method, params = {}, timeout = 15_000) =>
new Promise((resolve, reject) => {
const next = ++id;
const timer = setTimeout(() => {
pending.delete(next);
reject(new Error(`${method} timed out after ${timeout} ms`));
}, timeout);
pending.set(next, (value) => {
clearTimeout(timer);
resolve(value);
});
ws.send(JSON.stringify({ id: next, method, params }));
});
await send("Emulation.setDeviceMetricsOverride", {
width: Number(w),
height: Number(h),
deviceScaleFactor: 2,
mobile: false,
});
await send("Page.enable");
await send("Runtime.enable");
await send("Page.navigate", { url });
await wait(Number(process.env.SETTLE ?? 2500));
let alive = true;
try {
const ping = await send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }, 5000);
alive = ping.result?.result?.value === 2;
} catch {
alive = false;
}
if (!alive) {
console.log("shot: PAGE UNRESPONSIVE (main thread busy — a hang)");
}
if (alive && process.env.PROBE) {
const probe = await send("Runtime.evaluate", {
expression: process.env.PROBE,
returnByValue: true,
});
console.log("PROBE:", JSON.stringify(probe.result?.result?.value ?? probe.result));
}
if (alive && process.env.DUMP) {
const text = await send("Runtime.evaluate", {
expression: "document.body.innerText.slice(0, 1500)",
returnByValue: true,
});
console.log("TEXT:", text.result?.result?.value);
}
try {
const shot = await send("Page.captureScreenshot", { format: "png" }, 10_000);
writeFileSync(out, Buffer.from(shot.result.data, "base64"));
console.log("wrote", out);
} catch (error) {
console.log("shot: no screenshot —", error.message);
}
if (logs.length > 0) console.log("LOGS:\n" + logs.join("\n"));
ws.close();
proc.kill();
process.exit(alive ? 0 : 3);