critique 0.1.8 → 0.1.10
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/AGENTS.md +6 -1
- package/CHANGELOG.md +33 -0
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/ansi-html.ts +23 -5
- package/src/cli.tsx +157 -94
- package/src/themes/github-light.json +56 -0
- package/src/themes/opencode-light.json +62 -0
- package/src/themes/opencode.json +2 -2
- package/src/themes.ts +70 -64
- package/src/worker.ts +58 -5
package/AGENTS.md
CHANGED
|
@@ -8,7 +8,12 @@ ALWAYS!
|
|
|
8
8
|
|
|
9
9
|
## bun
|
|
10
10
|
|
|
11
|
-
NEVER run
|
|
11
|
+
NEVER run the interactive TUI (e.g. `bun run src/cli.tsx` without arguments). It will hang. Instead ask the user to run it.
|
|
12
|
+
|
|
13
|
+
The `web` command is safe to run - it generates HTML and exits:
|
|
14
|
+
```bash
|
|
15
|
+
bun run src/cli.tsx web
|
|
16
|
+
```
|
|
12
17
|
|
|
13
18
|
NEVER use require. just import at the top of the file with esm
|
|
14
19
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
# 0.1.10
|
|
2
|
+
|
|
3
|
+
- Default command:
|
|
4
|
+
- Add `--filter <pattern>` option to filter files by glob pattern (e.g. `critique --filter 'src/**/*.ts'`)
|
|
5
|
+
- Web command:
|
|
6
|
+
- Add support for comparing two refs: `critique web <base> <head>`
|
|
7
|
+
- Add `--filter <pattern>` option to filter files by glob pattern
|
|
8
|
+
- Add auto light/dark mode based on system preference (uses CSS `prefers-color-scheme`)
|
|
9
|
+
- Disabled when `--theme` is specified
|
|
10
|
+
- Fix browser rendering for Safari/Chrome subpixel issues
|
|
11
|
+
- Themes:
|
|
12
|
+
- Change default theme to `github` (dark)
|
|
13
|
+
- Fix opencode theme line number contrast (was nearly invisible on dark background)
|
|
14
|
+
|
|
15
|
+
# 0.1.9
|
|
16
|
+
|
|
17
|
+
- Performance:
|
|
18
|
+
- Lazy-load themes: Only the default (github) theme is loaded at startup; other themes load on-demand when selected
|
|
19
|
+
- Lazy-load `@parcel/watcher`: Native file watcher module is only loaded when `--watch` flag is used
|
|
20
|
+
- Parallelize `diff` module import with renderer creation
|
|
21
|
+
- Web preview:
|
|
22
|
+
- Generate desktop and mobile HTML versions in parallel
|
|
23
|
+
- Add `--mobile-cols` option (default: 100) for mobile column width
|
|
24
|
+
- Add `--theme` option to specify theme for web preview
|
|
25
|
+
- Worker auto-detects mobile devices via `CF-Device-Type`, `Sec-CH-UA-Mobile`, or User-Agent regex
|
|
26
|
+
- Add `?v=desktop` / `?v=mobile` query params to force a specific version
|
|
27
|
+
- Mobile version uses more rows to accommodate line wrapping
|
|
28
|
+
- Themes:
|
|
29
|
+
- Add `opencode-light` theme - light mode variant of OpenCode theme
|
|
30
|
+
- Change default theme from `github` to `opencode-light`
|
|
31
|
+
- Web preview now uses theme-aware colors (background, text, diff colors)
|
|
32
|
+
- Theme is changeable via state (press `t` in TUI to pick theme)
|
|
33
|
+
|
|
1
34
|
# 0.1.8
|
|
2
35
|
|
|
3
36
|
- Web preview:
|
package/README.md
CHANGED
|
@@ -27,16 +27,26 @@ critique
|
|
|
27
27
|
# View staged changes
|
|
28
28
|
critique --staged
|
|
29
29
|
|
|
30
|
+
# View the last commit (works whether pushed or unpushed)
|
|
31
|
+
critique HEAD
|
|
32
|
+
|
|
30
33
|
# View a specific commit
|
|
31
34
|
critique --commit HEAD~1
|
|
32
35
|
critique abc1234
|
|
33
36
|
|
|
37
|
+
# View combined changes from last N commits
|
|
38
|
+
critique HEAD~3 HEAD # shows all changes from 3 commits ago to now
|
|
39
|
+
|
|
34
40
|
# Compare two branches (PR-style, shows what head added since diverging from base)
|
|
35
41
|
critique main feature-branch # what feature-branch added vs main
|
|
36
42
|
critique main HEAD # what current branch added vs main
|
|
37
43
|
|
|
38
44
|
# Watch mode - auto-refresh on file changes
|
|
39
45
|
critique --watch
|
|
46
|
+
|
|
47
|
+
# Filter files by glob pattern (can be used multiple times)
|
|
48
|
+
critique --filter "src/**/*.ts"
|
|
49
|
+
critique --filter "src/**/*.ts" --filter "lib/**/*.js"
|
|
40
50
|
```
|
|
41
51
|
|
|
42
52
|
### Navigation
|
|
@@ -85,9 +95,15 @@ critique web
|
|
|
85
95
|
# View staged changes
|
|
86
96
|
critique web --staged
|
|
87
97
|
|
|
98
|
+
# View the last commit (works whether pushed or unpushed)
|
|
99
|
+
critique web HEAD
|
|
100
|
+
|
|
88
101
|
# View a specific commit
|
|
89
102
|
critique web --commit HEAD~1
|
|
90
103
|
|
|
104
|
+
# View combined changes from last N commits
|
|
105
|
+
critique web HEAD~3 HEAD
|
|
106
|
+
|
|
91
107
|
# Generate local HTML file instead of uploading
|
|
92
108
|
critique web --local
|
|
93
109
|
|
|
@@ -112,6 +128,7 @@ critique web --cols 100 --rows 2000
|
|
|
112
128
|
| `--cols <n>` | Terminal width for rendering | `240` |
|
|
113
129
|
| `--rows <n>` | Terminal height for rendering | `2000` |
|
|
114
130
|
| `--local` | Save HTML locally instead of uploading | - |
|
|
131
|
+
| `--filter <pattern>` | Filter files by glob (can be used multiple times) | - |
|
|
115
132
|
|
|
116
133
|
**Tips:**
|
|
117
134
|
|
package/package.json
CHANGED
package/src/ansi-html.ts
CHANGED
|
@@ -5,12 +5,16 @@ 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 */
|
|
11
13
|
fontSize?: string
|
|
12
14
|
/** Trim empty lines from the end */
|
|
13
15
|
trimEmptyLines?: boolean
|
|
16
|
+
/** Enable auto light/dark mode based on system preference */
|
|
17
|
+
autoTheme?: boolean
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -121,7 +125,8 @@ export function ansiToHtml(input: string | Buffer, options: AnsiToHtmlOptions =
|
|
|
121
125
|
export function ansiToHtmlDocument(input: string | Buffer, options: AnsiToHtmlOptions = {}): string {
|
|
122
126
|
const {
|
|
123
127
|
cols = 500,
|
|
124
|
-
backgroundColor = "#
|
|
128
|
+
backgroundColor = "#ffffff",
|
|
129
|
+
textColor = "#1a1a1a",
|
|
125
130
|
fontFamily = "Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace",
|
|
126
131
|
fontSize = "14px",
|
|
127
132
|
} = options
|
|
@@ -150,10 +155,13 @@ html, body {
|
|
|
150
155
|
max-width: 100vw;
|
|
151
156
|
overflow-x: hidden;
|
|
152
157
|
background-color: ${backgroundColor};
|
|
153
|
-
color:
|
|
158
|
+
color: ${textColor};
|
|
154
159
|
font-family: ${fontFamily};
|
|
155
160
|
font-size: ${fontSize};
|
|
156
|
-
line-height: 1.
|
|
161
|
+
line-height: 1.5;
|
|
162
|
+
-webkit-font-smoothing: antialiased;
|
|
163
|
+
-moz-osx-font-smoothing: grayscale;
|
|
164
|
+
text-rendering: optimizeLegibility;
|
|
157
165
|
}
|
|
158
166
|
body {
|
|
159
167
|
display: flex;
|
|
@@ -169,12 +177,19 @@ body {
|
|
|
169
177
|
white-space: pre;
|
|
170
178
|
display: flex;
|
|
171
179
|
content-visibility: auto;
|
|
172
|
-
contain-intrinsic-block-size: auto
|
|
180
|
+
contain-intrinsic-block-size: auto 1.5em;
|
|
173
181
|
background-color: ${backgroundColor};
|
|
182
|
+
transform: translateZ(0);
|
|
183
|
+
backface-visibility: hidden;
|
|
174
184
|
}
|
|
175
185
|
.line span {
|
|
176
186
|
white-space: pre;
|
|
177
187
|
}
|
|
188
|
+
${options.autoTheme ? `@media (prefers-color-scheme: light) {
|
|
189
|
+
html {
|
|
190
|
+
filter: invert(1) hue-rotate(180deg);
|
|
191
|
+
}
|
|
192
|
+
}` : ''}
|
|
178
193
|
</style>
|
|
179
194
|
</head>
|
|
180
195
|
<body>
|
|
@@ -192,7 +207,10 @@ ${content}
|
|
|
192
207
|
function adjustFontSize() {
|
|
193
208
|
const viewportWidth = window.innerWidth;
|
|
194
209
|
const calculatedSize = (viewportWidth - padding) / (cols * charRatio);
|
|
195
|
-
|
|
210
|
+
// Round to nearest even integer to prevent subpixel rendering issues
|
|
211
|
+
// (with line-height: 1.5, even font-size always yields integer line-height)
|
|
212
|
+
const clamped = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
|
|
213
|
+
const fontSize = Math.round(clamped / 2) * 2;
|
|
196
214
|
document.body.style.fontSize = fontSize + 'px';
|
|
197
215
|
}
|
|
198
216
|
|
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
|
|
@@ -553,26 +562,39 @@ cli
|
|
|
553
562
|
.option("--commit <ref>", "Show changes from a specific commit")
|
|
554
563
|
.option("--watch", "Watch for file changes and refresh diff")
|
|
555
564
|
.option("--context <lines>", "Number of context lines (default: 3)")
|
|
565
|
+
.option("--filter <pattern>", "Filter files by glob pattern (can be used multiple times)")
|
|
556
566
|
.action(async (base, head, options) => {
|
|
557
567
|
try {
|
|
558
568
|
const contextArg = options.context ? `-U${options.context}` : "";
|
|
569
|
+
const filters = options.filter ? (Array.isArray(options.filter) ? options.filter : [options.filter]) : [];
|
|
570
|
+
const filterArg = filters.length > 0 ? `-- ${filters.map((f: string) => `"${f}"`).join(" ")}` : "";
|
|
559
571
|
const gitCommand = (() => {
|
|
560
572
|
if (options.staged)
|
|
561
|
-
return `git diff --cached --no-prefix ${contextArg}`.trim();
|
|
573
|
+
return `git diff --cached --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
562
574
|
if (options.commit)
|
|
563
|
-
return `git show ${options.commit} --no-prefix ${contextArg}`.trim();
|
|
575
|
+
return `git show ${options.commit} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
564
576
|
// Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
|
|
565
577
|
if (base && head)
|
|
566
|
-
return `git diff ${base}...${head} --no-prefix ${contextArg}`.trim();
|
|
578
|
+
return `git diff ${base}...${head} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
567
579
|
// Single ref: show that commit's changes
|
|
568
|
-
if (base) return `git show ${base} --no-prefix ${contextArg}`.trim();
|
|
569
|
-
return `git add -N . && git diff --no-prefix ${contextArg}`.trim();
|
|
580
|
+
if (base) return `git show ${base} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
581
|
+
return `git add -N . && git diff --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
570
582
|
})();
|
|
571
583
|
|
|
572
|
-
const { parsePatch, formatPatch } = await import("diff");
|
|
573
|
-
|
|
574
584
|
const shouldWatch = options.watch && !base && !head && !options.commit;
|
|
575
585
|
|
|
586
|
+
// Parallelize diff module loading with renderer creation
|
|
587
|
+
const [diffModule, renderer] = await Promise.all([
|
|
588
|
+
import("diff"),
|
|
589
|
+
createCliRenderer({
|
|
590
|
+
onDestroy() {
|
|
591
|
+
process.exit(0);
|
|
592
|
+
},
|
|
593
|
+
exitOnCtrlC: true,
|
|
594
|
+
}),
|
|
595
|
+
]);
|
|
596
|
+
const { parsePatch, formatPatch } = diffModule;
|
|
597
|
+
|
|
576
598
|
function AppWithWatch() {
|
|
577
599
|
const [parsedFiles, setParsedFiles] = React.useState<
|
|
578
600
|
ParsedFile[] | null
|
|
@@ -636,6 +658,7 @@ cli
|
|
|
636
658
|
|
|
637
659
|
fetchDiff();
|
|
638
660
|
|
|
661
|
+
// Set up file watching only if --watch flag is used
|
|
639
662
|
if (!shouldWatch) {
|
|
640
663
|
return;
|
|
641
664
|
}
|
|
@@ -646,21 +669,26 @@ cli
|
|
|
646
669
|
fetchDiff();
|
|
647
670
|
}, 200);
|
|
648
671
|
|
|
649
|
-
let subscription:
|
|
672
|
+
let subscription:
|
|
673
|
+
| Awaited<ReturnType<typeof import("@parcel/watcher").subscribe>>
|
|
674
|
+
| undefined;
|
|
650
675
|
|
|
651
|
-
watcher
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
676
|
+
// Lazy-load watcher module only when watching
|
|
677
|
+
getWatcher().then((watcher) => {
|
|
678
|
+
watcher
|
|
679
|
+
.subscribe(cwd, (err, events) => {
|
|
680
|
+
if (err) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
656
683
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
684
|
+
if (events.length > 0) {
|
|
685
|
+
debouncedFetch();
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
.then((sub) => {
|
|
689
|
+
subscription = sub;
|
|
690
|
+
});
|
|
691
|
+
});
|
|
664
692
|
|
|
665
693
|
return () => {
|
|
666
694
|
if (subscription) {
|
|
@@ -704,12 +732,6 @@ cli
|
|
|
704
732
|
return <App parsedFiles={parsedFiles} />;
|
|
705
733
|
}
|
|
706
734
|
|
|
707
|
-
const renderer = await createCliRenderer({
|
|
708
|
-
onDestroy() {
|
|
709
|
-
process.exit(0);
|
|
710
|
-
},
|
|
711
|
-
exitOnCtrlC: true,
|
|
712
|
-
});
|
|
713
735
|
createRoot(renderer).render(
|
|
714
736
|
React.createElement(
|
|
715
737
|
ErrorBoundary,
|
|
@@ -991,33 +1013,48 @@ const WORKER_URL =
|
|
|
991
1013
|
process.env.CRITIQUE_WORKER_URL || "https://critique.work";
|
|
992
1014
|
|
|
993
1015
|
cli
|
|
994
|
-
.command("web [
|
|
1016
|
+
.command("web [base] [head]", "Generate web preview of diff")
|
|
995
1017
|
.option("--staged", "Show staged changes")
|
|
996
1018
|
.option("--commit <ref>", "Show changes from a specific commit")
|
|
997
1019
|
.option(
|
|
998
1020
|
"--cols <cols>",
|
|
999
|
-
"Number of columns for rendering
|
|
1021
|
+
"Number of columns for desktop rendering",
|
|
1000
1022
|
{ default: 240 },
|
|
1001
1023
|
)
|
|
1002
|
-
|
|
1024
|
+
.option(
|
|
1025
|
+
"--mobile-cols <cols>",
|
|
1026
|
+
"Number of columns for mobile rendering",
|
|
1027
|
+
{ default: 100 },
|
|
1028
|
+
)
|
|
1003
1029
|
.option("--local", "Save local preview instead of uploading")
|
|
1004
1030
|
.option("--open", "Open in browser after generating")
|
|
1005
1031
|
.option("--context <lines>", "Number of context lines (default: 3)")
|
|
1006
|
-
.
|
|
1032
|
+
.option("--theme <name>", "Theme to use for rendering")
|
|
1033
|
+
.option("--filter <pattern>", "Filter files by glob pattern (can be used multiple times)")
|
|
1034
|
+
.action(async (base, head, options) => {
|
|
1007
1035
|
const pty = await import("@xmorse/bun-pty");
|
|
1008
1036
|
const { ansiToHtmlDocument } = await import("./ansi-html.ts");
|
|
1009
1037
|
|
|
1010
1038
|
const contextArg = options.context ? `-U${options.context}` : "";
|
|
1039
|
+
const filters = options.filter ? (Array.isArray(options.filter) ? options.filter : [options.filter]) : [];
|
|
1040
|
+
const filterArg = filters.length > 0 ? `-- ${filters.map((f: string) => `"${f}"`).join(" ")}` : "";
|
|
1011
1041
|
const gitCommand = (() => {
|
|
1012
1042
|
if (options.staged)
|
|
1013
|
-
return `git diff --cached --no-prefix ${contextArg}`.trim();
|
|
1043
|
+
return `git diff --cached --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
1014
1044
|
if (options.commit)
|
|
1015
|
-
return `git show ${options.commit} --no-prefix ${contextArg}`.trim();
|
|
1016
|
-
|
|
1017
|
-
|
|
1045
|
+
return `git show ${options.commit} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
1046
|
+
// Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
|
|
1047
|
+
if (base && head)
|
|
1048
|
+
return `git diff ${base}...${head} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
1049
|
+
// Single ref: show that commit's changes
|
|
1050
|
+
if (base) return `git show ${base} --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
1051
|
+
return `git add -N . && git diff --no-prefix ${contextArg} ${filterArg}`.trim();
|
|
1018
1052
|
})();
|
|
1019
1053
|
|
|
1020
|
-
const
|
|
1054
|
+
const desktopCols = parseInt(options.cols) || 240;
|
|
1055
|
+
const mobileCols = parseInt(options.mobileCols) || 100;
|
|
1056
|
+
const customTheme = options.theme && themeNames.includes(options.theme);
|
|
1057
|
+
const themeName = customTheme ? options.theme : defaultThemeName;
|
|
1021
1058
|
|
|
1022
1059
|
console.log("Capturing diff output...");
|
|
1023
1060
|
|
|
@@ -1034,7 +1071,7 @@ cli
|
|
|
1034
1071
|
// Calculate required rows from diff content
|
|
1035
1072
|
const { parsePatch } = await import("diff");
|
|
1036
1073
|
const files = parsePatch(gitDiff);
|
|
1037
|
-
const
|
|
1074
|
+
const baseRenderRows = files.reduce((sum, file) => {
|
|
1038
1075
|
const diffLines = file.hunks.reduce((h, hunk) => h + hunk.lines.length, 0);
|
|
1039
1076
|
return sum + diffLines + 5; // header + margin per file
|
|
1040
1077
|
}, 100); // base padding
|
|
@@ -1043,67 +1080,85 @@ cli
|
|
|
1043
1080
|
const diffFile = join(tmpdir(), `critique-web-diff-${Date.now()}.patch`);
|
|
1044
1081
|
fs.writeFileSync(diffFile, gitDiff);
|
|
1045
1082
|
|
|
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
|
-
|
|
1083
|
+
// Helper function to capture PTY output for a given column width
|
|
1084
|
+
async function captureHtml(cols: number, renderRows: number): Promise<string> {
|
|
1085
|
+
let ansiOutput = "";
|
|
1086
|
+
const ptyProcess = pty.spawn(
|
|
1087
|
+
"bun",
|
|
1088
|
+
[
|
|
1089
|
+
process.argv[1]!, // path to cli.tsx
|
|
1090
|
+
"web-render",
|
|
1091
|
+
diffFile,
|
|
1092
|
+
"--cols",
|
|
1093
|
+
String(cols),
|
|
1094
|
+
"--rows",
|
|
1095
|
+
String(renderRows),
|
|
1096
|
+
"--theme",
|
|
1097
|
+
themeName,
|
|
1098
|
+
],
|
|
1099
|
+
{
|
|
1100
|
+
name: "xterm-256color",
|
|
1101
|
+
cols: cols,
|
|
1102
|
+
rows: renderRows,
|
|
1103
|
+
cwd: process.cwd(),
|
|
1104
|
+
env: { ...process.env, TERM: "xterm-256color" } as Record<
|
|
1105
|
+
string,
|
|
1106
|
+
string
|
|
1107
|
+
>,
|
|
1108
|
+
},
|
|
1109
|
+
);
|
|
1071
1110
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1111
|
+
ptyProcess.onData((data: string) => {
|
|
1112
|
+
ansiOutput += data;
|
|
1113
|
+
});
|
|
1075
1114
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1115
|
+
await new Promise<void>((resolve) => {
|
|
1116
|
+
ptyProcess.onExit(() => {
|
|
1117
|
+
resolve();
|
|
1118
|
+
});
|
|
1079
1119
|
});
|
|
1080
|
-
});
|
|
1081
1120
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1121
|
+
if (!ansiOutput.trim()) {
|
|
1122
|
+
throw new Error("No output captured");
|
|
1123
|
+
}
|
|
1084
1124
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1125
|
+
// Strip terminal cleanup sequences that clear the screen
|
|
1126
|
+
const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
|
|
1127
|
+
if (clearIdx > 0) {
|
|
1128
|
+
ansiOutput = ansiOutput.slice(0, clearIdx);
|
|
1129
|
+
}
|
|
1089
1130
|
|
|
1090
|
-
|
|
1131
|
+
// Get theme colors for HTML output
|
|
1132
|
+
const theme = getResolvedTheme(themeName);
|
|
1133
|
+
const backgroundColor = rgbaToHex(theme.background);
|
|
1134
|
+
const textColor = rgbaToHex(theme.text);
|
|
1091
1135
|
|
|
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);
|
|
1136
|
+
return ansiToHtmlDocument(ansiOutput, { cols, rows: renderRows, backgroundColor, textColor, autoTheme: !customTheme });
|
|
1097
1137
|
}
|
|
1098
1138
|
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1139
|
+
// Generate desktop and mobile versions in parallel
|
|
1140
|
+
// Mobile needs more rows since lines wrap more with fewer columns
|
|
1141
|
+
const mobileRenderRows = Math.ceil(baseRenderRows * (desktopCols / mobileCols));
|
|
1142
|
+
console.log("Generating desktop and mobile versions in parallel...");
|
|
1143
|
+
const [htmlDesktop, htmlMobile] = await Promise.all([
|
|
1144
|
+
captureHtml(desktopCols, baseRenderRows),
|
|
1145
|
+
captureHtml(mobileCols, mobileRenderRows),
|
|
1146
|
+
]);
|
|
1147
|
+
|
|
1148
|
+
// Clean up temp file
|
|
1149
|
+
fs.unlinkSync(diffFile);
|
|
1150
|
+
|
|
1151
|
+
console.log("Converting to HTML...");
|
|
1101
1152
|
|
|
1102
1153
|
if (options.local) {
|
|
1103
1154
|
// Save locally
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1155
|
+
const timestamp = Date.now();
|
|
1156
|
+
const htmlFileDesktop = join(tmpdir(), `critique-${timestamp}-desktop.html`);
|
|
1157
|
+
const htmlFileMobile = join(tmpdir(), `critique-${timestamp}-mobile.html`);
|
|
1158
|
+
fs.writeFileSync(htmlFileDesktop, htmlDesktop);
|
|
1159
|
+
fs.writeFileSync(htmlFileMobile, htmlMobile);
|
|
1160
|
+
console.log(`Saved desktop to: ${htmlFileDesktop}`);
|
|
1161
|
+
console.log(`Saved mobile to: ${htmlFileMobile}`);
|
|
1107
1162
|
|
|
1108
1163
|
// Open in browser if requested
|
|
1109
1164
|
if (options.open) {
|
|
@@ -1114,7 +1169,7 @@ cli
|
|
|
1114
1169
|
? "start"
|
|
1115
1170
|
: "xdg-open";
|
|
1116
1171
|
try {
|
|
1117
|
-
await execAsync(`${openCmd} "${
|
|
1172
|
+
await execAsync(`${openCmd} "${htmlFileDesktop}"`);
|
|
1118
1173
|
} catch {
|
|
1119
1174
|
console.log("Could not open browser automatically");
|
|
1120
1175
|
}
|
|
@@ -1130,7 +1185,7 @@ cli
|
|
|
1130
1185
|
headers: {
|
|
1131
1186
|
"Content-Type": "application/json",
|
|
1132
1187
|
},
|
|
1133
|
-
body: JSON.stringify({ html }),
|
|
1188
|
+
body: JSON.stringify({ html: htmlDesktop, htmlMobile }),
|
|
1134
1189
|
});
|
|
1135
1190
|
|
|
1136
1191
|
if (!response.ok) {
|
|
@@ -1162,7 +1217,7 @@ cli
|
|
|
1162
1217
|
|
|
1163
1218
|
// Fallback to local file
|
|
1164
1219
|
const htmlFile = join(tmpdir(), `critique-${Date.now()}.html`);
|
|
1165
|
-
fs.writeFileSync(htmlFile,
|
|
1220
|
+
fs.writeFileSync(htmlFile, htmlDesktop);
|
|
1166
1221
|
console.log(`\nFallback: Saved locally to ${htmlFile}`);
|
|
1167
1222
|
process.exit(1);
|
|
1168
1223
|
}
|
|
@@ -1175,9 +1230,13 @@ cli
|
|
|
1175
1230
|
})
|
|
1176
1231
|
.option("--cols <cols>", "Terminal columns", { default: 120 })
|
|
1177
1232
|
.option("--rows <rows>", "Terminal rows", { default: 1000 })
|
|
1233
|
+
.option("--theme <name>", "Theme to use for rendering")
|
|
1178
1234
|
.action(async (diffFile: string, options) => {
|
|
1179
1235
|
const cols = parseInt(options.cols) || 120;
|
|
1180
1236
|
const rows = parseInt(options.rows) || 1000;
|
|
1237
|
+
const themeName = options.theme && themeNames.includes(options.theme)
|
|
1238
|
+
? options.theme
|
|
1239
|
+
: defaultThemeName;
|
|
1181
1240
|
|
|
1182
1241
|
const { parsePatch, formatPatch } = await import("diff");
|
|
1183
1242
|
|
|
@@ -1246,7 +1305,11 @@ cli
|
|
|
1246
1305
|
const useSplitView = cols >= 150;
|
|
1247
1306
|
|
|
1248
1307
|
// Static component - no hooks that cause re-renders
|
|
1249
|
-
const
|
|
1308
|
+
const webTheme = getResolvedTheme(themeName);
|
|
1309
|
+
const webBg = webTheme.background;
|
|
1310
|
+
const webText = rgbaToHex(webTheme.text);
|
|
1311
|
+
const webAddedColor = rgbaToHex(webTheme.diffAddedBg);
|
|
1312
|
+
const webRemovedColor = rgbaToHex(webTheme.diffRemovedBg);
|
|
1250
1313
|
function WebApp() {
|
|
1251
1314
|
return (
|
|
1252
1315
|
<box
|
|
@@ -1289,15 +1352,15 @@ cli
|
|
|
1289
1352
|
alignItems: "center",
|
|
1290
1353
|
}}
|
|
1291
1354
|
>
|
|
1292
|
-
<text>{fileName.trim()}</text>
|
|
1293
|
-
<text fg="#
|
|
1294
|
-
<text fg="#
|
|
1355
|
+
<text fg={webText}>{fileName.trim()}</text>
|
|
1356
|
+
<text fg="#2d8a47"> +{additions}</text>
|
|
1357
|
+
<text fg="#c53b53">-{deletions}</text>
|
|
1295
1358
|
</box>
|
|
1296
1359
|
<DiffView
|
|
1297
1360
|
diff={file.rawDiff || ""}
|
|
1298
1361
|
view={viewMode}
|
|
1299
1362
|
filetype={filetype}
|
|
1300
|
-
themeName={
|
|
1363
|
+
themeName={themeName}
|
|
1301
1364
|
/>
|
|
1302
1365
|
</box>
|
|
1303
1366
|
);
|
|
@@ -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/opencode.json
CHANGED
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,7 +235,7 @@ 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
240
|
export const defaultThemeName = "github";
|
|
235
241
|
|
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
|