critique 0.1.8 → 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 +19 -0
- package/package.json +1 -1
- package/src/ansi-html.ts +5 -2
- package/src/cli.tsx +137 -83
- package/src/themes/github-light.json +56 -0
- package/src/themes/opencode-light.json +62 -0
- package/src/themes.ts +71 -65
- package/src/worker.ts +58 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
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
|
+
|
|
1
20
|
# 0.1.8
|
|
2
21
|
|
|
3
22
|
- Web preview:
|
package/package.json
CHANGED
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 = "#
|
|
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:
|
|
156
|
+
color: ${textColor};
|
|
154
157
|
font-family: ${fontFamily};
|
|
155
158
|
font-size: ${fontSize};
|
|
156
159
|
line-height: 1.6;
|
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
|
|
@@ -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:
|
|
669
|
+
let subscription:
|
|
670
|
+
| Awaited<ReturnType<typeof import("@parcel/watcher").subscribe>>
|
|
671
|
+
| undefined;
|
|
650
672
|
|
|
651
|
-
watcher
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1102
|
+
ptyProcess.onData((data: string) => {
|
|
1103
|
+
ansiOutput += data;
|
|
1104
|
+
});
|
|
1075
1105
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1106
|
+
await new Promise<void>((resolve) => {
|
|
1107
|
+
ptyProcess.onExit(() => {
|
|
1108
|
+
resolve();
|
|
1109
|
+
});
|
|
1079
1110
|
});
|
|
1080
|
-
});
|
|
1081
1111
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1112
|
+
if (!ansiOutput.trim()) {
|
|
1113
|
+
throw new Error("No output captured");
|
|
1114
|
+
}
|
|
1084
1115
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1100
|
-
|
|
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
|
|
1105
|
-
|
|
1106
|
-
|
|
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} "${
|
|
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,
|
|
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
|
|
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="#
|
|
1294
|
-
<text fg="#
|
|
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={
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"catppuccin
|
|
112
|
-
"catppuccin-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|