refactor: move bakeStyles from view-level to per-URL setting
This commit is contained in:
+61
-45
@@ -26,6 +26,7 @@ interface URLItem {
|
||||
url: string;
|
||||
durationSec: number;
|
||||
forceDarkMode?: boolean;
|
||||
bakeStyles?: boolean;
|
||||
}
|
||||
|
||||
interface View {
|
||||
@@ -37,7 +38,6 @@ interface View {
|
||||
cacheTtlSec: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
bakeStyles: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ interface ViewRow {
|
||||
cache_ttl_sec: number;
|
||||
viewport_width: number;
|
||||
viewport_height: number;
|
||||
bake_styles: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -62,7 +61,6 @@ interface CreateViewBody {
|
||||
cacheTtlSec?: number;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
bakeStyles?: boolean;
|
||||
}
|
||||
type UpdateViewBody = Partial<CreateViewBody>;
|
||||
|
||||
@@ -141,7 +139,6 @@ function rowToView(row: ViewRow): View {
|
||||
cacheTtlSec: row.cache_ttl_sec ?? 60,
|
||||
viewportWidth: row.viewport_width ?? 1920,
|
||||
viewportHeight: row.viewport_height ?? 1080,
|
||||
bakeStyles: row.bake_styles === 1,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
@@ -159,8 +156,8 @@ function dbLoad(id: string): View | null {
|
||||
|
||||
function dbInsert(v: View): void {
|
||||
db.run(
|
||||
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,bake_styles,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
v.id,
|
||||
v.name,
|
||||
@@ -170,7 +167,6 @@ function dbInsert(v: View): void {
|
||||
v.cacheTtlSec,
|
||||
v.viewportWidth,
|
||||
v.viewportHeight,
|
||||
v.bakeStyles ? 1 : 0,
|
||||
v.createdAt,
|
||||
],
|
||||
);
|
||||
@@ -178,7 +174,7 @@ function dbInsert(v: View): void {
|
||||
|
||||
function dbUpdate(v: View): void {
|
||||
db.run(
|
||||
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=?,bake_styles=? WHERE id=?",
|
||||
"UPDATE views SET name=?,urls=?,method=?,meta_refresh_enabled=?,cache_ttl_sec=?,viewport_width=?,viewport_height=? WHERE id=?",
|
||||
[
|
||||
v.name,
|
||||
JSON.stringify(v.urls),
|
||||
@@ -187,7 +183,6 @@ function dbUpdate(v: View): void {
|
||||
v.cacheTtlSec,
|
||||
v.viewportWidth,
|
||||
v.viewportHeight,
|
||||
v.bakeStyles ? 1 : 0,
|
||||
v.id,
|
||||
],
|
||||
);
|
||||
@@ -521,9 +516,11 @@ function drainRenderQueue(): void {
|
||||
inFlightRenders.set(next.key, { promise: renderPromise, urlIndex: next.urlIndex });
|
||||
renderPromise.then(
|
||||
(body) => {
|
||||
inFlightRenders.delete(next.key);
|
||||
next.resolve(body);
|
||||
},
|
||||
(err) => {
|
||||
inFlightRenders.delete(next.key);
|
||||
next.reject(err);
|
||||
},
|
||||
);
|
||||
@@ -533,7 +530,7 @@ function drainRenderQueue(): void {
|
||||
async function renderViewToCache(view: View, urlIndex: number, key: string): Promise<string> {
|
||||
const urlItem = view.urls[urlIndex];
|
||||
const forceDarkMode = urlItem.forceDarkMode ?? false;
|
||||
const bakeStyles = view.bakeStyles ?? false;
|
||||
const bakeStyles = urlItem.bakeStyles ?? false;
|
||||
|
||||
let body: string;
|
||||
if (view.method === "mjpeg") {
|
||||
@@ -620,7 +617,7 @@ function cacheStats(): Array<{
|
||||
}> = [];
|
||||
|
||||
for (const [key, entry] of renderCache) {
|
||||
const [viewId, idxStr] = key.split(":");
|
||||
const [viewId] = key.split(":");
|
||||
const view = viewCacheGet(viewId);
|
||||
result.push({
|
||||
key,
|
||||
@@ -729,15 +726,11 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
|
||||
/**
|
||||
* Bake computed styles into inline style attributes.
|
||||
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
|
||||
* Only bakes styles that differ from the element's default computed styles.
|
||||
* Compares each element against a same-tagname fresh element so tag-specific defaults
|
||||
* (e.g. block display on div, inline on span, bold on h1) are accounted for.
|
||||
*/
|
||||
async function bakeComputedStyles(page: Page): Promise<void> {
|
||||
await page.evaluate((): void => {
|
||||
// Create a dummy element to get default styles for comparison
|
||||
const dummy = document.createElement("div");
|
||||
dummy.style.cssText = "position:absolute;visibility:hidden;";
|
||||
document.body.appendChild(dummy);
|
||||
|
||||
const computedProperties = [
|
||||
// Layout
|
||||
"display", "position", "top", "right", "bottom", "left",
|
||||
@@ -749,64 +742,86 @@ async function bakeComputedStyles(page: Page): Promise<void> {
|
||||
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
|
||||
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
|
||||
// Box model
|
||||
"box-sizing", "border", "border-radius", "border-top", "border-right", "border-bottom", "border-left",
|
||||
"border-width", "border-style", "border-color",
|
||||
"box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
|
||||
"border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
|
||||
"border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
|
||||
"border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
|
||||
// Colors & Background
|
||||
"color", "background", "background-color", "background-image", "background-size", "background-position",
|
||||
"background-repeat", "background-attachment",
|
||||
"color", "background-color", "background-image", "background-size", "background-position",
|
||||
"background-repeat", "background-attachment", "background-clip", "background-origin",
|
||||
// Typography
|
||||
"font", "font-family", "font-size", "font-weight", "font-style", "font-variant",
|
||||
"line-height", "text-align", "text-decoration", "text-transform", "letter-spacing", "word-spacing",
|
||||
"font-family", "font-size", "font-weight", "font-style", "font-variant",
|
||||
"line-height", "text-align", "text-decoration-line", "text-decoration-color",
|
||||
"text-transform", "letter-spacing", "word-spacing",
|
||||
"white-space", "overflow-wrap", "word-break",
|
||||
// Visual
|
||||
"opacity", "visibility", "z-index", "overflow", "overflow-x", "overflow-y",
|
||||
"opacity", "visibility", "z-index", "overflow-x", "overflow-y",
|
||||
"box-shadow", "text-shadow", "transform", "transform-origin",
|
||||
// List/Table
|
||||
"list-style", "border-collapse", "border-spacing",
|
||||
"list-style-type", "list-style-position", "border-collapse", "border-spacing",
|
||||
// Cursor/pointer
|
||||
"cursor", "pointer-events",
|
||||
];
|
||||
|
||||
function getDefaultValue(prop: string): string {
|
||||
return window.getComputedStyle(dummy).getPropertyValue(prop);
|
||||
// Cache of default computed values keyed by tagName
|
||||
const defaultCache = new Map<string, Map<string, string>>();
|
||||
const hiddenContainer = document.createElement("div");
|
||||
hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
|
||||
document.body.appendChild(hiddenContainer);
|
||||
|
||||
function getTagDefaults(tag: string): Map<string, string> {
|
||||
let cache = defaultCache.get(tag);
|
||||
if (cache) return cache;
|
||||
cache = new Map<string, string>();
|
||||
try {
|
||||
const el = document.createElement(tag);
|
||||
hiddenContainer.appendChild(el);
|
||||
const cs = window.getComputedStyle(el);
|
||||
for (const prop of computedProperties) {
|
||||
cache.set(prop, cs.getPropertyValue(prop));
|
||||
}
|
||||
el.remove();
|
||||
} catch { /* unknown tag, cache will be empty */ }
|
||||
defaultCache.set(tag, cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
function shouldBake(prop: string, value: string): boolean {
|
||||
if (!value || value === "none" || value === "auto" || value === "normal") return false;
|
||||
if (value === "0px" || value === "0" || value === "rgba(0, 0, 0, 0)") return false;
|
||||
if (value.includes("initial") || value.includes("inherit")) return false;
|
||||
return value !== getDefaultValue(prop);
|
||||
function shouldBake(value: string, defaultValue: string): boolean {
|
||||
if (!value) return false;
|
||||
// Skip browser keywords that cannot be serialized as inline style values
|
||||
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false;
|
||||
return value !== defaultValue;
|
||||
}
|
||||
|
||||
// Walk all elements and bake computed styles
|
||||
const elements = document.querySelectorAll<HTMLElement>("*");
|
||||
for (const el of elements) {
|
||||
// Skip script, style, meta, link tags
|
||||
if (el === hiddenContainer) continue;
|
||||
// Skip non-visual tags
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "meta", "link", "noscript", "template"].includes(tag)) continue;
|
||||
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue;
|
||||
|
||||
const computed = window.getComputedStyle(el);
|
||||
const defaults = getTagDefaults(tag);
|
||||
const stylesToBake: string[] = [];
|
||||
|
||||
for (const prop of computedProperties) {
|
||||
const value = computed.getPropertyValue(prop);
|
||||
if (shouldBake(prop, value)) {
|
||||
const defaultValue = defaults.get(prop) ?? "";
|
||||
if (shouldBake(value, defaultValue)) {
|
||||
stylesToBake.push(`${prop}:${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stylesToBake.length > 0) {
|
||||
// Merge with existing inline styles, preserving user-set inline styles
|
||||
// Existing inline styles take precedence — put them after baked styles
|
||||
const existingInline = el.getAttribute("style") || "";
|
||||
const bakedStyles = stylesToBake.join(";");
|
||||
if (existingInline) {
|
||||
// User inline styles take precedence - put them last
|
||||
el.setAttribute("style", `${bakedStyles};${existingInline}`);
|
||||
} else {
|
||||
el.setAttribute("style", bakedStyles);
|
||||
}
|
||||
el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
|
||||
}
|
||||
}
|
||||
|
||||
dummy.remove();
|
||||
hiddenContainer.remove();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1169,13 +1184,13 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
})),
|
||||
method: body.method,
|
||||
metaRefreshEnabled: body.metaRefreshEnabled ?? false,
|
||||
cacheTtlSec: Math.max(0, body.cacheTtlSec ?? 60),
|
||||
viewportWidth: Math.max(320, Math.min(7680, body.viewportWidth ?? 1920)),
|
||||
viewportHeight: Math.max(240, Math.min(4320, body.viewportHeight ?? 1080)),
|
||||
bakeStyles: body.bakeStyles ?? false,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
@@ -1212,6 +1227,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
}));
|
||||
}
|
||||
if (body.method !== undefined) {
|
||||
@@ -1226,7 +1242,6 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
view.viewportWidth = Math.max(320, Math.min(7680, body.viewportWidth));
|
||||
if (body.viewportHeight !== undefined)
|
||||
view.viewportHeight = Math.max(240, Math.min(4320, body.viewportHeight));
|
||||
if (body.bakeStyles !== undefined) view.bakeStyles = body.bakeStyles;
|
||||
|
||||
dbUpdate(view);
|
||||
cacheInvalidate(viewId);
|
||||
@@ -1258,6 +1273,7 @@ const STATIC_DIR = new URL("../public", import.meta.url).pathname;
|
||||
|
||||
async function serveStatic(path: string): Promise<Response | null> {
|
||||
const filePath = path === "/" ? "/index.html" : path;
|
||||
if (!filePath.startsWith("/") || filePath.includes("..")) return null;
|
||||
try {
|
||||
const file = Bun.file(STATIC_DIR + filePath);
|
||||
await file.slice(0, 0).text();
|
||||
|
||||
Reference in New Issue
Block a user