newpr 0.4.0 → 0.5.1

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.
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { X, Check, Loader2 } from "lucide-react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { X, Check, Loader2, Search, ChevronDown } from "lucide-react";
3
3
 
4
4
  interface ConfigData {
5
5
  model: string;
@@ -10,6 +10,8 @@ interface ConfigData {
10
10
  concurrency: number;
11
11
  has_api_key: boolean;
12
12
  has_github_token: boolean;
13
+ enabled_plugins: string[];
14
+ available_plugins: Array<{ id: string; name: string }>;
13
15
  defaults: {
14
16
  model: string;
15
17
  language: string;
@@ -19,13 +21,13 @@ interface ConfigData {
19
21
  };
20
22
  }
21
23
 
22
- const MODELS = [
23
- "anthropic/claude-sonnet-4.6",
24
- "anthropic/claude-sonnet-4-20250514",
25
- "openai/gpt-4.1",
26
- "openai/o3",
27
- "google/gemini-2.5-pro-preview-06-05",
28
- ];
24
+ interface ModelInfo {
25
+ id: string;
26
+ name: string;
27
+ provider?: string;
28
+ created?: number;
29
+ contextLength?: number;
30
+ }
29
31
 
30
32
  const AGENTS = [
31
33
  { value: "", label: "Auto" },
@@ -39,17 +41,26 @@ const LANGUAGES = [
39
41
  "Spanish", "French", "German", "Portuguese",
40
42
  ];
41
43
 
42
- export function SettingsPanel({ onClose }: { onClose: () => void }) {
44
+ export function SettingsPanel({ onClose, onFeaturesChange }: { onClose: () => void; onFeaturesChange?: () => void }) {
43
45
  const [config, setConfig] = useState<ConfigData | null>(null);
44
46
  const [saving, setSaving] = useState(false);
45
47
  const [saved, setSaved] = useState(false);
46
48
  const [apiKeyInput, setApiKeyInput] = useState("");
47
49
  const [showApiKeyField, setShowApiKeyField] = useState(false);
50
+ const [models, setModels] = useState<ModelInfo[]>([]);
48
51
 
49
52
  useEffect(() => {
50
53
  fetch("/api/config")
51
54
  .then((r) => r.json())
52
- .then((data) => setConfig(data as ConfigData))
55
+ .then((data) => {
56
+ setConfig(data as ConfigData);
57
+ if ((data as ConfigData).has_api_key) {
58
+ fetch("/api/models")
59
+ .then((r) => r.json())
60
+ .then((m) => setModels(m as ModelInfo[]))
61
+ .catch(() => {});
62
+ }
63
+ })
53
64
  .catch(() => {});
54
65
  }, []);
55
66
 
@@ -67,10 +78,11 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
67
78
  setConfig(data as ConfigData);
68
79
  setSaved(true);
69
80
  setTimeout(() => setSaved(false), 2000);
81
+ if (update.enabled_plugins !== undefined) onFeaturesChange?.();
70
82
  } finally {
71
83
  setSaving(false);
72
84
  }
73
- }, []);
85
+ }, [onFeaturesChange]);
74
86
 
75
87
  if (!config) {
76
88
  return (
@@ -162,15 +174,15 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
162
174
 
163
175
  <Section title="Model">
164
176
  <Row label="LLM">
165
- <select
166
- value={config.model}
167
- onChange={(e) => save({ model: e.target.value })}
168
- className="h-7 rounded-md border bg-background px-2 text-[11px] font-mono focus:outline-none focus:border-foreground/20 cursor-pointer"
169
- >
170
- {MODELS.map((m) => (
171
- <option key={m} value={m}>{m.split("/").pop()}</option>
172
- ))}
173
- </select>
177
+ {config.has_api_key ? (
178
+ <ModelSelect
179
+ value={config.model}
180
+ models={models}
181
+ onChange={(id: string) => save({ model: id })}
182
+ />
183
+ ) : (
184
+ <span className="text-[11px] text-muted-foreground/40">Set API key first</span>
185
+ )}
174
186
  </Row>
175
187
  <Row label="Agent">
176
188
  <div className="flex gap-px rounded-md border p-0.5">
@@ -217,11 +229,151 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
217
229
  <NumberInput value={config.concurrency} onChange={(v) => save({ concurrency: v })} />
218
230
  </Row>
219
231
  </Section>
232
+
233
+ {config.available_plugins.length > 0 && (
234
+ <Section title="Plugins">
235
+ <div className="space-y-1">
236
+ {config.available_plugins.map((p) => {
237
+ const enabled = config.enabled_plugins.includes(p.id);
238
+ return (
239
+ <div key={p.id} className="flex items-center justify-between gap-3 py-1.5">
240
+ <div className="flex items-center gap-2 min-w-0">
241
+ <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${enabled ? "bg-green-500" : "bg-muted-foreground/20"}`} />
242
+ <span className="text-[11px] truncate">{p.name}</span>
243
+ </div>
244
+ <button
245
+ type="button"
246
+ onClick={() => {
247
+ const next = enabled
248
+ ? config.enabled_plugins.filter((id) => id !== p.id)
249
+ : [...config.enabled_plugins, p.id];
250
+ save({ enabled_plugins: next });
251
+ }}
252
+ className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors ${
253
+ enabled ? "bg-foreground" : "bg-muted"
254
+ }`}
255
+ >
256
+ <span className={`inline-block h-3 w-3 rounded-full bg-background transition-transform ${
257
+ enabled ? "translate-x-3.5" : "translate-x-0.5"
258
+ }`} />
259
+ </button>
260
+ </div>
261
+ );
262
+ })}
263
+ </div>
264
+ </Section>
265
+ )}
220
266
  </div>
221
267
  </div>
222
268
  );
223
269
  }
224
270
 
271
+ function ModelSelect({ value, models: initialModels, onChange }: { value: string; models: ModelInfo[]; onChange: (id: string) => void }) {
272
+ const [open, setOpen] = useState(false);
273
+ const [search, setSearch] = useState("");
274
+ const [models, setModels] = useState<ModelInfo[]>(initialModels);
275
+ const [loading, setLoading] = useState(false);
276
+ const ref = useRef<HTMLDivElement>(null);
277
+ const inputRef = useRef<HTMLInputElement>(null);
278
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
279
+
280
+ useEffect(() => { setModels(initialModels); }, [initialModels]);
281
+
282
+ useEffect(() => {
283
+ if (!open) return;
284
+ const handler = (e: MouseEvent) => {
285
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
286
+ };
287
+ document.addEventListener("mousedown", handler);
288
+ return () => document.removeEventListener("mousedown", handler);
289
+ }, [open]);
290
+
291
+ const fetchModels = useCallback((q: string) => {
292
+ setLoading(true);
293
+ fetch(`/api/models${q ? `?q=${encodeURIComponent(q)}` : ""}`)
294
+ .then((r) => r.json())
295
+ .then((data) => setModels(data as ModelInfo[]))
296
+ .catch(() => {})
297
+ .finally(() => setLoading(false));
298
+ }, []);
299
+
300
+ useEffect(() => {
301
+ if (open) {
302
+ setSearch("");
303
+ fetchModels("");
304
+ setTimeout(() => inputRef.current?.focus(), 0);
305
+ }
306
+ }, [open, fetchModels]);
307
+
308
+ const handleSearch = useCallback((q: string) => {
309
+ setSearch(q);
310
+ if (debounceRef.current) clearTimeout(debounceRef.current);
311
+ debounceRef.current = setTimeout(() => fetchModels(q), 300);
312
+ }, [fetchModels]);
313
+
314
+ const displayName = value.split("/").pop() ?? value;
315
+
316
+ return (
317
+ <div ref={ref} className="relative">
318
+ <button
319
+ type="button"
320
+ onClick={() => setOpen(!open)}
321
+ className="flex items-center gap-1.5 h-7 rounded-md border bg-background px-2.5 text-[11px] font-mono hover:border-foreground/20 transition-colors max-w-[220px]"
322
+ >
323
+ <span className="truncate flex-1 text-left">{displayName}</span>
324
+ <ChevronDown className={`h-3 w-3 text-muted-foreground/40 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
325
+ </button>
326
+ {open && (
327
+ <div className="absolute right-0 top-8 z-50 w-[320px] rounded-lg border bg-background shadow-lg">
328
+ <div className="p-1.5 border-b">
329
+ <div className="flex items-center gap-1.5 px-2 h-7 rounded-md bg-muted/50">
330
+ <Search className="h-3 w-3 text-muted-foreground/40 shrink-0" />
331
+ <input
332
+ ref={inputRef}
333
+ type="text"
334
+ value={search}
335
+ onChange={(e) => handleSearch(e.target.value)}
336
+ placeholder="Search models..."
337
+ className="flex-1 bg-transparent text-[11px] focus:outline-none placeholder:text-muted-foreground/30"
338
+ />
339
+ {loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/30 shrink-0" />}
340
+ </div>
341
+ </div>
342
+ <div className="max-h-[280px] overflow-y-auto p-1">
343
+ {models.length === 0 && !loading && (
344
+ <div className="px-2 py-3 text-center text-[11px] text-muted-foreground/40">No models found</div>
345
+ )}
346
+ {models.slice(0, 80).map((m, i) => {
347
+ const isSelected = m.id === value;
348
+ const provider = m.id.split("/")[0] ?? "";
349
+ const name = m.id.split("/").slice(1).join("/");
350
+ const prevProvider = i > 0 ? models[i - 1]!.id.split("/")[0] : null;
351
+ const showHeader = provider !== prevProvider;
352
+ return (
353
+ <div key={m.id}>
354
+ {showHeader && (
355
+ <div className="px-2 pt-2 pb-1 text-[10px] font-medium text-muted-foreground/30 uppercase tracking-wider">{provider}</div>
356
+ )}
357
+ <button
358
+ type="button"
359
+ onClick={() => { onChange(m.id); setOpen(false); }}
360
+ className={`w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
361
+ isSelected ? "bg-accent" : "hover:bg-accent/50"
362
+ }`}
363
+ >
364
+ <span className="text-[11px] font-mono truncate flex-1">{name}</span>
365
+ {isSelected && <Check className="h-3 w-3 text-foreground shrink-0" />}
366
+ </button>
367
+ </div>
368
+ );
369
+ })}
370
+ </div>
371
+ </div>
372
+ )}
373
+ </div>
374
+ );
375
+ }
376
+
225
377
  function Section({ title, children }: { title: string; children: React.ReactNode }) {
226
378
  return (
227
379
  <div>
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
3
  import type { NewprOutput } from "../../../types/output.ts";
4
+ import { sendNotification } from "../lib/notify.ts";
4
5
 
5
6
  export type BgStatus = "running" | "done" | "error";
6
7
 
@@ -89,19 +90,22 @@ export function useBackgroundAnalyses() {
89
90
  try {
90
91
  const res = await fetch(`/api/analysis/${sessionId}`);
91
92
  const data = (await res.json()) as { result?: NewprOutput; historyId?: string };
92
- setAnalyses((prev) =>
93
- prev.map((a) =>
94
- a.sessionId === sessionId
95
- ? { ...a, status: "done", result: data.result, historyId: data.historyId }
96
- : a,
97
- ),
98
- );
93
+ setAnalyses((prev) => {
94
+ const a = prev.find((x) => x.sessionId === sessionId);
95
+ sendNotification("Analysis complete", a?.prTitle ?? prInput);
96
+ return prev.map((x) =>
97
+ x.sessionId === sessionId
98
+ ? { ...x, status: "done" as const, result: data.result, historyId: data.historyId }
99
+ : x,
100
+ );
101
+ });
99
102
  } catch {
100
- setAnalyses((prev) =>
101
- prev.map((a) =>
102
- a.sessionId === sessionId ? { ...a, status: "done" } : a,
103
- ),
104
- );
103
+ setAnalyses((prev) => {
104
+ sendNotification("Analysis complete", prInput);
105
+ return prev.map((a) =>
106
+ a.sessionId === sessionId ? { ...a, status: "done" as const } : a,
107
+ );
108
+ });
105
109
  }
106
110
  });
107
111
 
@@ -110,6 +114,7 @@ export function useBackgroundAnalyses() {
110
114
  eventSourcesRef.current.delete(sessionId);
111
115
  let msg = "Analysis failed";
112
116
  try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
117
+ sendNotification("Analysis failed", msg);
113
118
  setAnalyses((prev) =>
114
119
  prev.map((a) =>
115
120
  a.sessionId === sessionId ? { ...a, status: "error", error: msg } : a,
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useCallback, useSyncExternalStore } from "react";
2
+ import { sendNotification } from "../lib/notify.ts";
2
3
  import type { ChatMessage, ChatToolCall, ChatSegment } from "../../../types/output.ts";
3
4
 
4
5
  interface ChatSessionState {
@@ -24,6 +25,12 @@ class ChatStore {
24
25
  return s;
25
26
  }
26
27
 
28
+ private update(sessionId: string, patch: Partial<ChatSessionState>) {
29
+ const s = this.getOrCreate(sessionId);
30
+ this.sessions.set(sessionId, { ...s, ...patch });
31
+ this.notify();
32
+ }
33
+
27
34
  private notify() {
28
35
  for (const l of this.listeners) l();
29
36
  }
@@ -55,12 +62,10 @@ class ChatStore {
55
62
  try {
56
63
  const res = await fetch(`/api/sessions/${sessionId}/chat`);
57
64
  const data = await res.json() as ChatMessage[];
58
- s.messages = data;
59
- s.loaded = true;
65
+ this.update(sessionId, { messages: data, loaded: true });
60
66
  } catch {
61
- s.loaded = true;
67
+ this.update(sessionId, { loaded: true });
62
68
  }
63
- this.notify();
64
69
  }
65
70
 
66
71
  async sendMessage(sessionId: string, text: string): Promise<void> {
@@ -68,10 +73,7 @@ class ChatStore {
68
73
  if (s.loading) return;
69
74
 
70
75
  const userMsg: ChatMessage = { role: "user", content: text, timestamp: new Date().toISOString() };
71
- s.messages = [...s.messages, userMsg];
72
- s.loading = true;
73
- s.streaming = { segments: [] };
74
- this.notify();
76
+ this.update(sessionId, { messages: [...s.messages, userMsg], loading: true, streaming: { segments: [] } });
75
77
 
76
78
  const controller = new AbortController();
77
79
  this.abortControllers.set(sessionId, controller);
@@ -122,23 +124,20 @@ class ChatStore {
122
124
  } else {
123
125
  orderedSegments.push({ type: "text", content: data.content ?? "" });
124
126
  }
125
- s.streaming = { segments: [...orderedSegments] };
126
- this.notify();
127
+ this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
127
128
  break;
128
129
  }
129
130
  case "tool_call": {
130
131
  const tc: ChatToolCall = { id: data.id, name: data.name, arguments: data.arguments ?? {} };
131
132
  allToolCalls.push(tc);
132
133
  orderedSegments.push({ type: "tool_call", toolCall: tc });
133
- s.streaming = { segments: [...orderedSegments], activeToolName: data.name };
134
- this.notify();
134
+ this.update(sessionId, { streaming: { segments: [...orderedSegments], activeToolName: data.name } });
135
135
  break;
136
136
  }
137
137
  case "tool_result": {
138
138
  const tc = allToolCalls.find((c) => c.id === data.id);
139
139
  if (tc) tc.result = data.result;
140
- s.streaming = { segments: [...orderedSegments] };
141
- this.notify();
140
+ this.update(sessionId, { streaming: { segments: [...orderedSegments] } });
142
141
  break;
143
142
  }
144
143
  case "done": break;
@@ -151,26 +150,31 @@ class ChatStore {
151
150
  }
152
151
  }
153
152
 
154
- s.messages = [...s.messages, {
155
- role: "assistant",
156
- content: fullText,
157
- toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
158
- segments: orderedSegments.length > 0 ? orderedSegments : undefined,
159
- timestamp: new Date().toISOString(),
160
- }];
161
- } catch (err) {
162
- if ((err as Error).name !== "AbortError") {
163
- s.messages = [...s.messages, {
153
+ const cur = this.getOrCreate(sessionId);
154
+ this.update(sessionId, {
155
+ messages: [...cur.messages, {
164
156
  role: "assistant",
165
- content: `Error: ${err instanceof Error ? err.message : String(err)}`,
157
+ content: fullText,
158
+ toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
159
+ segments: orderedSegments.length > 0 ? orderedSegments : undefined,
166
160
  timestamp: new Date().toISOString(),
167
- }];
161
+ }],
162
+ });
163
+ sendNotification("Chat response ready", fullText.slice(0, 100));
164
+ } catch (err) {
165
+ if ((err as Error).name !== "AbortError") {
166
+ const cur = this.getOrCreate(sessionId);
167
+ this.update(sessionId, {
168
+ messages: [...cur.messages, {
169
+ role: "assistant",
170
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
171
+ timestamp: new Date().toISOString(),
172
+ }],
173
+ });
168
174
  }
169
175
  } finally {
170
- s.loading = false;
171
- s.streaming = null;
176
+ this.update(sessionId, { loading: false, streaming: null });
172
177
  this.abortControllers.delete(sessionId);
173
- this.notify();
174
178
  }
175
179
  }
176
180
 
@@ -180,8 +184,7 @@ class ChatStore {
180
184
  if (lastAssistantIdx === -1) return;
181
185
  const lastUserIdx = s.messages.slice(0, lastAssistantIdx).findLastIndex((m) => m.role === "user");
182
186
  const removeFrom = lastUserIdx >= 0 ? lastUserIdx : lastAssistantIdx;
183
- s.messages = s.messages.slice(0, removeFrom);
184
- this.notify();
187
+ this.update(sessionId, { messages: s.messages.slice(0, removeFrom) });
185
188
  await fetch(`/api/sessions/${sessionId}/chat/undo`, { method: "POST" }).catch(() => {});
186
189
  }
187
190
  }
@@ -1,19 +1,22 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useCallback } from "react";
2
2
 
3
3
  interface Features {
4
4
  cartoon: boolean;
5
5
  version: string;
6
+ enabledPlugins: string[];
6
7
  }
7
8
 
8
- export function useFeatures(): Features {
9
- const [features, setFeatures] = useState<Features>({ cartoon: false, version: "" });
9
+ export function useFeatures(): Features & { refresh: () => void } {
10
+ const [features, setFeatures] = useState<Features>({ cartoon: false, version: "", enabledPlugins: [] });
10
11
 
11
- useEffect(() => {
12
+ const refresh = useCallback(() => {
12
13
  fetch("/api/features")
13
14
  .then((r) => r.json())
14
15
  .then((data) => setFeatures(data as Features))
15
16
  .catch(() => {});
16
17
  }, []);
17
18
 
18
- return features;
19
+ useEffect(() => { refresh(); }, [refresh]);
20
+
21
+ return { ...features, refresh };
19
22
  }
@@ -0,0 +1,41 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export interface OutdatedInfo {
4
+ outdated: boolean;
5
+ currentTitle?: string;
6
+ currentState?: string;
7
+ analyzedAt?: string;
8
+ currentUpdatedAt?: string;
9
+ }
10
+
11
+ export function useOutdatedCheck(sessionId?: string | null): OutdatedInfo | null {
12
+ const [info, setInfo] = useState<OutdatedInfo | null>(null);
13
+
14
+ useEffect(() => {
15
+ setInfo(null);
16
+ if (!sessionId) return;
17
+ fetch(`/api/sessions/${sessionId}/outdated`)
18
+ .then((r) => r.json())
19
+ .then((data) => {
20
+ const d = data as {
21
+ outdated?: boolean;
22
+ current_title?: string;
23
+ current_state?: string;
24
+ analyzed_at?: string;
25
+ current_updated_at?: string;
26
+ };
27
+ if (d.outdated !== undefined) {
28
+ setInfo({
29
+ outdated: d.outdated,
30
+ currentTitle: d.current_title,
31
+ currentState: d.current_state,
32
+ analyzedAt: d.analyzed_at,
33
+ currentUpdatedAt: d.current_updated_at,
34
+ });
35
+ }
36
+ })
37
+ .catch(() => {});
38
+ }, [sessionId]);
39
+
40
+ return info;
41
+ }
@@ -0,0 +1,21 @@
1
+ let permissionRequested = false;
2
+
3
+ export function requestNotificationPermission(): void {
4
+ if (permissionRequested || typeof Notification === "undefined") return;
5
+ permissionRequested = true;
6
+ if (Notification.permission === "default") {
7
+ Notification.requestPermission();
8
+ }
9
+ }
10
+
11
+ export function sendNotification(title: string, body?: string): void {
12
+ if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
13
+ if (document.hasFocus()) return;
14
+ try {
15
+ new Notification(title, {
16
+ body,
17
+ icon: "/favicon.ico",
18
+ tag: "newpr",
19
+ });
20
+ } catch {}
21
+ }
@@ -6,6 +6,10 @@ export const SHIKI_LANGS = [
6
6
  "yaml", "html", "bash", "java", "c",
7
7
  "cpp", "ruby", "php", "swift", "kotlin",
8
8
  "sql", "markdown", "toml", "xml",
9
+ "csharp", "dart", "lua", "zig", "graphql",
10
+ "dockerfile", "prisma", "svelte", "vue",
11
+ "scss", "less", "r", "scala", "elixir",
12
+ "haskell", "ocaml", "perl",
9
13
  ] as const;
10
14
 
11
15
  export type ShikiLang = (typeof SHIKI_LANGS)[number];
@@ -35,20 +39,41 @@ const EXT_TO_LANG: Record<string, string> = {
35
39
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
36
40
  py: "python", pyi: "python",
37
41
  go: "go", rs: "rust",
38
- css: "css", scss: "css", less: "css",
42
+ css: "css", scss: "scss", less: "less",
39
43
  json: "json", jsonc: "json",
40
44
  yaml: "yaml", yml: "yaml",
41
- html: "html", htm: "html", vue: "html", svelte: "html",
45
+ html: "html", htm: "html",
46
+ vue: "vue", svelte: "svelte",
42
47
  sh: "bash", bash: "bash", zsh: "bash",
43
48
  java: "java", kt: "kotlin",
44
49
  c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp",
50
+ cs: "csharp",
45
51
  rb: "ruby", php: "php", swift: "swift",
46
52
  sql: "sql", md: "markdown", mdx: "markdown",
47
- toml: "toml",
53
+ toml: "toml", xml: "xml",
54
+ dart: "dart", lua: "lua", zig: "zig",
55
+ graphql: "graphql", gql: "graphql",
56
+ prisma: "prisma",
57
+ dockerfile: "dockerfile",
58
+ r: "r", R: "r",
59
+ scala: "scala", sc: "scala",
60
+ ex: "elixir", exs: "elixir",
61
+ hs: "haskell", ml: "ocaml",
62
+ pl: "perl", pm: "perl",
63
+ };
64
+
65
+ const NAME_TO_LANG: Record<string, string> = {
66
+ Dockerfile: "dockerfile",
67
+ Makefile: "bash",
68
+ Gemfile: "ruby",
69
+ Rakefile: "ruby",
48
70
  };
49
71
 
50
72
  export function detectShikiLang(filePath: string): ShikiLang | null {
51
- const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
73
+ const fileName = filePath.split("/").pop() ?? "";
74
+ const nameLang = NAME_TO_LANG[fileName];
75
+ if (nameLang) return nameLang as ShikiLang;
76
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
52
77
  return (EXT_TO_LANG[ext] as ShikiLang | undefined) ?? null;
53
78
  }
54
79