@kgdiff/viewer 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +52 -2
- package/dist/compute-loader.d.ts +13 -0
- package/dist/compute-loader.d.ts.map +1 -0
- package/dist/compute-loader.js +46 -0
- package/dist/compute-loader.js.map +1 -0
- package/dist/compute.d.ts +8 -0
- package/dist/compute.d.ts.map +1 -0
- package/dist/compute.js +8 -0
- package/dist/compute.js.map +1 -0
- package/dist/controller.d.ts +199 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +1254 -0
- package/dist/controller.js.map +1 -0
- package/dist/dom.d.ts +32 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +105 -0
- package/dist/dom.js.map +1 -0
- package/dist/element.d.ts +43 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +656 -0
- package/dist/element.js.map +1 -0
- package/dist/framing.d.ts +45 -0
- package/dist/framing.d.ts.map +1 -0
- package/dist/framing.js +107 -0
- package/dist/framing.js.map +1 -0
- package/dist/hash-sync.d.ts +18 -0
- package/dist/hash-sync.d.ts.map +1 -0
- package/dist/hash-sync.js +45 -0
- package/dist/hash-sync.js.map +1 -0
- package/dist/i18n.d.ts +222 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +434 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +37 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +36 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/keyboard.d.ts +64 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +95 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/list-model.d.ts +35 -0
- package/dist/list-model.d.ts.map +1 -0
- package/dist/list-model.js +70 -0
- package/dist/list-model.js.map +1 -0
- package/dist/payload.d.ts +10 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +138 -0
- package/dist/payload.js.map +1 -0
- package/dist/pipeline.d.ts +31 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +122 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/prefs.d.ts +18 -0
- package/dist/prefs.d.ts.map +1 -0
- package/dist/prefs.js +38 -0
- package/dist/prefs.js.map +1 -0
- package/dist/registry.d.ts +3 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +3 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderer-port.d.ts +14 -0
- package/dist/renderer-port.d.ts.map +1 -0
- package/dist/renderer-port.js +16 -0
- package/dist/renderer-port.js.map +1 -0
- package/dist/replay.d.ts +55 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +147 -0
- package/dist/replay.js.map +1 -0
- package/dist/review-store.d.ts +30 -0
- package/dist/review-store.d.ts.map +1 -0
- package/dist/review-store.js +120 -0
- package/dist/review-store.js.map +1 -0
- package/dist/search.d.ts +25 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +90 -0
- package/dist/search.js.map +1 -0
- package/dist/standalone/kgdiff-viewer-compute.js +1 -0
- package/dist/standalone/kgdiff-viewer.js +5 -0
- package/dist/standalone-compute.d.ts +2 -0
- package/dist/standalone-compute.d.ts.map +1 -0
- package/dist/standalone-compute.js +8 -0
- package/dist/standalone-compute.js.map +1 -0
- package/dist/standalone.d.ts +2 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/standalone.js +18 -0
- package/dist/standalone.js.map +1 -0
- package/dist/styles.d.ts +11 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +622 -0
- package/dist/styles.js.map +1 -0
- package/dist/text-diff.d.ts +44 -0
- package/dist/text-diff.d.ts.map +1 -0
- package/dist/text-diff.js +261 -0
- package/dist/text-diff.js.map +1 -0
- package/dist/ui/context.d.ts +32 -0
- package/dist/ui/context.d.ts.map +1 -0
- package/dist/ui/context.js +49 -0
- package/dist/ui/context.js.map +1 -0
- package/dist/ui/detail.d.ts +9 -0
- package/dist/ui/detail.d.ts.map +1 -0
- package/dist/ui/detail.js +609 -0
- package/dist/ui/detail.js.map +1 -0
- package/dist/ui/header.d.ts +14 -0
- package/dist/ui/header.d.ts.map +1 -0
- package/dist/ui/header.js +195 -0
- package/dist/ui/header.js.map +1 -0
- package/dist/ui/popovers.d.ts +20 -0
- package/dist/ui/popovers.d.ts.map +1 -0
- package/dist/ui/popovers.js +180 -0
- package/dist/ui/popovers.js.map +1 -0
- package/dist/ui/sidebar.d.ts +25 -0
- package/dist/ui/sidebar.d.ts.map +1 -0
- package/dist/ui/sidebar.js +609 -0
- package/dist/ui/sidebar.js.map +1 -0
- package/dist/ui/stage.d.ts +18 -0
- package/dist/ui/stage.d.ts.map +1 -0
- package/dist/ui/stage.js +664 -0
- package/dist/ui/stage.js.map +1 -0
- package/package.json +58 -3
- package/src/compute-loader.ts +58 -0
- package/src/compute.ts +7 -0
- package/src/controller.ts +1393 -0
- package/src/dom.ts +131 -0
- package/src/element.ts +697 -0
- package/src/framing.ts +150 -0
- package/src/hash-sync.ts +54 -0
- package/src/i18n.ts +467 -0
- package/src/icons.ts +48 -0
- package/src/index.ts +171 -0
- package/src/keyboard.ts +128 -0
- package/src/list-model.ts +86 -0
- package/src/payload.ts +165 -0
- package/src/pipeline.ts +179 -0
- package/src/prefs.ts +55 -0
- package/src/registry.ts +2 -0
- package/src/renderer-port.ts +31 -0
- package/src/replay.ts +163 -0
- package/src/review-store.ts +125 -0
- package/src/search.ts +113 -0
- package/src/standalone-compute.ts +8 -0
- package/src/standalone.ts +18 -0
- package/src/styles.ts +633 -0
- package/src/text-diff.ts +263 -0
- package/src/ui/context.ts +70 -0
- package/src/ui/detail.ts +929 -0
- package/src/ui/header.ts +278 -0
- package/src/ui/popovers.ts +251 -0
- package/src/ui/sidebar.ts +752 -0
- package/src/ui/stage.ts +853 -0
package/src/framing.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera framing around floating chrome. The legend, minimap, replay transport and top chips
|
|
3
|
+
* float over the canvas; a fit or flight must keep its target clear of them without wasting the
|
|
4
|
+
* rest of the canvas. Each obstacle can be avoided through one side (an inset); all assignments
|
|
5
|
+
* are tried (4ⁿ, n ≤ ~6 visible chips) and the one that shows `bounds` largest wins, ties going
|
|
6
|
+
* to the free region closest to the canvas center. Pure; all sizes in canvas px.
|
|
7
|
+
*/
|
|
8
|
+
import type { Rect } from '@kgdiff/core';
|
|
9
|
+
|
|
10
|
+
export interface Insets {
|
|
11
|
+
readonly top: number;
|
|
12
|
+
readonly right: number;
|
|
13
|
+
readonly bottom: number;
|
|
14
|
+
readonly left: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The canvas size and the chrome floating over it (rects relative to the canvas). */
|
|
18
|
+
export interface ChromeLayout {
|
|
19
|
+
readonly width: number;
|
|
20
|
+
readonly height: number;
|
|
21
|
+
readonly obstacles: readonly Rect[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CameraTarget {
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
readonly k: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Breathing room around the target, px. */
|
|
31
|
+
export const FRAME_PADDING = 24;
|
|
32
|
+
/** Gap kept between the target and an obstacle, px. */
|
|
33
|
+
const OBSTACLE_GAP = 8;
|
|
34
|
+
/** Zooms within this ratio of the best count as equal (then the most centered region wins). */
|
|
35
|
+
const ZOOM_TOLERANCE = 0.94;
|
|
36
|
+
/** Above this many obstacles only the largest are considered (keeps the search tiny). */
|
|
37
|
+
const MAX_OBSTACLES = 6;
|
|
38
|
+
const MIN_FREE = 48;
|
|
39
|
+
|
|
40
|
+
export const NO_INSETS: Insets = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
41
|
+
|
|
42
|
+
/** Zoom that fits `bounds` into the canvas minus `insets` (unclamped; 0 when nothing fits). */
|
|
43
|
+
export function fitZoom(bounds: Rect, width: number, height: number, insets: Insets): number {
|
|
44
|
+
const w = width - insets.left - insets.right;
|
|
45
|
+
const h = height - insets.top - insets.bottom;
|
|
46
|
+
if (w < MIN_FREE || h < MIN_FREE) return 0;
|
|
47
|
+
const kw = bounds.width > 0 ? w / bounds.width : Number.POSITIVE_INFINITY;
|
|
48
|
+
const kh = bounds.height > 0 ? h / bounds.height : Number.POSITIVE_INFINITY;
|
|
49
|
+
return Math.min(kw, kh);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Insets (padding included) that keep `bounds` clear of every obstacle while showing it as
|
|
54
|
+
* large as possible, the free region as centered as possible. Obstacles outside the canvas are
|
|
55
|
+
* ignored. With no feasible assignment, only the padding is returned.
|
|
56
|
+
*/
|
|
57
|
+
export function chromeInsets(
|
|
58
|
+
bounds: Rect,
|
|
59
|
+
layout: ChromeLayout,
|
|
60
|
+
padding = FRAME_PADDING,
|
|
61
|
+
maxZoom = Number.POSITIVE_INFINITY,
|
|
62
|
+
): Insets {
|
|
63
|
+
const { width: W, height: H } = layout;
|
|
64
|
+
const base: Insets = { top: padding, right: padding, bottom: padding, left: padding };
|
|
65
|
+
const obstacles = layout.obstacles
|
|
66
|
+
.filter(
|
|
67
|
+
(r) =>
|
|
68
|
+
r.width > 0 &&
|
|
69
|
+
r.height > 0 &&
|
|
70
|
+
r.x < W &&
|
|
71
|
+
r.y < H &&
|
|
72
|
+
r.x + r.width > 0 &&
|
|
73
|
+
r.y + r.height > 0,
|
|
74
|
+
)
|
|
75
|
+
.sort((a, b) => b.width * b.height - a.width * a.height)
|
|
76
|
+
.slice(0, MAX_OBSTACLES);
|
|
77
|
+
// The inset each obstacle needs on each side to be avoided through it.
|
|
78
|
+
const needs = obstacles.map((r) => [
|
|
79
|
+
r.y + r.height + OBSTACLE_GAP, // top
|
|
80
|
+
W - r.x + OBSTACLE_GAP, // right
|
|
81
|
+
H - r.y + OBSTACLE_GAP, // bottom
|
|
82
|
+
r.x + r.width + OBSTACLE_GAP, // left
|
|
83
|
+
]);
|
|
84
|
+
const cap = (k: number): number => Math.min(k, maxZoom);
|
|
85
|
+
let best = base;
|
|
86
|
+
let bestK = -1;
|
|
87
|
+
let bestOff = Number.POSITIVE_INFINITY;
|
|
88
|
+
const count = 4 ** obstacles.length;
|
|
89
|
+
for (let code = 0; code < count; code++) {
|
|
90
|
+
let top = padding;
|
|
91
|
+
let right = padding;
|
|
92
|
+
let bottom = padding;
|
|
93
|
+
let left = padding;
|
|
94
|
+
let c = code;
|
|
95
|
+
for (const need of needs) {
|
|
96
|
+
const side = c & 3;
|
|
97
|
+
c >>= 2;
|
|
98
|
+
const v = need[side] as number;
|
|
99
|
+
if (side === 0) top = Math.max(top, v);
|
|
100
|
+
else if (side === 1) right = Math.max(right, v);
|
|
101
|
+
else if (side === 2) bottom = Math.max(bottom, v);
|
|
102
|
+
else left = Math.max(left, v);
|
|
103
|
+
}
|
|
104
|
+
const insets = { top, right, bottom, left };
|
|
105
|
+
const k = cap(fitZoom(bounds, W, H, insets));
|
|
106
|
+
if (!(k > 0)) continue;
|
|
107
|
+
// Distance of the free region's center from the canvas center.
|
|
108
|
+
const off = Math.hypot((left - right) / 2, (top - bottom) / 2);
|
|
109
|
+
const better =
|
|
110
|
+
k > bestK / ZOOM_TOLERANCE || (k >= bestK * ZOOM_TOLERANCE && off < bestOff - 0.5);
|
|
111
|
+
if (bestK < 0 || better) {
|
|
112
|
+
best = insets;
|
|
113
|
+
bestK = Math.max(k, bestK);
|
|
114
|
+
bestOff = off;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return best;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a fit lands: `bounds` centered in the canvas minus `insets`, zoom clamped to
|
|
122
|
+
* [minK, maxK] (the renderer's `fitState` with no extra safe area). Screen = (world − (x, y))·k +
|
|
123
|
+
* canvas center.
|
|
124
|
+
*/
|
|
125
|
+
export function frameTarget(
|
|
126
|
+
bounds: Rect,
|
|
127
|
+
width: number,
|
|
128
|
+
height: number,
|
|
129
|
+
insets: Insets,
|
|
130
|
+
minK: number,
|
|
131
|
+
maxK: number,
|
|
132
|
+
): CameraTarget {
|
|
133
|
+
const fit = fitZoom(bounds, width, height, insets);
|
|
134
|
+
const k = Math.min(maxK, Math.max(minK, fit > 0 ? fit : maxK));
|
|
135
|
+
const sx = insets.left + (width - insets.left - insets.right) / 2;
|
|
136
|
+
const sy = insets.top + (height - insets.top - insets.bottom) / 2;
|
|
137
|
+
const cx = bounds.x + bounds.width / 2;
|
|
138
|
+
const cy = bounds.y + bounds.height / 2;
|
|
139
|
+
return { x: cx - (sx - width / 2) / k, y: cy - (sy - height / 2) / k, k };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The part of `view` (world units) not covered by `insets` at zoom `k`. */
|
|
143
|
+
export function freeView(view: Rect, k: number, insets: Insets): Rect {
|
|
144
|
+
return {
|
|
145
|
+
x: view.x + insets.left / k,
|
|
146
|
+
y: view.y + insets.top / k,
|
|
147
|
+
width: Math.max(0, view.width - (insets.left + insets.right) / k),
|
|
148
|
+
height: Math.max(0, view.height - (insets.top + insets.bottom) / k),
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/hash-sync.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>`.
|
|
3
|
+
*
|
|
4
|
+
* Values are percent-encoded (hunk ids embed union ids, which may be IRIs containing `;`, `#`
|
|
5
|
+
* or `:`), so parsing splits on the first `:` of each `;`-separated field. Unknown fields are
|
|
6
|
+
* ignored; a malformed value drops just that field.
|
|
7
|
+
*/
|
|
8
|
+
import type { ViewerMode } from './index.js';
|
|
9
|
+
|
|
10
|
+
export interface HashState {
|
|
11
|
+
readonly hunk?: string;
|
|
12
|
+
readonly mode?: ViewerMode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PREFIX = 'kgdiff=';
|
|
16
|
+
const MODES: ReadonlySet<string> = new Set<ViewerMode>(['overlay', 'swipe', 'replay', 'changes']);
|
|
17
|
+
|
|
18
|
+
export function isViewerMode(value: unknown): value is ViewerMode {
|
|
19
|
+
return typeof value === 'string' && MODES.has(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decode(value: string): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return decodeURIComponent(value);
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse a location hash (with or without `#`). Returns null when it is not a kgdiff link. */
|
|
31
|
+
export function parseHash(hash: string): HashState | null {
|
|
32
|
+
const raw = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
33
|
+
if (!raw.startsWith(PREFIX)) return null;
|
|
34
|
+
let hunk: string | undefined;
|
|
35
|
+
let mode: ViewerMode | undefined;
|
|
36
|
+
for (const field of raw.slice(PREFIX.length).split(';')) {
|
|
37
|
+
const colon = field.indexOf(':');
|
|
38
|
+
if (colon <= 0) continue;
|
|
39
|
+
const key = field.slice(0, colon);
|
|
40
|
+
const value = decode(field.slice(colon + 1));
|
|
41
|
+
if (value === undefined || value === '') continue;
|
|
42
|
+
if (key === 'hunk') hunk = value;
|
|
43
|
+
else if (key === 'mode' && isViewerMode(value)) mode = value;
|
|
44
|
+
}
|
|
45
|
+
return { ...(hunk !== undefined ? { hunk } : {}), ...(mode !== undefined ? { mode } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Encode `state` as a hash (`#kgdiff=…`); empty state gives `#kgdiff=`. */
|
|
49
|
+
export function formatHash(state: HashState): string {
|
|
50
|
+
const fields: string[] = [];
|
|
51
|
+
if (state.hunk !== undefined) fields.push(`hunk:${encodeURIComponent(state.hunk)}`);
|
|
52
|
+
if (state.mode !== undefined) fields.push(`mode:${state.mode}`);
|
|
53
|
+
return `#${PREFIX}${fields.join(';')}`;
|
|
54
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI strings (`en`, `zh-CN`). Hunk titles and facet names come from core (`formatHunk`,
|
|
3
|
+
* `facetLabel`); everything the viewer itself says lives here.
|
|
4
|
+
*
|
|
5
|
+
* Placeholders are `{name}`. Keys ending in `.one` / `.other` are plural pairs used through
|
|
6
|
+
* `plural()`. Chinese UI quotes names with 「」, English with “ ”.
|
|
7
|
+
*/
|
|
8
|
+
import type { Locale } from '@kgdiff/core';
|
|
9
|
+
|
|
10
|
+
const en = {
|
|
11
|
+
'brand.tagline': 'git diff for knowledge graphs',
|
|
12
|
+
'header.compare': 'Compared versions',
|
|
13
|
+
'mode.label': 'View mode',
|
|
14
|
+
'mode.overlay': 'Overlay',
|
|
15
|
+
'mode.swipe': 'Swipe',
|
|
16
|
+
'mode.replay': 'Replay',
|
|
17
|
+
'mode.changes': 'Changes only',
|
|
18
|
+
'family.added': 'Added',
|
|
19
|
+
'family.removed': 'Removed',
|
|
20
|
+
'family.content': 'Content',
|
|
21
|
+
'family.structure': 'Structure',
|
|
22
|
+
'family.identity': 'Identity',
|
|
23
|
+
'family.neutral': 'Unchanged',
|
|
24
|
+
'familyHint.added': 'Only in the new version',
|
|
25
|
+
'familyHint.removed': 'Only in the old version',
|
|
26
|
+
'familyHint.content': 'Renamed, attributes, types, synonyms',
|
|
27
|
+
'familyHint.structure': 'Moved or relinked',
|
|
28
|
+
'familyHint.identity': 'Merged, split or re-keyed',
|
|
29
|
+
'familyHint.neutral': 'Unchanged context',
|
|
30
|
+
'phase.remove': 'Removed',
|
|
31
|
+
'phase.restructure': 'Restructured',
|
|
32
|
+
'phase.modify': 'Modified',
|
|
33
|
+
'phase.add': 'Added',
|
|
34
|
+
'kind.add': 'Addition',
|
|
35
|
+
'kind.remove': 'Removal',
|
|
36
|
+
'kind.merge': 'Merge',
|
|
37
|
+
'kind.split': 'Split',
|
|
38
|
+
'kind.modify': 'Edit',
|
|
39
|
+
'kind.relations': 'Relations',
|
|
40
|
+
'status.unchanged': 'Unchanged',
|
|
41
|
+
'status.added': 'Added',
|
|
42
|
+
'status.removed': 'Removed',
|
|
43
|
+
'status.modified': 'Modified',
|
|
44
|
+
'stat.added': '{n} added',
|
|
45
|
+
'stat.removed': '{n} removed',
|
|
46
|
+
'stat.modified': '{n} modified',
|
|
47
|
+
'stat.restructured': '{n} restructured',
|
|
48
|
+
'stat.filter': 'Show only these changes',
|
|
49
|
+
'stat.label': 'Change summary',
|
|
50
|
+
'action.fit': 'Fit to view',
|
|
51
|
+
'action.help': 'Keyboard shortcuts',
|
|
52
|
+
'action.sidebar': 'Toggle sidebar',
|
|
53
|
+
'action.zoomIn': 'Zoom in',
|
|
54
|
+
'action.zoomOut': 'Zoom out',
|
|
55
|
+
'action.settings': 'Display settings',
|
|
56
|
+
'menu.theme': 'Theme',
|
|
57
|
+
'menu.palette': 'Colors',
|
|
58
|
+
'menu.language': 'Language',
|
|
59
|
+
'theme.auto': 'Auto',
|
|
60
|
+
'theme.light': 'Light',
|
|
61
|
+
'theme.dark': 'Dark',
|
|
62
|
+
'palette.default': 'Default',
|
|
63
|
+
'palette.colorblind': 'Color-blind safe',
|
|
64
|
+
'sidebar.label': 'Change list',
|
|
65
|
+
'sidebar.changes': 'Changes',
|
|
66
|
+
'sidebar.search': 'Search nodes',
|
|
67
|
+
'sidebar.searchPlaceholder': 'Search nodes…',
|
|
68
|
+
'sidebar.searchClear': 'Clear search',
|
|
69
|
+
'sidebar.noResults': 'No nodes match “{q}”',
|
|
70
|
+
'sidebar.results.one': '{n} node',
|
|
71
|
+
'sidebar.results.other': '{n} nodes',
|
|
72
|
+
'sidebar.filters': 'Filter by kind of change',
|
|
73
|
+
'sidebar.clearFilters': 'Show all',
|
|
74
|
+
'sidebar.filteredEmpty': 'No changes match the filters.',
|
|
75
|
+
'sidebar.noChanges': 'No changes to review.',
|
|
76
|
+
'sidebar.sheet': 'Resize panel',
|
|
77
|
+
'sidebar.sheetValue': '{p} of the height',
|
|
78
|
+
'review.progress': '{done} of {total} reviewed',
|
|
79
|
+
'review.toggle': 'Reviewed',
|
|
80
|
+
'review.mark': 'Mark as reviewed',
|
|
81
|
+
'review.unmark': 'Mark as not reviewed',
|
|
82
|
+
'review.complete': 'All changes reviewed',
|
|
83
|
+
'detail.label': 'Change details',
|
|
84
|
+
'detail.empty': 'Select a change to see what happened.',
|
|
85
|
+
'detail.emptyHint': 'Use j / k to step through changes.',
|
|
86
|
+
'detail.prev': 'Previous change',
|
|
87
|
+
'detail.next': 'Next change',
|
|
88
|
+
'detail.changes': 'Changes',
|
|
89
|
+
'detail.changedTo': 'changed to',
|
|
90
|
+
'detail.identity': 'Overview',
|
|
91
|
+
'detail.id': 'Id',
|
|
92
|
+
'detail.types': 'Types',
|
|
93
|
+
'detail.status': 'Status',
|
|
94
|
+
'detail.name': 'Name',
|
|
95
|
+
'detail.before': 'Before',
|
|
96
|
+
'detail.after': 'After',
|
|
97
|
+
'detail.attributes': 'Attributes',
|
|
98
|
+
'detail.aliases': 'Synonyms',
|
|
99
|
+
'detail.hierarchy': 'Hierarchy',
|
|
100
|
+
'detail.relations': 'Relations',
|
|
101
|
+
'detail.composition': 'Composition',
|
|
102
|
+
'detail.evidence': 'Match evidence',
|
|
103
|
+
'detail.nodes': 'Nodes',
|
|
104
|
+
'detail.showMore': 'Show more',
|
|
105
|
+
'detail.showLess': 'Show less',
|
|
106
|
+
'detail.unchangedAttrs.one': '{n} unchanged attribute',
|
|
107
|
+
'detail.unchangedAttrs.other': '{n} unchanged attributes',
|
|
108
|
+
'detail.moreRelations.one': '{n} more relation',
|
|
109
|
+
'detail.moreRelations.other': '{n} more relations',
|
|
110
|
+
'detail.was': 'was {x}',
|
|
111
|
+
'detail.viaHint': 'via replacement hint',
|
|
112
|
+
'detail.viaHeuristic': 'heuristic',
|
|
113
|
+
'detail.confidence': '{p} confidence',
|
|
114
|
+
'detail.matchScore': '{p} match',
|
|
115
|
+
'detail.matchHint': 'Matched through a replacement hint',
|
|
116
|
+
'detail.evidence.label': 'Label',
|
|
117
|
+
'detail.evidence.neighbors': 'Neighbours',
|
|
118
|
+
'detail.evidence.types': 'Types',
|
|
119
|
+
'detail.evidence.attrs': 'Attributes',
|
|
120
|
+
'detail.suggestion': 'Possible match',
|
|
121
|
+
'detail.suggestion.ambiguous': 'ambiguous',
|
|
122
|
+
'detail.suggestion.below': 'below threshold',
|
|
123
|
+
'detail.deprecated': 'Deprecated',
|
|
124
|
+
'detail.undeprecated': 'No longer deprecated',
|
|
125
|
+
'detail.replacedBy': 'Replaced by',
|
|
126
|
+
'detail.replaces': 'Replaces',
|
|
127
|
+
'detail.unchangedNode': 'Unchanged node',
|
|
128
|
+
'detail.implicit': 'Created for a dangling relation',
|
|
129
|
+
'detail.collapsed': 'duplicate, collapsed',
|
|
130
|
+
'detail.mergedFrom': 'Merged from',
|
|
131
|
+
'detail.splitInto': 'Split into',
|
|
132
|
+
'detail.edgeAttrs': 'attributes changed',
|
|
133
|
+
'detail.showOnCanvas': 'Show on canvas',
|
|
134
|
+
'replay.label': 'Replay controls',
|
|
135
|
+
'replay.play': 'Play',
|
|
136
|
+
'replay.pause': 'Pause',
|
|
137
|
+
'replay.speed': 'Playback speed {s}',
|
|
138
|
+
'replay.position': '{i} / {n}',
|
|
139
|
+
'replay.finished': 'Replay finished',
|
|
140
|
+
'replay.restart': 'Restart',
|
|
141
|
+
'replay.scrubber': 'Replay position',
|
|
142
|
+
'swipe.divider': 'Comparison divider',
|
|
143
|
+
'focus.context': 'Context',
|
|
144
|
+
'focus.hops': 'Context hops',
|
|
145
|
+
'focus.expand': 'Click to expand',
|
|
146
|
+
'focus.expandUnavailable': 'Expanding needs the kgdiff compute add-on',
|
|
147
|
+
'focus.expandNearest': 'Show hidden context near the selection',
|
|
148
|
+
'announce.expanded': 'Expanded {label}',
|
|
149
|
+
'announce.nothingHidden': 'No hidden context',
|
|
150
|
+
'help.expand': 'Show hidden context (changes only)',
|
|
151
|
+
'legend.title': 'Legend',
|
|
152
|
+
'legend.toggle': 'Toggle legend',
|
|
153
|
+
'minimap.label': 'Overview',
|
|
154
|
+
'tooltip.relations.one': '{n} relation',
|
|
155
|
+
'tooltip.relations.other': '{n} relations',
|
|
156
|
+
'tooltip.select': 'Click to select',
|
|
157
|
+
'help.title': 'Keyboard shortcuts',
|
|
158
|
+
'help.close': 'Close',
|
|
159
|
+
'help.enter': 'Fly to the selected change',
|
|
160
|
+
'help.esc': 'Clear selection, close popovers',
|
|
161
|
+
'help.space': 'Play / pause (replay)',
|
|
162
|
+
'help.arrows': 'Step (replay) · move divider (swipe)',
|
|
163
|
+
'help.theme': 'Cycle theme',
|
|
164
|
+
'help.homeEnd': 'First / last change',
|
|
165
|
+
'state.loading': 'Preparing the diff',
|
|
166
|
+
'step.parse': 'Parsing',
|
|
167
|
+
'step.match': 'Matching nodes',
|
|
168
|
+
'step.layout': 'Laying out',
|
|
169
|
+
'step.scene': 'Drawing',
|
|
170
|
+
'state.identical.title': 'No differences',
|
|
171
|
+
'state.identical.body': 'Both versions have {nodes} and {edges}.',
|
|
172
|
+
'count.nodes.one': '{n} node',
|
|
173
|
+
'count.nodes.other': '{n} nodes',
|
|
174
|
+
'count.edges.one': '{n} edge',
|
|
175
|
+
'count.edges.other': '{n} edges',
|
|
176
|
+
'state.error.title': 'This diff could not be shown',
|
|
177
|
+
'state.error.details': 'Details',
|
|
178
|
+
'state.empty.title': 'Nothing to compare yet',
|
|
179
|
+
'state.empty.body': 'Load two versions of a graph to see what changed.',
|
|
180
|
+
'aria.canvas':
|
|
181
|
+
'Graph diff. Before: {nb} nodes, {eb} edges. After: {na} nodes, {ea} edges. {changes}.',
|
|
182
|
+
'aria.changes.one': '{n} change',
|
|
183
|
+
'aria.changes.other': '{n} changes',
|
|
184
|
+
'announce.change': 'Change {i} of {n}: {title}',
|
|
185
|
+
'announce.node': 'Node {label}, {status}',
|
|
186
|
+
'announce.mode': '{mode} view',
|
|
187
|
+
'announce.cleared': 'Selection cleared',
|
|
188
|
+
'announce.reviewed': 'Marked as reviewed. {progress}',
|
|
189
|
+
'announce.unreviewed': 'Marked as not reviewed. {progress}',
|
|
190
|
+
'announce.finished': 'Replay finished',
|
|
191
|
+
'announce.chapter': '{phase}: {n} changes',
|
|
192
|
+
'error.formats': 'Parsing text needs @kgdiff/formats (import it, or pass parsed graphs).',
|
|
193
|
+
'error.notLoaded': 'Nothing is loaded yet.',
|
|
194
|
+
} as const;
|
|
195
|
+
|
|
196
|
+
export type MessageKey = keyof typeof en;
|
|
197
|
+
export type Messages = Readonly<Record<MessageKey, string>>;
|
|
198
|
+
|
|
199
|
+
const zhCN: Messages = {
|
|
200
|
+
'brand.tagline': '知识图谱的 git diff',
|
|
201
|
+
'header.compare': '对比的版本',
|
|
202
|
+
'mode.label': '视图模式',
|
|
203
|
+
'mode.overlay': '叠加',
|
|
204
|
+
'mode.swipe': '滑动对比',
|
|
205
|
+
'mode.replay': '回放',
|
|
206
|
+
'mode.changes': '仅看变更',
|
|
207
|
+
'family.added': '新增',
|
|
208
|
+
'family.removed': '删除',
|
|
209
|
+
'family.content': '内容',
|
|
210
|
+
'family.structure': '结构',
|
|
211
|
+
'family.identity': '身份',
|
|
212
|
+
'family.neutral': '未变',
|
|
213
|
+
'familyHint.added': '只在新版本中',
|
|
214
|
+
'familyHint.removed': '只在旧版本中',
|
|
215
|
+
'familyHint.content': '改名、属性、类型、别名',
|
|
216
|
+
'familyHint.structure': '移动或关系改指向',
|
|
217
|
+
'familyHint.identity': '合并、拆分或更换 id',
|
|
218
|
+
'familyHint.neutral': '未变化的上下文',
|
|
219
|
+
'phase.remove': '删除',
|
|
220
|
+
'phase.restructure': '结构调整',
|
|
221
|
+
'phase.modify': '修改',
|
|
222
|
+
'phase.add': '新增',
|
|
223
|
+
'kind.add': '新增',
|
|
224
|
+
'kind.remove': '删除',
|
|
225
|
+
'kind.merge': '合并',
|
|
226
|
+
'kind.split': '拆分',
|
|
227
|
+
'kind.modify': '修改',
|
|
228
|
+
'kind.relations': '关系',
|
|
229
|
+
'status.unchanged': '未变',
|
|
230
|
+
'status.added': '新增',
|
|
231
|
+
'status.removed': '删除',
|
|
232
|
+
'status.modified': '修改',
|
|
233
|
+
'stat.added': '新增 {n} 处',
|
|
234
|
+
'stat.removed': '删除 {n} 处',
|
|
235
|
+
'stat.modified': '修改 {n} 处',
|
|
236
|
+
'stat.restructured': '结构调整 {n} 处',
|
|
237
|
+
'stat.filter': '只显示这类变更',
|
|
238
|
+
'stat.label': '变更概览',
|
|
239
|
+
'action.fit': '适应视图',
|
|
240
|
+
'action.help': '键盘快捷键',
|
|
241
|
+
'action.sidebar': '显示或隐藏侧栏',
|
|
242
|
+
'action.zoomIn': '放大',
|
|
243
|
+
'action.zoomOut': '缩小',
|
|
244
|
+
'action.settings': '显示设置',
|
|
245
|
+
'menu.theme': '主题',
|
|
246
|
+
'menu.palette': '配色',
|
|
247
|
+
'menu.language': '语言',
|
|
248
|
+
'theme.auto': '跟随系统',
|
|
249
|
+
'theme.light': '浅色',
|
|
250
|
+
'theme.dark': '深色',
|
|
251
|
+
'palette.default': '默认',
|
|
252
|
+
'palette.colorblind': '色觉友好',
|
|
253
|
+
'sidebar.label': '变更列表',
|
|
254
|
+
'sidebar.changes': '变更',
|
|
255
|
+
'sidebar.search': '搜索节点',
|
|
256
|
+
'sidebar.searchPlaceholder': '搜索节点…',
|
|
257
|
+
'sidebar.searchClear': '清除搜索',
|
|
258
|
+
'sidebar.noResults': '没有与「{q}」匹配的节点',
|
|
259
|
+
'sidebar.results.one': '{n} 个节点',
|
|
260
|
+
'sidebar.results.other': '{n} 个节点',
|
|
261
|
+
'sidebar.filters': '按变更类别筛选',
|
|
262
|
+
'sidebar.clearFilters': '显示全部',
|
|
263
|
+
'sidebar.filteredEmpty': '没有符合筛选条件的变更。',
|
|
264
|
+
'sidebar.noChanges': '没有需要审阅的变更。',
|
|
265
|
+
'sidebar.sheet': '调整面板高度',
|
|
266
|
+
'sidebar.sheetValue': '占高度的 {p}',
|
|
267
|
+
'review.progress': '已审阅 {done} / {total}',
|
|
268
|
+
'review.toggle': '已审阅',
|
|
269
|
+
'review.mark': '标记为已审阅',
|
|
270
|
+
'review.unmark': '标记为未审阅',
|
|
271
|
+
'review.complete': '全部变更已审阅',
|
|
272
|
+
'detail.label': '变更详情',
|
|
273
|
+
'detail.empty': '选择一项变更,查看具体发生了什么。',
|
|
274
|
+
'detail.emptyHint': '按 j / k 逐项浏览变更。',
|
|
275
|
+
'detail.prev': '上一项变更',
|
|
276
|
+
'detail.next': '下一项变更',
|
|
277
|
+
'detail.changes': '变更内容',
|
|
278
|
+
'detail.changedTo': '改为',
|
|
279
|
+
'detail.identity': '基本信息',
|
|
280
|
+
'detail.id': 'ID',
|
|
281
|
+
'detail.types': '类型',
|
|
282
|
+
'detail.status': '状态',
|
|
283
|
+
'detail.name': '名称',
|
|
284
|
+
'detail.before': '变更前',
|
|
285
|
+
'detail.after': '变更后',
|
|
286
|
+
'detail.attributes': '属性',
|
|
287
|
+
'detail.aliases': '别名',
|
|
288
|
+
'detail.hierarchy': '层级',
|
|
289
|
+
'detail.relations': '关系',
|
|
290
|
+
'detail.composition': '组成',
|
|
291
|
+
'detail.evidence': '匹配依据',
|
|
292
|
+
'detail.nodes': '节点',
|
|
293
|
+
'detail.showMore': '展开',
|
|
294
|
+
'detail.showLess': '收起',
|
|
295
|
+
'detail.unchangedAttrs.one': '{n} 个未变属性',
|
|
296
|
+
'detail.unchangedAttrs.other': '{n} 个未变属性',
|
|
297
|
+
'detail.moreRelations.one': '另外 {n} 条关系',
|
|
298
|
+
'detail.moreRelations.other': '另外 {n} 条关系',
|
|
299
|
+
'detail.was': '原为{x}',
|
|
300
|
+
'detail.viaHint': '依据替代声明',
|
|
301
|
+
'detail.viaHeuristic': '启发式推断',
|
|
302
|
+
'detail.confidence': '置信度 {p}',
|
|
303
|
+
'detail.matchScore': '匹配度 {p}',
|
|
304
|
+
'detail.matchHint': '依据替代声明匹配',
|
|
305
|
+
'detail.evidence.label': '名称',
|
|
306
|
+
'detail.evidence.neighbors': '邻居',
|
|
307
|
+
'detail.evidence.types': '类型',
|
|
308
|
+
'detail.evidence.attrs': '属性',
|
|
309
|
+
'detail.suggestion': '可能是同一节点',
|
|
310
|
+
'detail.suggestion.ambiguous': '存在歧义',
|
|
311
|
+
'detail.suggestion.below': '低于阈值',
|
|
312
|
+
'detail.deprecated': '已弃用',
|
|
313
|
+
'detail.undeprecated': '取消弃用',
|
|
314
|
+
'detail.replacedBy': '替代为',
|
|
315
|
+
'detail.replaces': '替代了',
|
|
316
|
+
'detail.unchangedNode': '未变节点',
|
|
317
|
+
'detail.implicit': '因悬空关系自动创建',
|
|
318
|
+
'detail.collapsed': '重复关系,已合并',
|
|
319
|
+
'detail.mergedFrom': '合并自',
|
|
320
|
+
'detail.splitInto': '拆分为',
|
|
321
|
+
'detail.edgeAttrs': '属性有变化',
|
|
322
|
+
'detail.showOnCanvas': '在画布中显示',
|
|
323
|
+
'replay.label': '回放控制',
|
|
324
|
+
'replay.play': '播放',
|
|
325
|
+
'replay.pause': '暂停',
|
|
326
|
+
'replay.speed': '播放速度 {s}',
|
|
327
|
+
'replay.position': '{i} / {n}',
|
|
328
|
+
'replay.finished': '回放结束',
|
|
329
|
+
'replay.restart': '重新播放',
|
|
330
|
+
'replay.scrubber': '回放进度',
|
|
331
|
+
'swipe.divider': '对比分隔线',
|
|
332
|
+
'focus.context': '上下文',
|
|
333
|
+
'focus.hops': '上下文跳数',
|
|
334
|
+
'focus.expand': '点击展开',
|
|
335
|
+
'focus.expandUnavailable': '展开需要 kgdiff 计算模块(kgdiff-viewer-compute.js)',
|
|
336
|
+
'focus.expandNearest': '展开选中项附近的隐藏节点',
|
|
337
|
+
'announce.expanded': '已展开{label}',
|
|
338
|
+
'announce.nothingHidden': '没有隐藏的节点',
|
|
339
|
+
'help.expand': '展开隐藏节点(仅看变更)',
|
|
340
|
+
'legend.title': '图例',
|
|
341
|
+
'legend.toggle': '显示或隐藏图例',
|
|
342
|
+
'minimap.label': '全局缩略图',
|
|
343
|
+
'tooltip.relations.one': '{n} 条关系',
|
|
344
|
+
'tooltip.relations.other': '{n} 条关系',
|
|
345
|
+
'tooltip.select': '点击选中',
|
|
346
|
+
'help.title': '键盘快捷键',
|
|
347
|
+
'help.close': '关闭',
|
|
348
|
+
'help.enter': '定位到选中的变更',
|
|
349
|
+
'help.esc': '取消选择,关闭弹出层',
|
|
350
|
+
'help.space': '播放 / 暂停(回放)',
|
|
351
|
+
'help.arrows': '单步(回放)· 移动分隔线(滑动对比)',
|
|
352
|
+
'help.theme': '切换主题',
|
|
353
|
+
'help.homeEnd': '第一项 / 最后一项变更',
|
|
354
|
+
'state.loading': '正在准备对比',
|
|
355
|
+
'step.parse': '解析文件',
|
|
356
|
+
'step.match': '匹配节点',
|
|
357
|
+
'step.layout': '计算布局',
|
|
358
|
+
'step.scene': '绘制',
|
|
359
|
+
'state.identical.title': '没有差异',
|
|
360
|
+
'state.identical.body': '两个版本均有 {nodes}、{edges}。',
|
|
361
|
+
'count.nodes.one': '{n} 个节点',
|
|
362
|
+
'count.nodes.other': '{n} 个节点',
|
|
363
|
+
'count.edges.one': '{n} 条关系',
|
|
364
|
+
'count.edges.other': '{n} 条关系',
|
|
365
|
+
'state.error.title': '无法显示这份对比',
|
|
366
|
+
'state.error.details': '详细信息',
|
|
367
|
+
'state.empty.title': '还没有可对比的内容',
|
|
368
|
+
'state.empty.body': '载入一个图谱的两个版本,查看发生了哪些变化。',
|
|
369
|
+
'aria.canvas':
|
|
370
|
+
'图谱对比。变更前:{nb} 个节点、{eb} 条关系;变更后:{na} 个节点、{ea} 条关系;{changes}。',
|
|
371
|
+
'aria.changes.one': '共 {n} 项变更',
|
|
372
|
+
'aria.changes.other': '共 {n} 项变更',
|
|
373
|
+
'announce.change': '第 {i} 项变更,共 {n} 项:{title}',
|
|
374
|
+
'announce.node': '节点「{label}」,{status}',
|
|
375
|
+
'announce.mode': '{mode}视图',
|
|
376
|
+
'announce.cleared': '已取消选择',
|
|
377
|
+
'announce.reviewed': '已标记为已审阅。{progress}',
|
|
378
|
+
'announce.unreviewed': '已标记为未审阅。{progress}',
|
|
379
|
+
'announce.finished': '回放结束',
|
|
380
|
+
'announce.chapter': '{phase}:{n} 项变更',
|
|
381
|
+
'error.formats': '解析文本需要 @kgdiff/formats(请引入它,或传入已解析的图)。',
|
|
382
|
+
'error.notLoaded': '尚未载入任何内容。',
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
export const MESSAGES: Readonly<Record<Locale, Messages>> = { en, 'zh-CN': zhCN };
|
|
386
|
+
|
|
387
|
+
export const LOCALES: readonly Locale[] = ['en', 'zh-CN'];
|
|
388
|
+
|
|
389
|
+
/** `zh*` → `zh-CN`, anything else → `en`. */
|
|
390
|
+
export function normalizeLocale(value: string | null | undefined): Locale | null {
|
|
391
|
+
if (!value) return null;
|
|
392
|
+
const v = value.trim().toLowerCase();
|
|
393
|
+
if (v === 'en' || v.startsWith('en-')) return 'en';
|
|
394
|
+
if (v === 'zh' || v.startsWith('zh')) return 'zh-CN';
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The browser's preferred locale among the supported ones (default `en`). */
|
|
399
|
+
export function detectLocale(): Locale {
|
|
400
|
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
401
|
+
for (const lang of nav?.languages ?? [nav?.language ?? '']) {
|
|
402
|
+
const l = normalizeLocale(lang);
|
|
403
|
+
if (l) return l;
|
|
404
|
+
}
|
|
405
|
+
return 'en';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const numberFormats = new Map<Locale, Intl.NumberFormat>();
|
|
409
|
+
|
|
410
|
+
export function formatNumber(n: number, locale: Locale): string {
|
|
411
|
+
let nf = numberFormats.get(locale);
|
|
412
|
+
if (nf === undefined) {
|
|
413
|
+
nf = new Intl.NumberFormat(locale);
|
|
414
|
+
numberFormats.set(locale, nf);
|
|
415
|
+
}
|
|
416
|
+
return nf.format(n);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function formatPercent(x: number): string {
|
|
420
|
+
return `${Math.round(Math.max(0, Math.min(1, x)) * 100)}%`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export type Params = Readonly<Record<string, string | number>>;
|
|
424
|
+
|
|
425
|
+
/** Localized message with `{name}` placeholders; numbers are formatted for the locale. */
|
|
426
|
+
export function translate(locale: Locale, key: MessageKey, params?: Params): string {
|
|
427
|
+
const template = (MESSAGES[locale] ?? MESSAGES.en)[key] ?? en[key];
|
|
428
|
+
if (!params) return template;
|
|
429
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
430
|
+
const v = params[name];
|
|
431
|
+
if (v === undefined) return match;
|
|
432
|
+
return typeof v === 'number' ? formatNumber(v, locale) : v;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
type PluralBase<K> = K extends `${infer B}.one` ? B : never;
|
|
437
|
+
export type PluralKey = PluralBase<MessageKey>;
|
|
438
|
+
|
|
439
|
+
/** Plural pair `<key>.one` / `<key>.other` with `{n}`. */
|
|
440
|
+
export function plural(locale: Locale, key: PluralKey, n: number, params?: Params): string {
|
|
441
|
+
const form = new Intl.PluralRules(locale).select(n) === 'one' ? 'one' : 'other';
|
|
442
|
+
return translate(locale, `${key}.${form}` as MessageKey, { ...params, n });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Quote a node name the way the locale does: “name” / 「name」. */
|
|
446
|
+
export function quote(text: string, locale: Locale): string {
|
|
447
|
+
return locale === 'zh-CN' ? `「${text}」` : `“${text}”`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** A translator bound to one locale. */
|
|
451
|
+
export interface Translator {
|
|
452
|
+
readonly locale: Locale;
|
|
453
|
+
t(key: MessageKey, params?: Params): string;
|
|
454
|
+
plural(key: PluralKey, n: number, params?: Params): string;
|
|
455
|
+
num(n: number): string;
|
|
456
|
+
quote(text: string): string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function translator(locale: Locale): Translator {
|
|
460
|
+
return {
|
|
461
|
+
locale,
|
|
462
|
+
t: (key, params) => translate(locale, key, params),
|
|
463
|
+
plural: (key, n, params) => plural(locale, key, n, params),
|
|
464
|
+
num: (n) => formatNumber(n, locale),
|
|
465
|
+
quote: (text) => quote(text, locale),
|
|
466
|
+
};
|
|
467
|
+
}
|