Add bot graphics
Bots can now show a concept as a visual: create_graphic (with update_graphic, read_graphic and list_graphics, on every run, no computer needed) stores one HTML document in bot_graphics, per user like the conversation, and the chat renders its [graphic:ID] ref as an inline card sized to fit, with a large view and a page of its own (no rail). Graphic HTML is treated as hostile: it is only served at /graphics/<id> with a CSP sandbox allowing scripts under an origin of its own and no network, loading only the vendored Chart.js and Mermaid from /graphics-lib/. The document carries the app's theme vars and scrollbars, reports its height to the thread, and a frame that navigates away is closed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { runGraphicsSql, viewerGraphicsSql } from "./bot-graphic-sql";
|
||||
import type { SqlCondition } from "./crew-access/types";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
|
||||
beforeAll(() => {
|
||||
db.run(`CREATE TABLE bot_graphics (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER)`);
|
||||
db.run(`INSERT INTO bot_graphics VALUES (1, 7, 2), (2, 7, 3), (3, 9, 2)`);
|
||||
});
|
||||
|
||||
function ids({ condition }: { condition: SqlCondition }) {
|
||||
return db
|
||||
.query(`SELECT g.id FROM bot_graphics g WHERE ${condition.sql} ORDER BY g.id`)
|
||||
.all(...condition.values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
describe("viewerGraphicsSql", () => {
|
||||
test("a user sees only the graphics made for them", () => {
|
||||
expect(ids({ condition: viewerGraphicsSql({ user_id: 7 }) })).toEqual([1, 2]);
|
||||
expect(ids({ condition: viewerGraphicsSql({ user_id: 9 }) })).toEqual([3]);
|
||||
expect(ids({ condition: viewerGraphicsSql({ user_id: 1 }) })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGraphicsSql", () => {
|
||||
test("a run edits only its bot's graphics for its user", () => {
|
||||
expect(ids({ condition: runGraphicsSql({ user_id: 7, bot_id: 2 }) })).toEqual([1]);
|
||||
expect(ids({ condition: runGraphicsSql({ user_id: 9, bot_id: 3 }) })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SqlCondition } from "./crew-access/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The graphics table's alias in the query.
|
||||
*/
|
||||
alias?: string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphics a user sees: only those a bot made for them, like the
|
||||
* conversation they were made in. Nobody else, crew managers included,
|
||||
* reads another user's graphic.
|
||||
*/
|
||||
export function viewerGraphicsSql({ alias = "g", user_id }: Params): SqlCondition {
|
||||
return { sql: `${alias}.user_id = ?`, values: [Number(user_id)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The graphics a run edits: those its bot made for its user.
|
||||
*/
|
||||
export function runGraphicsSql({ alias = "g", user_id, bot_id }: Params & { bot_id: number | string }): SqlCondition {
|
||||
return { sql: `${alias}.user_id = ? AND ${alias}.bot_id = ?`, values: [Number(user_id), Number(bot_id)] };
|
||||
}
|
||||
@@ -20,6 +20,10 @@ describe("exportMessageText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("words graphics by number", () => {
|
||||
expect(exportMessageText({ text: "See [graphic:5]", ...names, heading_offset: 0 })).toBe("See (graphic 5)");
|
||||
});
|
||||
|
||||
test("leaves refs that are already links alone", () => {
|
||||
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ type Params = {
|
||||
/**
|
||||
* A message's markdown as it reads outside the app: its refs in words
|
||||
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
|
||||
* "computer 3", a folder as its path, a secret by number only, never its
|
||||
* value) and its headings dropped by
|
||||
* "computer 3", a folder as its path, a graphic and a secret by number only, never
|
||||
* a secret's value) and its headings dropped by
|
||||
* `heading_offset`. Code blocks are left exactly as they are.
|
||||
*/
|
||||
export default function exportMessageText({
|
||||
@@ -85,6 +85,7 @@ function wordRefs({
|
||||
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
|
||||
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
|
||||
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
|
||||
.replace(/\[graphic:(\d+)\](?!\()/g, "(graphic $1)")
|
||||
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
|
||||
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
|
||||
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import graphicDocument from "./graphic-document";
|
||||
|
||||
const vars = {
|
||||
light: { "--c-paper": "#fff" },
|
||||
dark: { "--c-paper": "#000" },
|
||||
fonts: { "--font-sans": "sans-serif" },
|
||||
};
|
||||
|
||||
describe("graphicDocument", () => {
|
||||
test("wraps a fragment in a themed document", () => {
|
||||
const doc = graphicDocument({ html: "<svg></svg>", title: "A <b>", vars });
|
||||
|
||||
expect(doc.startsWith("<!DOCTYPE html><html><head>")).toBe(true);
|
||||
expect(doc).toContain("<title>A <b></title>");
|
||||
expect(doc).toContain("--c-paper:#fff;");
|
||||
expect(doc).toContain(":root.dark{color-scheme:dark;--c-paper:#000;}");
|
||||
expect(doc).toContain("<body><svg></svg><script>");
|
||||
expect(doc).toContain("cobble:graphic-size");
|
||||
});
|
||||
|
||||
test("styles scrollbars in the theme's colours", () => {
|
||||
const doc = graphicDocument({ html: "<p>x</p>", title: "T", vars });
|
||||
|
||||
expect(doc).toContain("::-webkit-scrollbar-thumb{background-color:var(--c-border-strong);");
|
||||
expect(doc).toContain("scrollbar-color:var(--c-border-strong) transparent;");
|
||||
});
|
||||
|
||||
test("puts the theme ahead of a whole document's own styles", () => {
|
||||
const doc = graphicDocument({
|
||||
html: `<!doctype html><html lang="en"><head><style>body{color:red}</style></head><body><p>Hi</p></body></html>`,
|
||||
title: "T",
|
||||
dark: true,
|
||||
vars,
|
||||
});
|
||||
|
||||
expect(doc.indexOf("--c-paper")).toBeLessThan(doc.indexOf("body{color:red}"));
|
||||
expect(doc).toContain(`classList.toggle("dark", true)`);
|
||||
expect(doc.indexOf("cobble:graphic-size")).toBeLessThan(doc.lastIndexOf("</body>"));
|
||||
expect(doc.match(/<!doctype/gi)?.length).toBe(1);
|
||||
});
|
||||
|
||||
test("gives a document without a head one", () => {
|
||||
const doc = graphicDocument({ html: `<html><body>x</body></html>`, title: "T", vars });
|
||||
|
||||
expect(doc).toContain(`<html><head><meta charset="utf-8">`);
|
||||
});
|
||||
|
||||
test("drops a refresh that would navigate the frame away", () => {
|
||||
const doc = graphicDocument({
|
||||
html: `<head><meta http-equiv="refresh" content="0;url=https://evil.test/?d=1"></head>`,
|
||||
title: "T",
|
||||
vars,
|
||||
});
|
||||
|
||||
expect(doc).not.toContain("evil.test");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GraphicSizeMessageType } from "@/src/dict/graphics-dict";
|
||||
import type { ThemeVars } from "./parse-theme-vars";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The graphic as the bot wrote it: a whole document or a fragment.
|
||||
*/
|
||||
html: string;
|
||||
title: string;
|
||||
dark?: boolean;
|
||||
vars: ThemeVars;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot graphic as it's served: the app's theme colours and fonts set
|
||||
* ahead of the bot's own styles (as `--c-paper`, `--c-ink`, ... and
|
||||
* `--font-sans`), `dark` on the root in the dark theme, and a script that
|
||||
* tells the thread how tall it is. A fragment is wrapped into a document;
|
||||
* a refresh tag (which would navigate the frame away) is dropped.
|
||||
*/
|
||||
export default function graphicDocument({ html, title, dark, vars }: Params) {
|
||||
const head = `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeText(title)}</title><style>${themeCss({ vars })}</style><script>document.documentElement.classList.toggle("dark", ${dark ? "true" : "false"});</script>`;
|
||||
const tail = `<script>${SizeScript}</script>`;
|
||||
const body = html.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?refresh[^>]*>/gi, "");
|
||||
|
||||
const with_head = /<head\b[^>]*>/i.test(body)
|
||||
? body.replace(/<head\b[^>]*>/i, (tag) => `${tag}${head}`)
|
||||
: /<html\b[^>]*>/i.test(body)
|
||||
? body.replace(/<html\b[^>]*>/i, (tag) => `${tag}<head>${head}</head>`)
|
||||
: `<!DOCTYPE html><html><head>${head}</head><body>${body}</body></html>`;
|
||||
|
||||
const last_body_end = with_head.toLowerCase().lastIndexOf("</body>");
|
||||
|
||||
const document =
|
||||
last_body_end >= 0
|
||||
? `${with_head.slice(0, last_body_end)}${tail}${with_head.slice(last_body_end)}`
|
||||
: `${with_head}${tail}`;
|
||||
|
||||
return /^\s*<!doctype/i.test(document) ? document : `<!DOCTYPE html>${document}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the body's height to the page framing it whenever it changes.
|
||||
*/
|
||||
const SizeScript = `(function(){var last=0;function post(){var b=document.body;if(!b)return;var h=Math.ceil(b.scrollHeight);if(h==last)return;last=h;parent.postMessage({type:${JSON.stringify(GraphicSizeMessageType)},height:h},"*");}if(window.ResizeObserver&&document.body){new ResizeObserver(post).observe(document.body);}addEventListener("load",post);post();})();`;
|
||||
|
||||
/**
|
||||
* Thin scrollbars in the theme's colours, for the graphic and anything in
|
||||
* it that scrolls: a rounded thumb inset in a clear track where the
|
||||
* browser takes `::-webkit-scrollbar` (Chrome, Safari), the standard
|
||||
* properties elsewhere (Firefox), since Chrome drops the rounded thumb
|
||||
* once those are set.
|
||||
*/
|
||||
const ScrollbarCss = [
|
||||
`::-webkit-scrollbar{width:10px;height:10px;}`,
|
||||
`::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent;}`,
|
||||
`::-webkit-scrollbar-thumb{background-color:var(--c-border-strong);border:3px solid transparent;border-radius:999px;background-clip:padding-box;}`,
|
||||
`::-webkit-scrollbar-thumb:hover{background-color:var(--c-ink-secondary);}`,
|
||||
`@supports not selector(::-webkit-scrollbar){*{scrollbar-width:thin;scrollbar-color:var(--c-border-strong) transparent;}}`,
|
||||
].join("");
|
||||
|
||||
function themeCss({ vars }: { vars: ThemeVars }) {
|
||||
const list = (record: Record<string, string>) =>
|
||||
Object.entries(record)
|
||||
.map(([name, value]) => `${name}:${value};`)
|
||||
.join("");
|
||||
|
||||
return [
|
||||
`:root{color-scheme:light;${list(vars.light)}${list(vars.fonts)}}`,
|
||||
`:root.dark{color-scheme:dark;${list(vars.dark)}}`,
|
||||
`html,body{margin:0;}`,
|
||||
`body{padding:16px;background:var(--c-paper);color:var(--c-ink);font-family:var(--font-sans);font-size:14px;line-height:1.5;}`,
|
||||
ScrollbarCss,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function escapeText(text: string) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import graphicHeaders from "./graphic-headers";
|
||||
|
||||
describe("graphicHeaders", () => {
|
||||
const csp = graphicHeaders({ origin: "https://app.test" })["Content-Security-Policy"]!;
|
||||
const directives = csp.split("; ");
|
||||
|
||||
test("sandboxes scripts away from the app's origin", () => {
|
||||
expect(directives).toContain("sandbox allow-scripts");
|
||||
expect(csp).not.toContain("allow-same-origin");
|
||||
expect(csp).not.toContain("allow-top-navigation");
|
||||
expect(csp).not.toContain("allow-popups");
|
||||
});
|
||||
|
||||
test("allows no network beyond the app's graphic libraries", () => {
|
||||
expect(directives).toContain("default-src 'none'");
|
||||
expect(directives).toContain("connect-src 'none'");
|
||||
expect(directives).toContain("script-src 'unsafe-inline' https://app.test/graphics-lib/");
|
||||
expect(directives).toContain("img-src data: blob:");
|
||||
});
|
||||
|
||||
test("is only framed by the app and never cached", () => {
|
||||
const headers = graphicHeaders({ origin: "https://app.test" });
|
||||
|
||||
expect(directives).toContain("frame-ancestors 'self'");
|
||||
expect(headers["Cache-Control"]).toBe("private, no-store");
|
||||
expect(headers["X-Content-Type-Options"]).toBe("nosniff");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { GraphicsLibPrefix } from "@/src/dict/graphics-dict";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The app's origin as the browser reached it, e.g.
|
||||
* `https://cobble.example.com`: the only place a graphic loads scripts
|
||||
* from, and only its libraries.
|
||||
*/
|
||||
origin: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The headers a bot graphic is served with. Its HTML is a bot's and may
|
||||
* carry what a prompt injection put there, so it gets:
|
||||
*
|
||||
* - `sandbox allow-scripts` without `allow-same-origin`: scripts run (for
|
||||
* charts) but under an origin of its own, so no cookie, storage or API of
|
||||
* the app's is reachable.
|
||||
* - no network (`default-src 'none'`, `connect-src 'none'`): nothing it
|
||||
* holds can be sent anywhere, and only the app's graphic libraries load.
|
||||
* - framed only by the app (`frame-ancestors 'self'`), never cached.
|
||||
*/
|
||||
export default function graphicHeaders({ origin }: Params): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Security-Policy": [
|
||||
"sandbox allow-scripts",
|
||||
"default-src 'none'",
|
||||
`script-src 'unsafe-inline' ${origin}${GraphicsLibPrefix}/`,
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src data: blob:",
|
||||
"font-src data:",
|
||||
"connect-src 'none'",
|
||||
"form-action 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
].join("; "),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import graphicRef, { graphicRefId } from "./graphic-ref";
|
||||
import linkComputerRefs from "./link-computer-refs";
|
||||
|
||||
describe("graphicRef", () => {
|
||||
test("names a graphic", () => {
|
||||
expect(graphicRef({ id: 5 })).toBe("[graphic:5]");
|
||||
});
|
||||
|
||||
test("reads the id back from its link", () => {
|
||||
expect(graphicRefId({ href: "#graphic-5" })).toBe(5);
|
||||
expect(graphicRefId({ href: "#graphic-0" })).toBeNull();
|
||||
expect(graphicRefId({ href: "#graphic-x" })).toBeNull();
|
||||
expect(graphicRefId({ href: "#file-5" })).toBeNull();
|
||||
expect(graphicRefId({ href: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("becomes a link in a message", () => {
|
||||
expect(linkComputerRefs({ text: "Here: [graphic:5]" })).toBe("Here: [graphic 5](#graphic-5)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
type Params = {
|
||||
id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a bot names a graphic it made: `[graphic:5]`. The chat turns it
|
||||
* into a link (`linkComputerRefs`) that renders as the graphic itself,
|
||||
* framed in the thread, with ways to open it full size.
|
||||
*/
|
||||
export default function graphicRef({ id }: Params) {
|
||||
return `[graphic:${id}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link target a graphic ref becomes, e.g. `#graphic-5`.
|
||||
*/
|
||||
export const GraphicRefHrefPrefix = "#graphic-";
|
||||
|
||||
/**
|
||||
* The id in a graphic ref's link target, or null.
|
||||
*/
|
||||
export function graphicRefId({ href }: { href?: string | null }) {
|
||||
if (!href?.startsWith(GraphicRefHrefPrefix)) return null;
|
||||
|
||||
const id = Number(href.slice(GraphicRefHrefPrefix.length));
|
||||
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GraphicsPrefix } from "@/src/dict/graphics-dict";
|
||||
|
||||
type Params = {
|
||||
graphic_id: number;
|
||||
dark?: boolean;
|
||||
/**
|
||||
* The version shown, so a changed graphic loads afresh.
|
||||
*/
|
||||
version?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a graphic's sandboxed document is served, in a theme.
|
||||
*/
|
||||
export default function graphicUrl({ graphic_id, dark, version }: Params) {
|
||||
const query = new URLSearchParams({ theme: dark ? "dark" : "light" });
|
||||
|
||||
if (version) query.set("v", String(version));
|
||||
|
||||
return `${GraphicsPrefix}/${graphic_id}?${query.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app page that shows a graphic full size, beside the rail.
|
||||
*/
|
||||
export function graphicPageHref({ graphic_id, bot_id }: { graphic_id: number; bot_id: number }) {
|
||||
return `/admin/bots/${bot_id}/graphics/${graphic_id}`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GraphicRefHrefPrefix } from "./graphic-ref";
|
||||
|
||||
/**
|
||||
* The link target a computer ref (`computerRef`) becomes, e.g.
|
||||
* `#computer-3`. The chat's markdown renders these links as buttons that
|
||||
@@ -70,7 +72,8 @@ export const SecretRequestRefHrefPrefix = "#secret-request-";
|
||||
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
||||
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
||||
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
|
||||
* request ref (`[secret-request:4]`) in a message into a markdown link, so it
|
||||
* request ref (`[secret-request:4]`) and graphic ref (`[graphic:5]`) in a
|
||||
* message into a markdown link, so it
|
||||
* survives markdown rendering as a link the chat can swap for a button,
|
||||
* chip or download.
|
||||
*
|
||||
@@ -106,6 +109,7 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
||||
/\[secret-request:(\d+)\](?!\()/g,
|
||||
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
|
||||
)
|
||||
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
|
||||
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
||||
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
||||
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Paths the loading page never stands in for: the app's code, live data,
|
||||
* media, proxied computers and the files the loading page itself needs.
|
||||
* media, proxied computers, bot graphics and the files the loading page
|
||||
* itself needs.
|
||||
*/
|
||||
const OpenPrefixes = ["/.bunext/", "/api/", "/ws", "/media", "/computers/"];
|
||||
const OpenPrefixes = ["/.bunext/", "/api/", "/ws", "/media", "/computers/", "/graphics/", "/graphics-lib/"];
|
||||
|
||||
type Params = {
|
||||
method: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import parseThemeVars from "./parse-theme-vars";
|
||||
|
||||
describe("parseThemeVars", () => {
|
||||
test("reads both schemes and the fonts", () => {
|
||||
const css = `:root {\n --c-paper: #fff;\n --m-fast: 1ms;\n}\n\n.dark {\n --c-paper: #000;\n}\n\n@theme inline {\n --font-sans:\n "A", sans-serif;\n --color-ink: var(--c-ink);\n}\n`;
|
||||
|
||||
expect(parseThemeVars({ css })).toEqual({
|
||||
light: { "--c-paper": "#fff" },
|
||||
dark: { "--c-paper": "#000" },
|
||||
fonts: { "--font-sans": `"A", sans-serif` },
|
||||
});
|
||||
});
|
||||
|
||||
test("finds the app's own tokens", () => {
|
||||
const css = fs.readFileSync(path.join(process.cwd(), "src/styles/main.css"), "utf-8");
|
||||
const vars = parseThemeVars({ css });
|
||||
|
||||
for (const name of ["--c-paper", "--c-ink", "--c-border"]) {
|
||||
expect(vars.light[name]).toBeTruthy();
|
||||
expect(vars.dark[name]).toBeTruthy();
|
||||
expect(vars.light[name]).not.toBe(vars.dark[name]);
|
||||
}
|
||||
|
||||
expect(vars.fonts["--font-sans"]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ThemeVars = {
|
||||
/**
|
||||
* The `:root` block's `--c-*` colours, keyed with their `--` names.
|
||||
*/
|
||||
light: Record<string, string>;
|
||||
/**
|
||||
* The `.dark` block's `--c-*` colours.
|
||||
*/
|
||||
dark: Record<string, string>;
|
||||
/**
|
||||
* `--font-sans` and `--font-mono` from the `@theme inline` block.
|
||||
*/
|
||||
fonts: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The app's neutral colours (light and dark) and font stacks from
|
||||
* `src/styles/main.css`, for documents served outside the app's own
|
||||
* styles (a bot graphic), so they match the theme without a copy of it.
|
||||
*/
|
||||
export default function parseThemeVars({ css }: { css: string }): ThemeVars {
|
||||
return {
|
||||
light: blockVars({ block: css.match(/(?:^|\n):root\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--c-" }),
|
||||
dark: blockVars({ block: css.match(/(?:^|\n)\.dark\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--c-" }),
|
||||
fonts: blockVars({ block: css.match(/@theme\s+inline\s*\{([\s\S]*?)\n\}/)?.[1], prefix: "--font-" }),
|
||||
};
|
||||
}
|
||||
|
||||
function blockVars({ block, prefix }: { block?: string; prefix: string }) {
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
if (!block) return vars;
|
||||
|
||||
for (const match of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/g)) {
|
||||
const [, name, value] = match;
|
||||
|
||||
if (name?.startsWith(prefix) && value) vars[name] = value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
Reference in New Issue
Block a user