fix(css,preview): scope utilities under the view root, preview with real boards and foreign css
This commit is contained in:
1 parent
0a7742eb5e
commit
8c971e431a
10 files changed
+458
-14
No files matched your search
+54
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(", ")}`);
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user