diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index 1e8b538a665ae..241c89546c228 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentThemeFile, ServerSettingsError, + THEME_CSS_MAX_LENGTH, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -20,6 +21,12 @@ import { ServerSettingsService } from "./serverSettings.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); +/** Writes non-ASCII characters as `\uXXXX` escapes, as encoders such as Python's json.dumps do. */ +const encodeThemeFileEscaped = (theme: EnvironmentThemeFile) => + encodeThemeFile(theme).replace( + /[\u0080-\uffff]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); const NIGHTFALL_THEME: EnvironmentThemeFile = { name: "Nightfall", @@ -190,7 +197,10 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ); it.effect("does not replace a theme file it could not put back", () => { - const oversized = JSON.stringify({ ...NIGHTFALL_THEME, padding: "x".repeat(40_000) }); + const oversized = JSON.stringify({ + ...NIGHTFALL_THEME, + padding: "x".repeat(EnvironmentTheme.MAX_THEME_FILE_BYTES), + }); return withEnvironmentThemes( { [`${REFUSED_THEME_ID}.json`]: oversized }, Effect.gen(function* () { @@ -298,14 +308,14 @@ it.layer(NodeServices.layer)("environment theme", (it) => { const large = (name: string): EnvironmentThemeFile => ({ ...NIGHTFALL_THEME, name, - css: `/* ${"x".repeat(19_900)} */`, + css: `/* ${"界".repeat(THEME_CSS_MAX_LENGTH - 6)} */`, }); return withEnvironmentThemes( { ...Object.fromEntries( - Array.from({ length: 9 }, (_, index) => [ + Array.from({ length: 6 }, (_, index) => [ `aa-${index}.json`, - encodeThemeFile(large(`Large ${index}`)), + encodeThemeFileEscaped(large(`Large ${index}`)), ]), ), "zz-look.json": encodeThemeFile(NIGHTFALL_THEME), @@ -341,7 +351,10 @@ it.layer(NodeServices.layer)("environment theme", (it) => { withEnvironmentThemes( { ...Object.fromEntries( - Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]), + Array.from({ length: 7 }, (_, index) => [ + `junk-${index}.json`, + "{".repeat(EnvironmentTheme.MAX_THEME_FILE_BYTES), + ]), ), "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME), }, diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index b658870f2e960..1e7f4d3052301 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -9,6 +9,7 @@ import { EnvironmentThemeFile, EnvironmentThemeId, environmentThemeFileHasColors, + THEME_CSS_MAX_LENGTH, } from "@t3tools/contracts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { themeCssLoadsRemoteFiles } from "@t3tools/shared/themeCss"; @@ -44,9 +45,12 @@ const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); const THEME_FILE_SUFFIX = ".json"; const MAX_THEME_FILES = 32; -/** Shared with publishing so it cannot accept files the watcher skips. */ -export const MAX_THEME_FILE_BYTES = 32 * 1024; -const MAX_THEME_TOTAL_BYTES = 192 * 1024; +/** + * Shared with publishing so it cannot accept files the watcher skips. Fits the longest CSS in any + * JSON encoding, at up to 6 bytes per character for a `\uXXXX` escape, plus the colors. + */ +export const MAX_THEME_FILE_BYTES = 6 * THEME_CSS_MAX_LENGTH + 16 * 1024; +const MAX_THEME_TOTAL_BYTES = 6 * MAX_THEME_FILE_BYTES; interface PublishedThemes { readonly seq: number; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 88fa0220fe920..e224471c19607 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -622,7 +622,7 @@ const EnvironmentThemeColors = Schema.Record( TrimmedNonEmptyString.check(Schema.isMaxLength(64)), ); -export const THEME_CSS_MAX_LENGTH = 20_000; +export const THEME_CSS_MAX_LENGTH = 50_000; const environmentThemeFields = { version: Schema.optional(Schema.Literal(1)), diff --git a/packages/shared/src/themeCss.ts b/packages/shared/src/themeCss.ts index 48b1259d0377b..ee933f48a171e 100644 --- a/packages/shared/src/themeCss.ts +++ b/packages/shared/src/themeCss.ts @@ -67,7 +67,7 @@ export const THEME_GUIDE = [ "", "CSS: `css` applies after the app's styles. Use `.dark` or `:root:not(.dark)` for one appearance.", "It cannot load files: no @import, no image-set(), and url() only with data: or #id.", - "A background image must be embedded as a data: address. `css` holds 20,000 characters, so keep the image near 10 KB.", + "A background image must be embedded as a data: address. `css` holds 50,000 characters, so keep the image near 30 KB.", "The app's own rules can be more specific. If a rule has no effect, add !important.", "It cannot reach the terminal text, the file tree, or diff internals. Use color roles for those.", "Native scrollbars take `::-webkit-scrollbar` and `::-webkit-scrollbar-thumb`.",