docs-i18n 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. package/dist/{assemble-IOHQYYHI.js → assemble-ZHDLGVTL.js} +3 -4
  2. package/dist/chunk-I74LIORX.js +11211 -0
  3. package/dist/{chunk-QSVWLTGQ.js → chunk-OSMPWXSQ.js} +1 -1
  4. package/dist/{chunk-AKLW2MUS.js → chunk-PHDMD6EM.js} +29 -7
  5. package/dist/cli.js +6 -7
  6. package/dist/{rescan-VB2PILB2.js → rescan-OJTVWDAP.js} +2 -3
  7. package/dist/server-HNVJP43X.js +2742 -0
  8. package/dist/{status-EWQEACVF.js → status-ZG7F3FRT.js} +1 -2
  9. package/dist/translate-2PCYIWIG.js +14531 -0
  10. package/package.json +3 -2
  11. package/src/admin/index.html +13 -0
  12. package/src/admin/server/index.ts +88 -0
  13. package/src/admin/server/routes/jobs.ts +113 -0
  14. package/src/admin/server/routes/models.ts +87 -0
  15. package/src/admin/server/routes/status.ts +57 -0
  16. package/src/admin/server/services/job-manager.ts +184 -0
  17. package/src/admin/server/services/status.ts +183 -0
  18. package/src/admin/ui/App.tsx +326 -0
  19. package/src/admin/ui/components/FileList.tsx +438 -0
  20. package/src/admin/ui/components/JobDialog.tsx +360 -0
  21. package/src/admin/ui/components/JobPanel.tsx +134 -0
  22. package/src/admin/ui/components/LangGrid.tsx +54 -0
  23. package/src/admin/ui/components/Preview.tsx +369 -0
  24. package/src/admin/ui/components/ProgressBar.tsx +21 -0
  25. package/src/admin/ui/lib/api.ts +154 -0
  26. package/src/admin/ui/lib/flags.ts +30 -0
  27. package/src/admin/ui/main.tsx +19 -0
  28. package/src/admin/ui/styles.css +1096 -0
  29. package/src/admin/vite.config.ts +7 -0
  30. package/dist/build-4EQEL4NI.js +0 -12
  31. package/dist/build2-3W5WMFHZ.js +0 -4901
  32. package/dist/chunk-3YNFMSJH.js +0 -30
  33. package/dist/chunk-55MBYBVK.js +0 -368
  34. package/dist/chunk-FYDB7MZX.js +0 -38944
  35. package/dist/chunk-O35QHRY6.js +0 -6
  36. package/dist/chunk-PTIH4GGE.js +0 -44
  37. package/dist/chunk-SUIDX6IZ.js +0 -122
  38. package/dist/chunk-VKKNQBDN.js +0 -6487
  39. package/dist/dist-6C32URTL.js +0 -19
  40. package/dist/dist-HOWMMQFV.js +0 -6677
  41. package/dist/false-JGP4AGWN.js +0 -7
  42. package/dist/main-QVE5TVA3.js +0 -2505
  43. package/dist/node-4GLCLDJ6.js +0 -875
  44. package/dist/node-NUDVMOF2.js +0 -129
  45. package/dist/postcss-3SK7VUC2.js +0 -5886
  46. package/dist/postcss-import-JD46KA2Z.js +0 -458
  47. package/dist/prompt-BYQIwEjg-TG7DLENB.js +0 -915
  48. package/dist/server-ER56DGPR.js +0 -548
  49. package/dist/translate-F3AQFN6X.js +0 -707
@@ -0,0 +1,369 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { api, type FileBlock } from '../lib/api';
4
+ import { FLAGS } from '../lib/flags';
5
+
6
+ interface ContextMenuState {
7
+ x: number;
8
+ y: number;
9
+ md5: string;
10
+ type: string;
11
+ }
12
+
13
+ interface Heading {
14
+ id: string;
15
+ level: number;
16
+ text: string;
17
+ }
18
+
19
+ function extractHeading(text: string, prefix: string, idx: number) {
20
+ const firstLine = text.split('\n')[0];
21
+ const m = firstLine.match(/^(#{1,6})\s+(.+)/);
22
+ if (!m) return null;
23
+ const clean = m[2]
24
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
25
+ .replace(/\[]\(#[^)]*\)/g, '')
26
+ .replace(/[`*[\]]/g, '')
27
+ .trim();
28
+ return { id: `${prefix}-h-${idx}`, level: m[1].length, text: clean };
29
+ }
30
+
31
+ function getHeadings(
32
+ blocks: FileBlock[],
33
+ prefix: string,
34
+ useTranslation = false,
35
+ ): Heading[] {
36
+ const headings: Heading[] = [];
37
+ for (let i = 0; i < blocks.length; i++) {
38
+ const text =
39
+ useTranslation && blocks[i].translation != null
40
+ ? (blocks[i].translation as string)
41
+ : blocks[i].source;
42
+ const h = extractHeading(text, prefix, i);
43
+ if (h) headings.push(h);
44
+ }
45
+ return headings;
46
+ }
47
+
48
+ export type ViewMode = 'split' | 'en' | 'lang';
49
+
50
+ interface Props {
51
+ version: string;
52
+ lang: string;
53
+ file: string;
54
+ viewMode: ViewMode;
55
+ onViewMode: (m: ViewMode) => void;
56
+ showToc: boolean;
57
+ onToggleToc: () => void;
58
+ showNodes: boolean;
59
+ onToggleNodes: () => void;
60
+ onClose?: () => void;
61
+ }
62
+
63
+ export function Preview({
64
+ version,
65
+ lang,
66
+ file,
67
+ viewMode,
68
+ onViewMode,
69
+ showToc,
70
+ onToggleToc,
71
+ showNodes,
72
+ onToggleNodes,
73
+ onClose,
74
+ }: Props) {
75
+ const bodyRef = useRef<HTMLDivElement>(null);
76
+ const isEn = lang === 'en';
77
+ const mode = isEn ? 'en' : viewMode;
78
+
79
+ const [highlightMd5, setHighlightMd5] = useState<string | null>(null);
80
+ const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
81
+
82
+ const qc = useQueryClient();
83
+ const deleteCache = useMutation({
84
+ mutationFn: (key: string) => api.deleteCache(version, lang, key),
85
+ onSuccess: () => {
86
+ qc.invalidateQueries({ queryKey: ['fileBlocks', version, lang, file] });
87
+ qc.invalidateQueries({ queryKey: ['files'] });
88
+ qc.invalidateQueries({ queryKey: ['status'] });
89
+ setCtxMenu(null);
90
+ },
91
+ });
92
+
93
+ // Close context menu on click outside
94
+ useEffect(() => {
95
+ if (!ctxMenu) return;
96
+ const close = () => setCtxMenu(null);
97
+ window.addEventListener('click', close);
98
+ return () => window.removeEventListener('click', close);
99
+ }, [ctxMenu]);
100
+
101
+ const { data: blocksData } = useQuery({
102
+ queryKey: ['fileBlocks', version, lang, file],
103
+ queryFn: () => api.fileBlocks(version, lang, file),
104
+ });
105
+
106
+ const blocks = blocksData?.blocks ?? [];
107
+ const showEnCol = mode === 'split' || mode === 'en';
108
+ const showTransCol = !isEn && (mode === 'split' || mode === 'lang');
109
+
110
+ const enHeadings = useMemo(() => getHeadings(blocks, 'b'), [blocks]);
111
+ const transHeadings = useMemo(() => getHeadings(blocks, 'b', true), [blocks]);
112
+ const headings = showTransCol ? transHeadings : enHeadings;
113
+ const showGutter = showNodes && blocks.length > 0;
114
+
115
+ // Stats
116
+ const translatableBlocks = useMemo(
117
+ () => blocks.filter((b) => b.md5),
118
+ [blocks],
119
+ );
120
+ const translatedCount = translatableBlocks.filter(
121
+ (b) => b.translation != null,
122
+ ).length;
123
+ const totalCount = translatableBlocks.length;
124
+
125
+ function scrollToHeading(idx: number) {
126
+ const h = headings[idx];
127
+ if (h)
128
+ document
129
+ .getElementById(h.id)
130
+ ?.scrollIntoView({ behavior: 'smooth', block: 'start' });
131
+ }
132
+
133
+ function _scrollToBlock(md5: string) {
134
+ setHighlightMd5(md5);
135
+ document
136
+ .getElementById(`block-${md5.slice(0, 8)}`)
137
+ ?.scrollIntoView({ behavior: 'smooth', block: 'center' });
138
+ setTimeout(() => setHighlightMd5(null), 3000);
139
+ }
140
+
141
+ // Build grid columns
142
+ const cols: string[] = [];
143
+ if (showGutter) cols.push('4.5rem');
144
+ if (showEnCol) cols.push('1fr');
145
+ if (showTransCol) cols.push('1fr');
146
+ const gridCols = cols.join(' ');
147
+
148
+ return (
149
+ <div className="preview-wrap">
150
+ {/* Header */}
151
+ <div className="preview-hdr">
152
+ <a
153
+ className="preview-filename"
154
+ href="#"
155
+ title="Click to open in editor"
156
+ onClick={(e) => {
157
+ e.preventDefault();
158
+ fetch('/api/open-file', {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ file: `content/${version}/${file}` }),
162
+ });
163
+ }}
164
+ >
165
+ {file}
166
+ </a>
167
+ {!isEn && totalCount > 0 && (
168
+ <span className="preview-stats">
169
+ {translatedCount}/{totalCount} (
170
+ {Math.round((translatedCount / totalCount) * 100)}%)
171
+ </span>
172
+ )}
173
+ {onClose && (
174
+ <button
175
+ type="button"
176
+ className="preview-close"
177
+ onClick={onClose}
178
+ title="Close preview"
179
+ >
180
+
181
+ </button>
182
+ )}
183
+ <div className="preview-toggle">
184
+ {!isEn && (
185
+ <button
186
+ type="button"
187
+ className={showNodes ? 'active' : ''}
188
+ onClick={onToggleNodes}
189
+ title="Toggle MD5"
190
+ >
191
+ #
192
+ </button>
193
+ )}
194
+ {!isEn && (
195
+ <>
196
+ <button
197
+ type="button"
198
+ className={mode === 'split' ? 'active' : ''}
199
+ onClick={() => onViewMode('split')}
200
+ title="Side by side"
201
+ >
202
+
203
+ </button>
204
+ <button
205
+ type="button"
206
+ className={mode === 'en' ? 'active' : ''}
207
+ onClick={() => onViewMode('en')}
208
+ title="EN only"
209
+ >
210
+ {FLAGS.en}
211
+ </button>
212
+ <button
213
+ type="button"
214
+ className={mode === 'lang' ? 'active' : ''}
215
+ onClick={() => onViewMode('lang')}
216
+ title={`${lang} only`}
217
+ >
218
+ {FLAGS[lang]}
219
+ </button>
220
+ </>
221
+ )}
222
+ <button
223
+ type="button"
224
+ className={showToc ? 'active' : ''}
225
+ onClick={onToggleToc}
226
+ title="Toggle TOC"
227
+ >
228
+
229
+ </button>
230
+ </div>
231
+ </div>
232
+
233
+ {/* Content */}
234
+ <div className="preview-content-area">
235
+ <div className="preview-body-blocks" ref={bodyRef}>
236
+ {/* Column headers */}
237
+ {showGutter && (
238
+ <div
239
+ className="block-header"
240
+ style={{ gridTemplateColumns: gridCols }}
241
+ >
242
+ <span className="col-hdr gutter-hdr">MD5</span>
243
+ {showEnCol && <span className="col-hdr">EN</span>}
244
+ {showTransCol && <span className="col-hdr">{lang}</span>}
245
+ </div>
246
+ )}
247
+
248
+ {blocks.length === 0 && (
249
+ <div className="preview-loading">Loading...</div>
250
+ )}
251
+
252
+ {blocks.map((block, i) => {
253
+ const isGap = !block.md5;
254
+ const isBlank = isGap && !block.source.trim();
255
+ const isHighlighted = block.md5 && block.md5 === highlightMd5;
256
+ const h = extractHeading(block.source, 'b', i);
257
+ const blockId = block.md5
258
+ ? `block-${block.md5.slice(0, 8)}`
259
+ : undefined;
260
+
261
+ return (
262
+ <div
263
+ // biome-ignore lint/suspicious/noArrayIndexKey: static block order
264
+ key={i}
265
+ id={blockId}
266
+ className={`block-row${isHighlighted ? ' block-highlight' : ''}${isBlank ? ' block-blank' : ''}${isGap && !isBlank ? ' block-gap' : ''}`}
267
+ style={{ gridTemplateColumns: gridCols }}
268
+ >
269
+ {showGutter && (
270
+ <span className="block-gutter">
271
+ {block.md5 ? (
272
+ <code
273
+ className={`gutter-md5 ${block.translation != null ? 'done' : 'miss'}`}
274
+ title={`${block.type} · ${block.md5}`}
275
+ >
276
+ {block.md5.slice(0, 6)}
277
+ </code>
278
+ ) : isBlank ? (
279
+ <span className="gutter-blank" />
280
+ ) : (
281
+ <span className="gutter-gap">{block.type}</span>
282
+ )}
283
+ </span>
284
+ )}
285
+ {showEnCol && (
286
+ <pre
287
+ className={`block-cell${h ? ' block-heading' : ''}`}
288
+ id={h?.id}
289
+ >
290
+ {block.source}
291
+ </pre>
292
+ )}
293
+ {showTransCol && (
294
+ <pre
295
+ className={`block-cell${h ? ' block-heading' : ''}${block.md5 && block.translation == null ? ' block-missing' : ''}`}
296
+ onContextMenu={(e) => {
297
+ if (!block.md5 || isEn) return;
298
+ e.preventDefault();
299
+ setCtxMenu({
300
+ x: e.clientX,
301
+ y: e.clientY,
302
+ md5: block.md5,
303
+ type: block.type,
304
+ });
305
+ }}
306
+ >
307
+ {block.translation != null
308
+ ? block.translation
309
+ : block.source}
310
+ </pre>
311
+ )}
312
+ </div>
313
+ );
314
+ })}
315
+ </div>
316
+
317
+ {/* TOC */}
318
+ {showToc && headings.length > 0 && (
319
+ <div className="preview-toc">
320
+ <div className="preview-toc-title">On this page</div>
321
+ {headings.map((h, idx) => (
322
+ <a
323
+ key={h.id}
324
+ href={`#${h.id}`}
325
+ className={`h${h.level}`}
326
+ onClick={(e) => {
327
+ e.preventDefault();
328
+ scrollToHeading(idx);
329
+ }}
330
+ >
331
+ {h.text}
332
+ </a>
333
+ ))}
334
+ </div>
335
+ )}
336
+ </div>
337
+
338
+ {/* Context menu */}
339
+ {ctxMenu && (
340
+ <div
341
+ className="ctx-menu"
342
+ style={{ left: ctxMenu.x, top: ctxMenu.y }}
343
+ onClick={(e) => e.stopPropagation()}
344
+ >
345
+ <div className="ctx-menu-header">
346
+ <code>{ctxMenu.md5.slice(0, 12)}…</code>
347
+ <span className="ctx-menu-type">{ctxMenu.type}</span>
348
+ </div>
349
+ <button
350
+ type="button"
351
+ onClick={() => {
352
+ navigator.clipboard.writeText(ctxMenu.md5);
353
+ setCtxMenu(null);
354
+ }}
355
+ >
356
+ 📋 Copy MD5
357
+ </button>
358
+ <button
359
+ type="button"
360
+ className="ctx-menu-danger"
361
+ onClick={() => deleteCache.mutate(ctxMenu.md5)}
362
+ >
363
+ 🗑️ Delete cache
364
+ </button>
365
+ </div>
366
+ )}
367
+ </div>
368
+ );
369
+ }
@@ -0,0 +1,21 @@
1
+ export function ProgressBar({
2
+ value,
3
+ color,
4
+ height = 4,
5
+ }: {
6
+ value: number;
7
+ color?: string;
8
+ height?: number;
9
+ }) {
10
+ return (
11
+ <div className="bar-bg" style={{ height }}>
12
+ <div
13
+ className="bar-fill"
14
+ style={{
15
+ width: `${Math.min(100, value)}%`,
16
+ background: color || 'var(--accent)',
17
+ }}
18
+ />
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,154 @@
1
+ const BASE = '/api';
2
+
3
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
4
+ const res = await fetch(`${BASE}${path}`, init);
5
+ const data = await res.json();
6
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
7
+ return data as T;
8
+ }
9
+
10
+ // ── Types ──
11
+
12
+ export interface StatusOverview {
13
+ versions: string[];
14
+ langs: string[];
15
+ data: Record<
16
+ string,
17
+ {
18
+ enFileCount: number;
19
+ langs: Record<
20
+ string,
21
+ {
22
+ sections: Record<
23
+ string,
24
+ {
25
+ totalFiles: number;
26
+ translatedFiles: number;
27
+ totalNodes: number;
28
+ translatedNodes: number;
29
+ }
30
+ >;
31
+ totalFiles: number;
32
+ translatedFiles: number;
33
+ totalNodes: number;
34
+ translatedNodes: number;
35
+ }
36
+ >;
37
+ }
38
+ >;
39
+ }
40
+
41
+ export interface FileCoverage {
42
+ file: string;
43
+ total: number;
44
+ translated: number;
45
+ }
46
+
47
+ export interface FileContent {
48
+ file: string;
49
+ lang: string;
50
+ version: string;
51
+ content: string;
52
+ }
53
+
54
+ export interface FileNode {
55
+ key: string;
56
+ source: string;
57
+ type: string;
58
+ translation: string | null;
59
+ line: number;
60
+ }
61
+
62
+ export interface FileBlock {
63
+ md5: string | null;
64
+ type: string;
65
+ source: string;
66
+ translation: string | null;
67
+ }
68
+
69
+ export interface FileBlocksResponse {
70
+ file: string;
71
+ lang: string;
72
+ version: string;
73
+ blocks: FileBlock[];
74
+ }
75
+
76
+ export interface Job {
77
+ id: string;
78
+ lang: string;
79
+ version: string;
80
+ status: 'running' | 'completed' | 'failed' | 'cancelled';
81
+ startedAt: string;
82
+ finishedAt?: string;
83
+ exitCode?: number | null;
84
+ totalFiles: number;
85
+ toTranslate: number;
86
+ translatedFiles: number;
87
+ errorFiles: number;
88
+ currentFile?: string;
89
+ logLines?: string[];
90
+ }
91
+
92
+ // ── API ──
93
+
94
+ export interface Model {
95
+ id: string;
96
+ name: string;
97
+ promptPrice: number;
98
+ completionPrice: number;
99
+ contextLength: number;
100
+ maxOutput: number;
101
+ isFree: boolean;
102
+ supportsJson: boolean;
103
+ supportsTools: boolean;
104
+ provider: string;
105
+ }
106
+
107
+ export const api = {
108
+ status: () => request<StatusOverview>('/status'),
109
+
110
+ fileCoverage: (version: string, lang: string) =>
111
+ request<FileCoverage[]>(`/status/${version}/${lang}`),
112
+
113
+ fileContent: (version: string, lang: string, file: string) =>
114
+ request<FileContent>(`/status/content/${version}/${lang}/${file}`),
115
+
116
+ fileDetail: (version: string, lang: string, file: string) =>
117
+ request<FileNode[]>(
118
+ `/status/${version}/${lang}/file?path=${encodeURIComponent(file)}`,
119
+ ),
120
+
121
+ fileBlocks: (version: string, lang: string, file: string) =>
122
+ request<FileBlocksResponse>(
123
+ `/status/${version}/${lang}/blocks?path=${encodeURIComponent(file)}`,
124
+ ),
125
+
126
+ deleteCache: (version: string, lang: string, key: string) =>
127
+ request<{ deleted: string }>(
128
+ `/status/${version}/${lang}/cache?key=${encodeURIComponent(key)}`,
129
+ { method: 'DELETE' },
130
+ ),
131
+
132
+ models: () => request<Model[]>('/models'),
133
+
134
+ jobs: () => request<Job[]>('/jobs'),
135
+
136
+ createJob: (body: {
137
+ lang: string;
138
+ version: string;
139
+ max?: number;
140
+ concurrency?: number;
141
+ model?: string;
142
+ modelRotate?: string[];
143
+ md5?: boolean;
144
+ files?: string[];
145
+ }) =>
146
+ request<Job>('/jobs', {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify(body),
150
+ }),
151
+
152
+ deleteJob: (id: string) =>
153
+ request<{ ok: boolean }>(`/jobs/${id}`, { method: 'DELETE' }),
154
+ };
@@ -0,0 +1,30 @@
1
+ export const FLAGS: Record<string, string> = {
2
+ en: '🇺🇸',
3
+ 'zh-hans': '🇨🇳',
4
+ 'zh-hant': '🇭🇰',
5
+ ja: '🇯🇵',
6
+ ar: '🇸🇦',
7
+ de: '🇩🇪',
8
+ es: '🇪🇸',
9
+ fr: '🇫🇷',
10
+ ru: '🇷🇺',
11
+ };
12
+
13
+ export function pctColor(pct: number) {
14
+ if (pct >= 95) return 'var(--green)';
15
+ if (pct > 50) return 'var(--yellow)';
16
+ return 'var(--red)';
17
+ }
18
+
19
+ export function statusIcon(pct: number) {
20
+ if (pct >= 100) return '🟢';
21
+ if (pct > 0) return '🟡';
22
+ return '🔴';
23
+ }
24
+
25
+ export function getSection(file: string) {
26
+ if (file.startsWith('docs/')) return 'docs';
27
+ if (file.startsWith('blog/')) return 'blog';
28
+ if (file.startsWith('learn/')) return 'learn';
29
+ return 'other';
30
+ }
@@ -0,0 +1,19 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { StrictMode } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { App } from './App';
5
+ import './styles.css';
6
+
7
+ const queryClient = new QueryClient({
8
+ defaultOptions: {
9
+ queries: { refetchOnWindowFocus: false, retry: 1 },
10
+ },
11
+ });
12
+
13
+ createRoot(document.getElementById('root')!).render(
14
+ <StrictMode>
15
+ <QueryClientProvider client={queryClient}>
16
+ <App />
17
+ </QueryClientProvider>
18
+ </StrictMode>,
19
+ );