@youtyan/code-viewer 0.1.16 → 0.1.17

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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
7
- "code-viewer": "web-src/server/preview.ts",
8
- "git-diff-preview": "web-src/server/preview.ts"
7
+ "code-viewer": "dist/code-viewer.js",
8
+ "git-diff-preview": "dist/code-viewer.js"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -20,45 +20,50 @@
20
20
  "provenance": true
21
21
  },
22
22
  "files": [
23
+ "dist",
23
24
  "web",
24
- "web-src/server",
25
- "web-src/routes.ts",
26
- "web-src/types.ts",
27
25
  "README.md",
28
26
  "LICENSE"
29
27
  ],
30
28
  "scripts": {
31
- "build": "bun build --target=browser --format=iife --outfile=web/app.js web-src/app.ts && bun build --target=browser --format=esm --outfile=web/mermaid.js web-src/mermaid-entry.ts && bun build --target=browser --format=esm --outfile=web/shiki.js web-src/shiki-entry.ts",
32
- "check": "tsc --noEmit",
29
+ "build": "bun run build:web && bun run build:server",
30
+ "build:web": "bun build --target=browser --format=iife --outfile=web/app.js web-src/app.ts && bun build --target=browser --format=esm --outfile=web/mermaid.js web-src/mermaid-entry.ts && bun build --target=browser --format=esm --outfile=web/shiki.js web-src/shiki-entry.ts",
31
+ "build:server": "bun build --target=node --format=esm --outfile=dist/code-viewer.js web-src/server/cli.ts",
32
+ "check": "bun run typecheck",
33
+ "typecheck": "tsc --noEmit",
33
34
  "check:bundle": "bun build --target=browser --format=iife --outfile=/tmp/code-viewer-app.js web-src/app.ts && cmp /tmp/code-viewer-app.js web/app.js && bun build --target=browser --format=esm --outfile=/tmp/code-viewer-mermaid.js web-src/mermaid-entry.ts && cmp /tmp/code-viewer-mermaid.js web/mermaid.js && bun build --target=browser --format=esm --outfile=/tmp/code-viewer-shiki.js web-src/shiki-entry.ts && cmp /tmp/code-viewer-shiki.js web/shiki.js",
34
35
  "dev": "bun run web-src/server/dev.ts",
35
36
  "preview": "bun run web-src/server/dev.ts",
36
37
  "preview:raw": "bun run web-src/server/preview.ts",
37
38
  "test": "bun test",
38
- "verify": "bun run check && bun run build && bun run check:bundle && bun run test && node --check web/app.js && node --check web/mermaid.js && node --check web/shiki.js",
39
+ "lint": "biome lint web-src/server package.json biome.jsonc",
40
+ "verify": "bun run check && bun run lint && bun run build && bun run check:bundle && bun run test && node --check web/app.js && node --check web/mermaid.js && node --check web/shiki.js && node --check dist/code-viewer.js && node dist/code-viewer.js --help && node scripts/node-smoke.mjs",
39
41
  "pack:dry": "npm pack --dry-run",
42
+ "prepack": "bun run build",
40
43
  "prepublishOnly": "bun run verify"
41
44
  },
42
45
  "keywords": [
43
46
  "git",
44
47
  "diff",
45
48
  "viewer",
46
- "bun"
49
+ "bun",
50
+ "npx",
51
+ "npm"
47
52
  ],
48
53
  "license": "MIT",
49
54
  "devDependencies": {
55
+ "@biomejs/biome": "2.4.14",
50
56
  "@types/bun": "latest",
51
- "typescript": "^5.0.0"
52
- },
53
- "engines": {
54
- "bun": ">=1.0.0"
55
- },
56
- "dependencies": {
57
57
  "@types/markdown-it": "^14.1.2",
58
58
  "markdown-it": "^14.1.1",
59
59
  "markdown-it-anchor": "^9.2.0",
60
60
  "markdown-it-footnote": "^4.0.0",
61
61
  "mermaid": "^11.14.0",
62
- "shiki": "^4.0.2"
63
- }
62
+ "shiki": "^4.0.2",
63
+ "typescript": "^5.0.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=20.0.0"
67
+ },
68
+ "dependencies": {}
64
69
  }
package/web-src/routes.ts DELETED
@@ -1,148 +0,0 @@
1
- export type DiffRange = {
2
- from: string;
3
- to: string;
4
- };
5
-
6
- export type SourceLineRange = {
7
- start: number;
8
- end: number;
9
- };
10
-
11
- export type SourceLineTarget = number | SourceLineRange;
12
-
13
- export type SourceFileTarget = {
14
- path: string;
15
- ref: string;
16
- };
17
-
18
- export type AppRoute =
19
- | { screen: 'repo'; ref: string; path: string; range: DiffRange }
20
- | { screen: 'diff'; range: DiffRange; path?: string; line?: SourceLineTarget }
21
- | { screen: 'file'; path: string; ref: string; range: DiffRange; view?: 'blob' | 'detail'; line?: SourceLineTarget }
22
- | { screen: 'help'; range: DiffRange; lang: string; section: string }
23
- | { screen: 'unknown'; reason: 'unknown-pathname' | 'missing-path'; rawPathname: string; rawSearch: string; range: DiffRange };
24
-
25
- export const SPA_PATHS = ['/todif', '/todiff', '/file', '/help'] as const;
26
- export const APP_ENTRY_PATHS = ['/', '/index.html'] as const;
27
-
28
- export function assertNever(value: never): never {
29
- throw new Error('unhandled route: ' + JSON.stringify(value));
30
- }
31
-
32
- function parseLegacyRange(value: string | null | undefined, fallback: DiffRange): DiffRange {
33
- const raw = value || '';
34
- const sep = raw.indexOf('..');
35
- if (sep < 0) return fallback;
36
- return {
37
- from: raw.slice(0, sep) || fallback.from,
38
- to: raw.slice(sep + 2) || fallback.to,
39
- };
40
- }
41
-
42
- function parseLineTarget(value: string | null | undefined): SourceLineTarget | undefined {
43
- const raw = value || '';
44
- const range = /^(\d+)-(\d+)$/.exec(raw);
45
- if (range) {
46
- const a = Number(range[1]);
47
- const b = Number(range[2]);
48
- const start = Math.min(a, b);
49
- const end = Math.max(a, b);
50
- if (start > 0) return { start, end };
51
- return undefined;
52
- }
53
- const line = Number(raw);
54
- return Number.isInteger(line) && line > 0 ? line : undefined;
55
- }
56
-
57
- function formatLineTarget(line: SourceLineTarget): string {
58
- return typeof line === 'number' ? String(line) : line.start + '-' + line.end;
59
- }
60
-
61
- export function parseRoute(pathname: string, search: string, fallbackRange: DiffRange): AppRoute {
62
- const params = new URLSearchParams(search);
63
- const legacyRange = parseLegacyRange(params.get('range'), fallbackRange);
64
- const range = {
65
- from: params.get('from') || legacyRange.from,
66
- to: params.get('to') || legacyRange.to,
67
- };
68
- switch (pathname) {
69
- case '/':
70
- case '/index.html':
71
- return {
72
- screen: 'repo',
73
- ref: params.get('ref') || params.get('target') || 'worktree',
74
- path: params.get('path') || '',
75
- range,
76
- };
77
- case '/todif':
78
- case '/todiff':
79
- return {
80
- screen: 'diff',
81
- range,
82
- ...(params.get('path') ? { path: params.get('path') || '' } : {}),
83
- ...(parseLineTarget(params.get('line')) ? { line: parseLineTarget(params.get('line')) } : {}),
84
- };
85
- case '/file': {
86
- const path = params.get('path') || '';
87
- const target = params.get('target') || '';
88
- const ref = target || params.get('ref') || 'worktree';
89
- const line = parseLineTarget(params.get('line'));
90
- if (!path) return { screen: 'unknown', reason: 'missing-path', rawPathname: pathname, rawSearch: search, range };
91
- return { screen: 'file', path, ref, range, view: target ? 'blob' : 'detail', ...(line ? { line } : {}) };
92
- }
93
- case '/help':
94
- return {
95
- screen: 'help',
96
- range,
97
- lang: params.get('lang') || 'en',
98
- section: params.get('section') || 'keybindings',
99
- };
100
- default:
101
- return { screen: 'unknown', reason: 'unknown-pathname', rawPathname: pathname, rawSearch: search, range };
102
- }
103
- }
104
-
105
- export function buildRoute(route: AppRoute): string {
106
- switch (route.screen) {
107
- case 'repo': {
108
- const params = new URLSearchParams();
109
- if (route.ref && route.ref !== 'worktree') params.set('ref', route.ref);
110
- if (route.path) params.set('path', route.path);
111
- const qs = params.toString();
112
- return '/' + (qs ? '?' + qs : '');
113
- }
114
- case 'file':
115
- if (route.view === 'blob') {
116
- return '/file?path=' + encodeURIComponent(route.path) +
117
- '&target=' + encodeURIComponent(route.ref || 'worktree') +
118
- (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
119
- }
120
- return '/file?path=' + encodeURIComponent(route.path) +
121
- '&ref=' + encodeURIComponent(route.ref || 'worktree') +
122
- '&from=' + encodeURIComponent(route.range.from || '') +
123
- '&to=' + encodeURIComponent(route.range.to || 'worktree') +
124
- (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
125
- case 'diff':
126
- return '/todif?from=' + encodeURIComponent(route.range.from || '') +
127
- '&to=' + encodeURIComponent(route.range.to || 'worktree') +
128
- (route.path ? '&path=' + encodeURIComponent(route.path) : '') +
129
- (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
130
- case 'help': {
131
- const params = new URLSearchParams();
132
- if (route.lang && route.lang !== 'en') params.set('lang', route.lang);
133
- if (route.section && route.section !== 'keybindings') params.set('section', route.section);
134
- const qs = params.toString();
135
- return '/help' + (qs ? '?' + qs : '');
136
- }
137
- case 'unknown':
138
- return '/todif?from=' + encodeURIComponent(route.range.from || '') +
139
- '&to=' + encodeURIComponent(route.range.to || 'worktree');
140
- default:
141
- return assertNever(route);
142
- }
143
- }
144
-
145
- export function buildRawFileUrl(target: SourceFileTarget): string {
146
- return '/_file?path=' + encodeURIComponent(target.path) +
147
- '&ref=' + encodeURIComponent(target.ref || 'worktree');
148
- }
@@ -1,64 +0,0 @@
1
- import { lstatSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
- // Short enough that a browser reload self-heals stale git data, while still
5
- // coalescing bursts from one render pass.
6
- export const CACHE_TTL_MS = 1500;
7
- export const MAX_TIMED_CACHE_ENTRIES = 200;
8
-
9
- export type TimedCacheEntry<T> = T & { storedAt: number };
10
-
11
- export function cacheFresh<T>(
12
- cached: TimedCacheEntry<T> | undefined,
13
- now = Date.now(),
14
- ttlMs = CACHE_TTL_MS,
15
- ): cached is TimedCacheEntry<T> {
16
- return !!cached && now - cached.storedAt <= ttlMs;
17
- }
18
-
19
- export function setTimedCacheEntry<T>(
20
- cache: Map<string, TimedCacheEntry<T>>,
21
- key: string,
22
- value: T,
23
- now = Date.now(),
24
- maxEntries = MAX_TIMED_CACHE_ENTRIES,
25
- ): void {
26
- cache.set(key, { ...value, storedAt: now });
27
- while (cache.size > maxEntries) {
28
- const oldest = cache.keys().next().value;
29
- if (oldest === undefined) break;
30
- cache.delete(oldest);
31
- }
32
- }
33
-
34
- export function worktreeFileSignature(path: string, cwd: string): string {
35
- try {
36
- const stats = lstatSync(join(cwd, path));
37
- const inode = 'ino' in stats ? stats.ino : 0;
38
- return `state:file|size:${stats.size}|mtime:${stats.mtimeMs}|ctime:${stats.ctimeMs}|ino:${inode}`;
39
- } catch {
40
- return 'state:missing';
41
- }
42
- }
43
-
44
- export function fileDiffCacheKey(options: {
45
- path: string;
46
- oldPath?: string | null;
47
- isUntracked: boolean;
48
- range: { from?: string; to?: string };
49
- extras: string[];
50
- args: string[];
51
- cwd: string;
52
- }): string {
53
- const worktreeTarget = options.range.from === 'worktree' || !options.range.to || options.range.to === 'worktree';
54
- if (options.isUntracked && !worktreeTarget) {
55
- throw new Error('untracked file diffs require a worktree range');
56
- }
57
- const signature = worktreeTarget
58
- ? `\0${worktreeFileSignature(options.path, options.cwd)}`
59
- : '';
60
- if (options.isUntracked) {
61
- return `u\0${options.path}${signature}\0${options.extras.join('\0')}`;
62
- }
63
- return `t\0${options.path}\0${options.oldPath || ''}${signature}\0${[...options.extras, ...options.args].join('\0')}`;
64
- }
@@ -1,37 +0,0 @@
1
- import { basename } from 'node:path';
2
-
3
- export type WatchFn = (
4
- path: string,
5
- options: { persistent?: boolean },
6
- listener: (eventType: string, filename: string | Buffer | null) => void,
7
- ) => unknown;
8
-
9
- type DevAssetReloadOptions = {
10
- enabled: boolean;
11
- webRoot: string;
12
- watchedFiles: readonly string[];
13
- watch: WatchFn;
14
- sendReload: () => void;
15
- setTimeoutFn?: typeof setTimeout;
16
- clearTimeoutFn?: typeof clearTimeout;
17
- debounceMs?: number;
18
- };
19
-
20
- export function startDevAssetReload(options: DevAssetReloadOptions): boolean {
21
- if (!options.enabled) return false;
22
- const watched = new Set(options.watchedFiles);
23
- const setTimer = options.setTimeoutFn || setTimeout;
24
- const clearTimer = options.clearTimeoutFn || clearTimeout;
25
- const debounceMs = options.debounceMs ?? 150;
26
- let timer: ReturnType<typeof setTimeout> | null = null;
27
-
28
- options.watch(options.webRoot, { persistent: false }, (_event, filename) => {
29
- if (!filename || !watched.has(basename(filename.toString()))) return;
30
- if (timer) clearTimer(timer);
31
- timer = setTimer(() => {
32
- timer = null;
33
- options.sendReload();
34
- }, debounceMs);
35
- });
36
- return true;
37
- }
@@ -1,100 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { readdirSync, statSync } from 'node:fs';
4
- import { join, normalize } from 'node:path';
5
-
6
- const ROOT = normalize(join(import.meta.dir, '..', '..'));
7
- const SERVER_ROOT = join(ROOT, 'web-src', 'server');
8
- const DEFAULT_DEV_PORT = 64160;
9
-
10
- type ChildProcess = {
11
- kill(signal?: string): void;
12
- exited: Promise<number>;
13
- };
14
-
15
- let server: ChildProcess | null = null;
16
- let build: ChildProcess | null = null;
17
- let restarting = false;
18
- let firstStart = true;
19
-
20
- function withDefaultPort(args: string[]) {
21
- if (args.includes('--port')) return args;
22
- return ['--port', String(DEFAULT_DEV_PORT), ...args];
23
- }
24
-
25
- function withoutOpen(args: string[]) {
26
- return args.filter((arg) => arg !== '--open');
27
- }
28
-
29
- function serverArgs() {
30
- const args = withDefaultPort(process.argv.slice(2));
31
- return firstStart ? args : withoutOpen(args);
32
- }
33
-
34
- function watchedFiles() {
35
- return readdirSync(SERVER_ROOT)
36
- .filter((name) => name.endsWith('.ts') && name !== 'runtime.d.ts')
37
- .map((name) => join(SERVER_ROOT, name))
38
- .concat(join(ROOT, 'web-src', 'types.ts'));
39
- }
40
-
41
- function watchSignature() {
42
- return watchedFiles()
43
- .map((file) => `${file}:${statSync(file).mtimeMs}`)
44
- .join('|');
45
- }
46
-
47
- function startBuild() {
48
- build = Bun.spawn([
49
- 'bun', 'build', '--watch', '--target=browser', '--format=iife',
50
- '--outfile=web/app.js', 'web-src/app.ts',
51
- ], { cwd: ROOT, stdout: 'inherit', stderr: 'inherit' }) as ChildProcess;
52
- }
53
-
54
- function startServer() {
55
- const args = serverArgs();
56
- firstStart = false;
57
- server = Bun.spawn([
58
- 'bun', 'run', 'web-src/server/preview.ts', ...args,
59
- ], {
60
- cwd: ROOT,
61
- stdout: 'inherit',
62
- stderr: 'inherit',
63
- env: { ...process.env, CODE_VIEWER_DEV: '1' },
64
- }) as ChildProcess;
65
- }
66
-
67
- async function restartServer() {
68
- if (restarting) return;
69
- restarting = true;
70
- const old = server;
71
- server = null;
72
- if (old) {
73
- old.kill();
74
- await old.exited.catch(() => 1);
75
- }
76
- startServer();
77
- restarting = false;
78
- }
79
-
80
- function shutdown() {
81
- if (server) server.kill();
82
- if (build) build.kill();
83
- process.exit(0);
84
- }
85
-
86
- process.on('SIGINT', shutdown);
87
- process.on('SIGTERM', shutdown);
88
-
89
- console.log(`code-viewer dev server watching ${SERVER_ROOT}`);
90
- startBuild();
91
- startServer();
92
-
93
- let sig = watchSignature();
94
- setInterval(() => {
95
- const next = watchSignature();
96
- if (next === sig) return;
97
- sig = next;
98
- console.log('server source changed; restarting preview server');
99
- restartServer();
100
- }, 500);