critique 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
1
+ # 0.1.9
2
+
3
+ - Performance:
4
+ - Lazy-load themes: Only the default (github) theme is loaded at startup; other themes load on-demand when selected
5
+ - Lazy-load `@parcel/watcher`: Native file watcher module is only loaded when `--watch` flag is used
6
+ - Parallelize `diff` module import with renderer creation
7
+ - Web preview:
8
+ - Generate desktop and mobile HTML versions in parallel
9
+ - Add `--mobile-cols` option (default: 100) for mobile column width
10
+ - Add `--theme` option to specify theme for web preview
11
+ - Worker auto-detects mobile devices via `CF-Device-Type`, `Sec-CH-UA-Mobile`, or User-Agent regex
12
+ - Add `?v=desktop` / `?v=mobile` query params to force a specific version
13
+ - Mobile version uses more rows to accommodate line wrapping
14
+ - Themes:
15
+ - Add `opencode-light` theme - light mode variant of OpenCode theme
16
+ - Change default theme from `github` to `opencode-light`
17
+ - Web preview now uses theme-aware colors (background, text, diff colors)
18
+ - Theme is changeable via state (press `t` in TUI to pick theme)
19
+
20
+ # 0.1.8
21
+
22
+ - Web preview:
23
+ - Enable text wrapping in opentui diff component (`wrapMode="wrap"`)
24
+ - Revert HTML CSS wrapping (caused broken backgrounds on wrapped lines)
25
+ - Remove `www.` from preview URLs
26
+
1
27
  # 0.1.7
2
28
 
3
29
  - Web preview:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.1.7",
5
+ "version": "0.1.9",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
package/src/ansi-html.ts CHANGED
@@ -5,6 +5,8 @@ export interface AnsiToHtmlOptions {
5
5
  rows?: number
6
6
  /** Background color for the container */
7
7
  backgroundColor?: string
8
+ /** Text color for the container */
9
+ textColor?: string
8
10
  /** Font family for the output */
9
11
  fontFamily?: string
10
12
  /** Font size for the output */
@@ -121,7 +123,8 @@ export function ansiToHtml(input: string | Buffer, options: AnsiToHtmlOptions =
121
123
  export function ansiToHtmlDocument(input: string | Buffer, options: AnsiToHtmlOptions = {}): string {
122
124
  const {
123
125
  cols = 500,
124
- backgroundColor = "#0f0f0f",
126
+ backgroundColor = "#ffffff",
127
+ textColor = "#1a1a1a",
125
128
  fontFamily = "Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace",
126
129
  fontSize = "14px",
127
130
  } = options
@@ -150,7 +153,7 @@ html, body {
150
153
  max-width: 100vw;
151
154
  overflow-x: hidden;
152
155
  background-color: ${backgroundColor};
153
- color: #c5c8c6;
156
+ color: ${textColor};
154
157
  font-family: ${fontFamily};
155
158
  font-size: ${fontSize};
156
159
  line-height: 1.6;
@@ -166,16 +169,14 @@ body {
166
169
  box-sizing: border-box;
167
170
  }
168
171
  .line {
169
- white-space: pre-wrap;
170
- word-break: break-all;
172
+ white-space: pre;
171
173
  display: flex;
172
- flex-wrap: wrap;
173
174
  content-visibility: auto;
174
175
  contain-intrinsic-block-size: auto 1lh;
175
176
  background-color: ${backgroundColor};
176
177
  }
177
178
  .line span {
178
- white-space: pre-wrap;
179
+ white-space: pre;
179
180
  }
180
181
  </style>
181
182
  </head>
package/src/cli.tsx CHANGED
@@ -20,13 +20,22 @@ import { tmpdir, homedir } from "os";
20
20
  import { join } from "path";
21
21
  import { create } from "zustand";
22
22
  import Dropdown from "./dropdown.tsx";
23
- import * as watcher from "@parcel/watcher";
24
23
  import { debounce } from "./utils.ts";
24
+
25
+ // Lazy-load watcher only when --watch is used
26
+ let watcherModule: typeof import("@parcel/watcher") | null = null;
27
+ async function getWatcher() {
28
+ if (!watcherModule) {
29
+ watcherModule = await import("@parcel/watcher");
30
+ }
31
+ return watcherModule;
32
+ }
25
33
  import {
26
34
  getSyntaxTheme,
27
35
  getResolvedTheme,
28
36
  themeNames,
29
37
  defaultThemeName,
38
+ rgbaToHex,
30
39
  } from "./themes.ts";
31
40
 
32
41
  // State persistence
@@ -229,7 +238,7 @@ function DiffView({ diff, view, filetype, themeName }: DiffViewProps) {
229
238
  filetype={filetype}
230
239
  syntaxStyle={syntaxStyle}
231
240
  showLineNumbers
232
- wrapMode="none"
241
+ wrapMode="wrap"
233
242
  addedContentBg={resolvedTheme.diffAddedBg}
234
243
  removedContentBg={resolvedTheme.diffRemovedBg}
235
244
  contextContentBg={resolvedTheme.backgroundPanel}
@@ -569,10 +578,20 @@ cli
569
578
  return `git add -N . && git diff --no-prefix ${contextArg}`.trim();
570
579
  })();
571
580
 
572
- const { parsePatch, formatPatch } = await import("diff");
573
-
574
581
  const shouldWatch = options.watch && !base && !head && !options.commit;
575
582
 
583
+ // Parallelize diff module loading with renderer creation
584
+ const [diffModule, renderer] = await Promise.all([
585
+ import("diff"),
586
+ createCliRenderer({
587
+ onDestroy() {
588
+ process.exit(0);
589
+ },
590
+ exitOnCtrlC: true,
591
+ }),
592
+ ]);
593
+ const { parsePatch, formatPatch } = diffModule;
594
+
576
595
  function AppWithWatch() {
577
596
  const [parsedFiles, setParsedFiles] = React.useState<
578
597
  ParsedFile[] | null
@@ -636,6 +655,7 @@ cli
636
655
 
637
656
  fetchDiff();
638
657
 
658
+ // Set up file watching only if --watch flag is used
639
659
  if (!shouldWatch) {
640
660
  return;
641
661
  }
@@ -646,21 +666,26 @@ cli
646
666
  fetchDiff();
647
667
  }, 200);
648
668
 
649
- let subscription: watcher.AsyncSubscription | undefined;
669
+ let subscription:
670
+ | Awaited<ReturnType<typeof import("@parcel/watcher").subscribe>>
671
+ | undefined;
650
672
 
651
- watcher
652
- .subscribe(cwd, (err, events) => {
653
- if (err) {
654
- return;
655
- }
673
+ // Lazy-load watcher module only when watching
674
+ getWatcher().then((watcher) => {
675
+ watcher
676
+ .subscribe(cwd, (err, events) => {
677
+ if (err) {
678
+ return;
679
+ }
656
680
 
657
- if (events.length > 0) {
658
- debouncedFetch();
659
- }
660
- })
661
- .then((sub) => {
662
- subscription = sub;
663
- });
681
+ if (events.length > 0) {
682
+ debouncedFetch();
683
+ }
684
+ })
685
+ .then((sub) => {
686
+ subscription = sub;
687
+ });
688
+ });
664
689
 
665
690
  return () => {
666
691
  if (subscription) {
@@ -704,12 +729,6 @@ cli
704
729
  return <App parsedFiles={parsedFiles} />;
705
730
  }
706
731
 
707
- const renderer = await createCliRenderer({
708
- onDestroy() {
709
- process.exit(0);
710
- },
711
- exitOnCtrlC: true,
712
- });
713
732
  createRoot(renderer).render(
714
733
  React.createElement(
715
734
  ErrorBoundary,
@@ -988,7 +1007,7 @@ cli
988
1007
 
989
1008
  // Worker URL for uploading HTML previews
990
1009
  const WORKER_URL =
991
- process.env.CRITIQUE_WORKER_URL || "https://www.critique.work";
1010
+ process.env.CRITIQUE_WORKER_URL || "https://critique.work";
992
1011
 
993
1012
  cli
994
1013
  .command("web [ref]", "Generate web preview of diff")
@@ -996,13 +1015,18 @@ cli
996
1015
  .option("--commit <ref>", "Show changes from a specific commit")
997
1016
  .option(
998
1017
  "--cols <cols>",
999
- "Number of columns for rendering (use ~100 for mobile)",
1018
+ "Number of columns for desktop rendering",
1000
1019
  { default: 240 },
1001
1020
  )
1002
-
1021
+ .option(
1022
+ "--mobile-cols <cols>",
1023
+ "Number of columns for mobile rendering",
1024
+ { default: 100 },
1025
+ )
1003
1026
  .option("--local", "Save local preview instead of uploading")
1004
1027
  .option("--open", "Open in browser after generating")
1005
1028
  .option("--context <lines>", "Number of context lines (default: 3)")
1029
+ .option("--theme <name>", "Theme to use for rendering")
1006
1030
  .action(async (ref, options) => {
1007
1031
  const pty = await import("@xmorse/bun-pty");
1008
1032
  const { ansiToHtmlDocument } = await import("./ansi-html.ts");
@@ -1017,7 +1041,11 @@ cli
1017
1041
  return `git add -N . && git diff --no-prefix ${contextArg}`.trim();
1018
1042
  })();
1019
1043
 
1020
- const cols = parseInt(options.cols) || 240;
1044
+ const desktopCols = parseInt(options.cols) || 240;
1045
+ const mobileCols = parseInt(options.mobileCols) || 100;
1046
+ const themeName = options.theme && themeNames.includes(options.theme)
1047
+ ? options.theme
1048
+ : defaultThemeName;
1021
1049
 
1022
1050
  console.log("Capturing diff output...");
1023
1051
 
@@ -1034,7 +1062,7 @@ cli
1034
1062
  // Calculate required rows from diff content
1035
1063
  const { parsePatch } = await import("diff");
1036
1064
  const files = parsePatch(gitDiff);
1037
- const renderRows = files.reduce((sum, file) => {
1065
+ const baseRenderRows = files.reduce((sum, file) => {
1038
1066
  const diffLines = file.hunks.reduce((h, hunk) => h + hunk.lines.length, 0);
1039
1067
  return sum + diffLines + 5; // header + margin per file
1040
1068
  }, 100); // base padding
@@ -1043,67 +1071,85 @@ cli
1043
1071
  const diffFile = join(tmpdir(), `critique-web-diff-${Date.now()}.patch`);
1044
1072
  fs.writeFileSync(diffFile, gitDiff);
1045
1073
 
1046
- // Spawn the TUI in a PTY to capture ANSI output
1047
- let ansiOutput = "";
1048
- const ptyProcess = pty.spawn(
1049
- "bun",
1050
- [
1051
- process.argv[1]!, // path to cli.tsx
1052
- "web-render",
1053
- diffFile,
1054
- "--cols",
1055
- String(cols),
1056
- "--rows",
1057
- String(renderRows),
1058
- ],
1059
- {
1060
- name: "xterm-256color",
1061
- cols: cols,
1062
- rows: renderRows,
1063
-
1064
- cwd: process.cwd(),
1065
- env: { ...process.env, TERM: "xterm-256color" } as Record<
1066
- string,
1067
- string
1068
- >,
1069
- },
1070
- );
1074
+ // Helper function to capture PTY output for a given column width
1075
+ async function captureHtml(cols: number, renderRows: number): Promise<string> {
1076
+ let ansiOutput = "";
1077
+ const ptyProcess = pty.spawn(
1078
+ "bun",
1079
+ [
1080
+ process.argv[1]!, // path to cli.tsx
1081
+ "web-render",
1082
+ diffFile,
1083
+ "--cols",
1084
+ String(cols),
1085
+ "--rows",
1086
+ String(renderRows),
1087
+ "--theme",
1088
+ themeName,
1089
+ ],
1090
+ {
1091
+ name: "xterm-256color",
1092
+ cols: cols,
1093
+ rows: renderRows,
1094
+ cwd: process.cwd(),
1095
+ env: { ...process.env, TERM: "xterm-256color" } as Record<
1096
+ string,
1097
+ string
1098
+ >,
1099
+ },
1100
+ );
1071
1101
 
1072
- ptyProcess.onData((data: string) => {
1073
- ansiOutput += data;
1074
- });
1102
+ ptyProcess.onData((data: string) => {
1103
+ ansiOutput += data;
1104
+ });
1075
1105
 
1076
- await new Promise<void>((resolve) => {
1077
- ptyProcess.onExit(() => {
1078
- resolve();
1106
+ await new Promise<void>((resolve) => {
1107
+ ptyProcess.onExit(() => {
1108
+ resolve();
1109
+ });
1079
1110
  });
1080
- });
1081
1111
 
1082
- // Clean up temp file
1083
- fs.unlinkSync(diffFile);
1112
+ if (!ansiOutput.trim()) {
1113
+ throw new Error("No output captured");
1114
+ }
1084
1115
 
1085
- if (!ansiOutput.trim()) {
1086
- console.log("No output captured");
1087
- process.exit(1);
1088
- }
1116
+ // Strip terminal cleanup sequences that clear the screen
1117
+ const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
1118
+ if (clearIdx > 0) {
1119
+ ansiOutput = ansiOutput.slice(0, clearIdx);
1120
+ }
1089
1121
 
1090
- console.log("Converting to HTML...");
1122
+ // Get theme colors for HTML output
1123
+ const theme = getResolvedTheme(themeName);
1124
+ const backgroundColor = rgbaToHex(theme.background);
1125
+ const textColor = rgbaToHex(theme.text);
1091
1126
 
1092
- // Strip terminal cleanup sequences that clear the screen
1093
- // The renderer outputs \x1b[H\x1b[J (cursor home + clear to end) on exit
1094
- const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
1095
- if (clearIdx > 0) {
1096
- ansiOutput = ansiOutput.slice(0, clearIdx);
1127
+ return ansiToHtmlDocument(ansiOutput, { cols, rows: renderRows, backgroundColor, textColor });
1097
1128
  }
1098
1129
 
1099
- // Convert ANSI to HTML document
1100
- const html = ansiToHtmlDocument(ansiOutput, { cols, rows: renderRows });
1130
+ // Generate desktop and mobile versions in parallel
1131
+ // Mobile needs more rows since lines wrap more with fewer columns
1132
+ const mobileRenderRows = Math.ceil(baseRenderRows * (desktopCols / mobileCols));
1133
+ console.log("Generating desktop and mobile versions in parallel...");
1134
+ const [htmlDesktop, htmlMobile] = await Promise.all([
1135
+ captureHtml(desktopCols, baseRenderRows),
1136
+ captureHtml(mobileCols, mobileRenderRows),
1137
+ ]);
1138
+
1139
+ // Clean up temp file
1140
+ fs.unlinkSync(diffFile);
1141
+
1142
+ console.log("Converting to HTML...");
1101
1143
 
1102
1144
  if (options.local) {
1103
1145
  // Save locally
1104
- const htmlFile = join(tmpdir(), `critique-${Date.now()}.html`);
1105
- fs.writeFileSync(htmlFile, html);
1106
- console.log(`Saved to: ${htmlFile}`);
1146
+ const timestamp = Date.now();
1147
+ const htmlFileDesktop = join(tmpdir(), `critique-${timestamp}-desktop.html`);
1148
+ const htmlFileMobile = join(tmpdir(), `critique-${timestamp}-mobile.html`);
1149
+ fs.writeFileSync(htmlFileDesktop, htmlDesktop);
1150
+ fs.writeFileSync(htmlFileMobile, htmlMobile);
1151
+ console.log(`Saved desktop to: ${htmlFileDesktop}`);
1152
+ console.log(`Saved mobile to: ${htmlFileMobile}`);
1107
1153
 
1108
1154
  // Open in browser if requested
1109
1155
  if (options.open) {
@@ -1114,7 +1160,7 @@ cli
1114
1160
  ? "start"
1115
1161
  : "xdg-open";
1116
1162
  try {
1117
- await execAsync(`${openCmd} "${htmlFile}"`);
1163
+ await execAsync(`${openCmd} "${htmlFileDesktop}"`);
1118
1164
  } catch {
1119
1165
  console.log("Could not open browser automatically");
1120
1166
  }
@@ -1130,7 +1176,7 @@ cli
1130
1176
  headers: {
1131
1177
  "Content-Type": "application/json",
1132
1178
  },
1133
- body: JSON.stringify({ html }),
1179
+ body: JSON.stringify({ html: htmlDesktop, htmlMobile }),
1134
1180
  });
1135
1181
 
1136
1182
  if (!response.ok) {
@@ -1162,7 +1208,7 @@ cli
1162
1208
 
1163
1209
  // Fallback to local file
1164
1210
  const htmlFile = join(tmpdir(), `critique-${Date.now()}.html`);
1165
- fs.writeFileSync(htmlFile, html);
1211
+ fs.writeFileSync(htmlFile, htmlDesktop);
1166
1212
  console.log(`\nFallback: Saved locally to ${htmlFile}`);
1167
1213
  process.exit(1);
1168
1214
  }
@@ -1175,9 +1221,13 @@ cli
1175
1221
  })
1176
1222
  .option("--cols <cols>", "Terminal columns", { default: 120 })
1177
1223
  .option("--rows <rows>", "Terminal rows", { default: 1000 })
1224
+ .option("--theme <name>", "Theme to use for rendering")
1178
1225
  .action(async (diffFile: string, options) => {
1179
1226
  const cols = parseInt(options.cols) || 120;
1180
1227
  const rows = parseInt(options.rows) || 1000;
1228
+ const themeName = options.theme && themeNames.includes(options.theme)
1229
+ ? options.theme
1230
+ : defaultThemeName;
1181
1231
 
1182
1232
  const { parsePatch, formatPatch } = await import("diff");
1183
1233
 
@@ -1246,7 +1296,11 @@ cli
1246
1296
  const useSplitView = cols >= 150;
1247
1297
 
1248
1298
  // Static component - no hooks that cause re-renders
1249
- const webBg = getResolvedTheme(defaultThemeName).background;
1299
+ const webTheme = getResolvedTheme(themeName);
1300
+ const webBg = webTheme.background;
1301
+ const webText = rgbaToHex(webTheme.text);
1302
+ const webAddedColor = rgbaToHex(webTheme.diffAddedBg);
1303
+ const webRemovedColor = rgbaToHex(webTheme.diffRemovedBg);
1250
1304
  function WebApp() {
1251
1305
  return (
1252
1306
  <box
@@ -1289,15 +1343,15 @@ cli
1289
1343
  alignItems: "center",
1290
1344
  }}
1291
1345
  >
1292
- <text>{fileName.trim()}</text>
1293
- <text fg="#00ff00"> +{additions}</text>
1294
- <text fg="#ff0000">-{deletions}</text>
1346
+ <text fg={webText}>{fileName.trim()}</text>
1347
+ <text fg="#2d8a47"> +{additions}</text>
1348
+ <text fg="#c53b53">-{deletions}</text>
1295
1349
  </box>
1296
1350
  <DiffView
1297
1351
  diff={file.rawDiff || ""}
1298
1352
  view={viewMode}
1299
1353
  filetype={filetype}
1300
- themeName={defaultThemeName}
1354
+ themeName={themeName}
1301
1355
  />
1302
1356
  </box>
1303
1357
  );
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "bg": "#ffffff",
5
+ "bgPanel": "#ffffff",
6
+ "bgElement": "#f0f3f6",
7
+ "fg": "#24292f",
8
+ "fgMuted": "#57606a",
9
+ "blue": "#0969da",
10
+ "green": "#1a7f37",
11
+ "red": "#cf222e",
12
+ "orange": "#bc4c00",
13
+ "purple": "#8250df",
14
+ "pink": "#bf3989",
15
+ "yellow": "#9a6700",
16
+ "cyan": "#1b7c83"
17
+ },
18
+ "theme": {
19
+ "primary": "blue",
20
+ "secondary": "purple",
21
+ "accent": "cyan",
22
+ "error": "red",
23
+ "warning": "yellow",
24
+ "success": "green",
25
+ "info": "orange",
26
+ "text": "fg",
27
+ "textMuted": "fgMuted",
28
+ "background": "bg",
29
+ "backgroundPanel": "bgPanel",
30
+ "backgroundElement": "bgElement",
31
+ "border": "#d0d7de",
32
+ "borderActive": "blue",
33
+ "borderSubtle": "#d8dee4",
34
+ "diffAdded": "green",
35
+ "diffRemoved": "red",
36
+ "diffContext": "fgMuted",
37
+ "diffHunkHeader": "blue",
38
+ "diffHighlightAdded": "#1a7f37",
39
+ "diffHighlightRemoved": "#cf222e",
40
+ "diffAddedBg": "#e6ffec",
41
+ "diffRemovedBg": "#fff0ee",
42
+ "diffContextBg": "bg",
43
+ "diffLineNumber": "fgMuted",
44
+ "diffAddedLineNumberBg": "#dafbe1",
45
+ "diffRemovedLineNumberBg": "#ffebe9",
46
+ "syntaxComment": "fgMuted",
47
+ "syntaxKeyword": "red",
48
+ "syntaxFunction": "purple",
49
+ "syntaxVariable": "orange",
50
+ "syntaxString": "blue",
51
+ "syntaxNumber": "cyan",
52
+ "syntaxType": "orange",
53
+ "syntaxOperator": "red",
54
+ "syntaxPunctuation": "fg"
55
+ }
56
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "step1": "#ffffff",
5
+ "step2": "#fafafa",
6
+ "step3": "#f5f5f5",
7
+ "step4": "#ebebeb",
8
+ "step5": "#e1e1e1",
9
+ "step6": "#d4d4d4",
10
+ "step7": "#b8b8b8",
11
+ "step8": "#a0a0a0",
12
+ "step9": "#3b7dd8",
13
+ "step10": "#2968c3",
14
+ "step11": "#8a8a8a",
15
+ "step12": "#1a1a1a",
16
+ "secondary": "#7b5bb6",
17
+ "accent": "#d68c27",
18
+ "red": "#d1383d",
19
+ "orange": "#d68c27",
20
+ "green": "#3d9a57",
21
+ "cyan": "#318795",
22
+ "yellow": "#b0851f"
23
+ },
24
+ "theme": {
25
+ "primary": "step9",
26
+ "secondary": "secondary",
27
+ "accent": "accent",
28
+ "error": "red",
29
+ "warning": "orange",
30
+ "success": "green",
31
+ "info": "cyan",
32
+ "text": "step12",
33
+ "textMuted": "step11",
34
+ "background": "step1",
35
+ "backgroundPanel": "step1",
36
+ "backgroundElement": "step3",
37
+ "border": "step6",
38
+ "borderActive": "step7",
39
+ "borderSubtle": "step5",
40
+ "diffAdded": "#1e725c",
41
+ "diffRemoved": "#c53b53",
42
+ "diffContext": "#7086b5",
43
+ "diffHunkHeader": "#7086b5",
44
+ "diffHighlightAdded": "#4db380",
45
+ "diffHighlightRemoved": "#f52a65",
46
+ "diffAddedBg": "#e8f4e8",
47
+ "diffRemovedBg": "#fceced",
48
+ "diffContextBg": "step1",
49
+ "diffLineNumber": "step11",
50
+ "diffAddedLineNumberBg": "#dceadc",
51
+ "diffRemovedLineNumberBg": "#f5e0e1",
52
+ "syntaxComment": "step11",
53
+ "syntaxKeyword": "secondary",
54
+ "syntaxFunction": "step9",
55
+ "syntaxVariable": "red",
56
+ "syntaxString": "green",
57
+ "syntaxNumber": "orange",
58
+ "syntaxType": "yellow",
59
+ "syntaxOperator": "cyan",
60
+ "syntaxPunctuation": "step12"
61
+ }
62
+ }
package/src/themes.ts CHANGED
@@ -3,37 +3,9 @@
3
3
 
4
4
  import { parseColor, RGBA } from "@opentui/core";
5
5
 
6
- import aura from "./themes/aura.json";
7
- import ayu from "./themes/ayu.json";
8
- import catppuccin from "./themes/catppuccin.json";
9
- import catppuccinFrappe from "./themes/catppuccin-frappe.json";
10
- import catppuccinMacchiato from "./themes/catppuccin-macchiato.json";
11
- import cobalt2 from "./themes/cobalt2.json";
12
- import cursor from "./themes/cursor.json";
13
- import dracula from "./themes/dracula.json";
14
- import everforest from "./themes/everforest.json";
15
- import flexoki from "./themes/flexoki.json";
6
+ // Only import the default theme statically for fast startup
7
+ // Other themes are loaded on-demand when selected
16
8
  import github from "./themes/github.json";
17
- import gruvbox from "./themes/gruvbox.json";
18
- import kanagawa from "./themes/kanagawa.json";
19
- import lucentOrng from "./themes/lucent-orng.json";
20
- import material from "./themes/material.json";
21
- import matrix from "./themes/matrix.json";
22
- import mercury from "./themes/mercury.json";
23
- import monokai from "./themes/monokai.json";
24
- import nightowl from "./themes/nightowl.json";
25
- import nord from "./themes/nord.json";
26
- import oneDark from "./themes/one-dark.json";
27
- import opencode from "./themes/opencode.json";
28
- import orng from "./themes/orng.json";
29
- import palenight from "./themes/palenight.json";
30
- import rosepine from "./themes/rosepine.json";
31
- import solarized from "./themes/solarized.json";
32
- import synthwave84 from "./themes/synthwave84.json";
33
- import tokyonight from "./themes/tokyonight.json";
34
- import vercel from "./themes/vercel.json";
35
- import vesper from "./themes/vesper.json";
36
- import zenburn from "./themes/zenburn.json";
37
9
 
38
10
  type HexColor = `#${string}`;
39
11
  type RefName = string;
@@ -104,40 +76,74 @@ export interface SyntaxTheme {
104
76
  default: SyntaxThemeStyle;
105
77
  }
106
78
 
107
- const DEFAULT_THEMES: Record<string, ThemeJson> = {
108
- aura,
109
- ayu,
110
- catppuccin,
111
- "catppuccin-frappe": catppuccinFrappe,
112
- "catppuccin-macchiato": catppuccinMacchiato,
113
- cobalt2,
114
- cursor,
115
- dracula,
116
- everforest,
117
- flexoki,
118
- github,
119
- gruvbox,
120
- kanagawa,
121
- "lucent-orng": lucentOrng,
122
- material,
123
- matrix,
124
- mercury,
125
- monokai,
126
- nightowl,
127
- nord,
128
- "one-dark": oneDark,
129
- opencode,
130
- orng,
131
- palenight,
132
- rosepine,
133
- solarized,
134
- synthwave84,
135
- tokyonight,
136
- vercel,
137
- vesper,
138
- zenburn,
79
+ // Theme name to file mapping for lazy loading
80
+ const THEME_FILES: Record<string, string> = {
81
+ aura: "aura.json",
82
+ ayu: "ayu.json",
83
+ catppuccin: "catppuccin.json",
84
+ "catppuccin-frappe": "catppuccin-frappe.json",
85
+ "catppuccin-macchiato": "catppuccin-macchiato.json",
86
+ cobalt2: "cobalt2.json",
87
+ cursor: "cursor.json",
88
+ dracula: "dracula.json",
89
+ everforest: "everforest.json",
90
+ flexoki: "flexoki.json",
91
+ github: "github.json",
92
+ "github-light": "github-light.json",
93
+ gruvbox: "gruvbox.json",
94
+ kanagawa: "kanagawa.json",
95
+ "lucent-orng": "lucent-orng.json",
96
+ material: "material.json",
97
+ matrix: "matrix.json",
98
+ mercury: "mercury.json",
99
+ monokai: "monokai.json",
100
+ nightowl: "nightowl.json",
101
+ nord: "nord.json",
102
+ "one-dark": "one-dark.json",
103
+ opencode: "opencode.json",
104
+ "opencode-light": "opencode-light.json",
105
+ orng: "orng.json",
106
+ palenight: "palenight.json",
107
+ rosepine: "rosepine.json",
108
+ solarized: "solarized.json",
109
+ synthwave84: "synthwave84.json",
110
+ tokyonight: "tokyonight.json",
111
+ vercel: "vercel.json",
112
+ vesper: "vesper.json",
113
+ zenburn: "zenburn.json",
139
114
  };
140
115
 
116
+ // Cache for loaded themes
117
+ const themeCache: Record<string, ThemeJson> = {
118
+ github, // Pre-loaded default theme
119
+ };
120
+
121
+ // Synchronously load a theme (themes are small JSON files)
122
+ function loadTheme(name: string): ThemeJson {
123
+ if (themeCache[name]) {
124
+ return themeCache[name];
125
+ }
126
+
127
+ const fileName = THEME_FILES[name];
128
+ if (!fileName) {
129
+ return github; // Fallback to default
130
+ }
131
+
132
+ try {
133
+ // Use dynamic import with synchronous pattern for JSON
134
+ // This works because JSON imports are resolved at bundle time by Bun
135
+ const themePath = new URL(`./themes/${fileName}`, import.meta.url).pathname;
136
+ // Read file synchronously using Node fs (works in Bun)
137
+ const fs = require("fs");
138
+ const content = fs.readFileSync(themePath, "utf-8");
139
+ const themeJson = JSON.parse(content) as ThemeJson;
140
+ themeCache[name] = themeJson;
141
+ return themeJson;
142
+ } catch {
143
+ return github; // Fallback to default
144
+ }
145
+ }
146
+
141
147
  function resolveTheme(
142
148
  themeJson: ThemeJson,
143
149
  mode: "dark" | "light",
@@ -198,7 +204,7 @@ export function getResolvedTheme(
198
204
  name: string,
199
205
  mode: "dark" | "light" = "dark",
200
206
  ): ResolvedTheme {
201
- const themeJson = DEFAULT_THEMES[name] ?? DEFAULT_THEMES.github!;
207
+ const themeJson = loadTheme(name);
202
208
  return resolveTheme(themeJson, mode);
203
209
  }
204
210
 
@@ -229,9 +235,9 @@ export function getSyntaxTheme(
229
235
  };
230
236
  }
231
237
 
232
- export const themeNames = Object.keys(DEFAULT_THEMES).sort();
238
+ export const themeNames = Object.keys(THEME_FILES).sort();
233
239
 
234
- export const defaultThemeName = "github";
240
+ export const defaultThemeName = "github-light";
235
241
 
236
242
  // Helper to convert RGBA to hex string
237
243
  export function rgbaToHex(rgba: RGBA): string {
package/src/worker.ts CHANGED
@@ -17,18 +17,47 @@ app.get("/", (c) => {
17
17
  return c.redirect("https://github.com/remorses/critique")
18
18
  })
19
19
 
20
+ // Detect if request is from a mobile device
21
+ function isMobileDevice(c: { req: { header: (name: string) => string | undefined } }): boolean {
22
+ // Check CF-Device-Type header (Cloudflare provides this on Enterprise/APO)
23
+ const cfDeviceType = c.req.header("CF-Device-Type")
24
+ if (cfDeviceType === "mobile" || cfDeviceType === "tablet") {
25
+ return true
26
+ }
27
+ if (cfDeviceType === "desktop") {
28
+ return false
29
+ }
30
+
31
+ // Check Sec-CH-UA-Mobile header (Chromium browsers only)
32
+ const secChUaMobile = c.req.header("Sec-CH-UA-Mobile")
33
+ if (secChUaMobile === "?1") {
34
+ return true
35
+ }
36
+ if (secChUaMobile === "?0") {
37
+ return false
38
+ }
39
+
40
+ // Fallback to User-Agent parsing with comprehensive regex
41
+ const userAgent = c.req.header("User-Agent") || ""
42
+
43
+ // Comprehensive mobile detection regex (case-insensitive)
44
+ const mobileRegex = /Mobile|iP(hone|od|ad)|Android|BlackBerry|IEMobile|Kindle|Silk|NetFront|Opera M(obi|ini)|Windows Phone|webOS|Fennec|Minimo|UCBrowser|UCWEB|SonyEricsson|Symbian|Nintendo|PSP|PlayStation|MIDP|CLDC|AvantGo|Maemo|PalmOS|PalmSource|DoCoMo|UP\.Browser|Blazer|Xiino|OneBrowser/i
45
+
46
+ return mobileRegex.test(userAgent)
47
+ }
48
+
20
49
  // Upload HTML content
21
- // POST /upload with JSON body { html: string }
50
+ // POST /upload with JSON body { html: string, htmlMobile?: string }
22
51
  // Returns { id: string, url: string }
23
52
  app.post("/upload", async (c) => {
24
53
  try {
25
- const body = await c.req.json<{ html: string }>()
54
+ const body = await c.req.json<{ html: string; htmlMobile?: string }>()
26
55
 
27
56
  if (!body.html || typeof body.html !== "string") {
28
57
  return c.json({ error: "Missing or invalid 'html' field" }, 400)
29
58
  }
30
59
 
31
- // Generate hash of the HTML content as the key (enables caching/deduplication)
60
+ // Generate hash of the desktop HTML content as the key (enables caching/deduplication)
32
61
  const encoder = new TextEncoder()
33
62
  const data = encoder.encode(body.html)
34
63
  const hashBuffer = await crypto.subtle.digest("SHA-256", data)
@@ -38,11 +67,18 @@ app.post("/upload", async (c) => {
38
67
  // Use first 32 chars of hash as ID (128 bits, secure against guessing)
39
68
  const id = hashHex.slice(0, 32)
40
69
 
41
- // Store in KV with 7 day expiration
70
+ // Store desktop version in KV with 7 day expiration
42
71
  await c.env.CRITIQUE_KV.put(id, body.html, {
43
72
  expirationTtl: 60 * 60 * 24 * 7, // 7 days
44
73
  })
45
74
 
75
+ // Store mobile version if provided
76
+ if (body.htmlMobile && typeof body.htmlMobile === "string") {
77
+ await c.env.CRITIQUE_KV.put(`${id}-mobile`, body.htmlMobile, {
78
+ expirationTtl: 60 * 60 * 24 * 7, // 7 days
79
+ })
80
+ }
81
+
46
82
  const url = new URL(c.req.url)
47
83
  const viewUrl = `${url.origin}/view/${id}`
48
84
 
@@ -54,6 +90,7 @@ app.post("/upload", async (c) => {
54
90
 
55
91
  // View HTML content with streaming
56
92
  // GET /view/:id
93
+ // Query params: ?v=desktop or ?v=mobile to force a version
57
94
  app.get("/view/:id", async (c) => {
58
95
  const id = c.req.param("id")
59
96
 
@@ -61,7 +98,21 @@ app.get("/view/:id", async (c) => {
61
98
  return c.text("Invalid ID", 400)
62
99
  }
63
100
 
64
- const html = await c.env.CRITIQUE_KV.get(id)
101
+ // Check for forced version via query param
102
+ const forcedVersion = c.req.query("v")
103
+ const isMobile = forcedVersion === "mobile" || (forcedVersion !== "desktop" && isMobileDevice(c))
104
+
105
+ // Try to get the appropriate version
106
+ let html: string | null = null
107
+ if (isMobile) {
108
+ // Try mobile version first, fall back to desktop
109
+ html = await c.env.CRITIQUE_KV.get(`${id}-mobile`)
110
+ if (!html) {
111
+ html = await c.env.CRITIQUE_KV.get(id)
112
+ }
113
+ } else {
114
+ html = await c.env.CRITIQUE_KV.get(id)
115
+ }
65
116
 
66
117
  if (!html) {
67
118
  return c.text("Not found", 404)
@@ -71,6 +122,8 @@ app.get("/view/:id", async (c) => {
71
122
  return stream(c, async (s) => {
72
123
  // Set content type header
73
124
  c.header("Content-Type", "text/html; charset=utf-8")
125
+ // Vary by User-Agent and Sec-CH-UA-Mobile for proper caching
126
+ c.header("Vary", "User-Agent, Sec-CH-UA-Mobile")
74
127
  c.header("Cache-Control", "public, max-age=3600")
75
128
 
76
129
  // Stream in chunks for better performance