@open-slide/core 1.0.4 → 1.0.6
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/dist/{build-DqfKmw9h.js → build-4wOJF1l4.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
- package/dist/{config-CN7J0RDO.js → config-evLWCV1-.js} +378 -222
- package/dist/{dev-jWxtWHAG.js → dev-BUr0S-Ij.js} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +1189 -0
- package/dist/{preview-CSA05Gfm.js → preview-DP_gIphz.js} +1 -1
- package/dist/types-BVvl_xup.d.ts +314 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -1
- package/src/app/app.tsx +6 -2
- package/src/app/components/asset-view.tsx +87 -64
- package/src/app/components/click-nav-zones.tsx +4 -2
- package/src/app/components/inspector/comment-widget.tsx +9 -7
- package/src/app/components/inspector/inspect-overlay.tsx +79 -17
- package/src/app/components/inspector/inspector-panel.tsx +68 -39
- package/src/app/components/inspector/inspector-provider.tsx +185 -58
- package/src/app/components/inspector/save-bar.tsx +6 -5
- package/src/app/components/panel/save-card.tsx +12 -9
- package/src/app/components/pdf-progress-toast.tsx +11 -4
- package/src/app/components/player.tsx +7 -25
- package/src/app/components/present/control-bar.tsx +17 -10
- package/src/app/components/present/help-overlay.tsx +18 -17
- package/src/app/components/present/overview-grid.tsx +6 -9
- package/src/app/components/present/use-presenter-channel.ts +3 -10
- package/src/app/components/sidebar/folder-item.tsx +16 -9
- package/src/app/components/sidebar/icon-picker.tsx +4 -5
- package/src/app/components/sidebar/sidebar.tsx +87 -25
- package/src/app/components/slide-canvas.tsx +1 -10
- package/src/app/components/style-panel/design-provider.tsx +2 -6
- package/src/app/components/style-panel/style-panel.tsx +26 -18
- package/src/app/components/theme-toggle.tsx +7 -5
- package/src/app/components/thumbnail-rail.tsx +4 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/lib/export-html.ts +1 -9
- package/src/app/lib/export-pdf.ts +0 -5
- package/src/app/lib/inspector/use-editor.ts +9 -7
- package/src/app/lib/print-ready.ts +0 -4
- package/src/app/lib/sdk.ts +1 -2
- package/src/app/lib/use-locale.ts +20 -0
- package/src/app/routes/home.tsx +90 -45
- package/src/app/routes/presenter.tsx +45 -25
- package/src/app/routes/slide.tsx +37 -24
- package/src/app/styles.css +28 -0
- package/src/app/virtual.d.ts +4 -0
- package/src/locale/en.ts +303 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +307 -0
- package/src/locale/types.ts +323 -0
- package/src/locale/zh-cn.ts +303 -0
- package/src/locale/zh-tw.ts +303 -0
|
@@ -2,6 +2,7 @@ import { Palette, X } from 'lucide-react';
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
4
4
|
import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
|
|
5
|
+
import { useLocale } from '@/lib/use-locale';
|
|
5
6
|
import { Button } from '../ui/button';
|
|
6
7
|
import { Input } from '../ui/input';
|
|
7
8
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
|
@@ -29,6 +30,7 @@ type DesignPanelProps = {
|
|
|
29
30
|
export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
30
31
|
const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
|
|
31
32
|
const { mounted, animVisible } = usePanelMount(open);
|
|
33
|
+
const t = useLocale();
|
|
32
34
|
|
|
33
35
|
if (!loaded) return null;
|
|
34
36
|
if (!mounted) return null;
|
|
@@ -43,15 +45,19 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
43
45
|
<div className="flex min-w-0 items-center gap-2">
|
|
44
46
|
<Palette className="size-3.5 text-muted-foreground" />
|
|
45
47
|
<span className="font-heading text-[12px] font-semibold tracking-tight">
|
|
46
|
-
|
|
48
|
+
{t.stylePanel.designTokens}
|
|
47
49
|
</span>
|
|
48
50
|
{!exists && (
|
|
49
51
|
<span className="rounded-[3px] border border-hairline bg-muted/60 px-1.5 py-px font-mono text-[9.5px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
50
|
-
|
|
52
|
+
{t.stylePanel.draftBadge}
|
|
51
53
|
</span>
|
|
52
54
|
)}
|
|
53
55
|
{dirty && (
|
|
54
|
-
<span
|
|
56
|
+
<span
|
|
57
|
+
className="size-1.5 rounded-full bg-brand"
|
|
58
|
+
title={t.stylePanel.unsavedTitle}
|
|
59
|
+
aria-hidden
|
|
60
|
+
/>
|
|
55
61
|
)}
|
|
56
62
|
</div>
|
|
57
63
|
<Button
|
|
@@ -59,7 +65,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
59
65
|
size="icon-sm"
|
|
60
66
|
className="text-muted-foreground hover:text-foreground"
|
|
61
67
|
onClick={onClose}
|
|
62
|
-
aria-label=
|
|
68
|
+
aria-label={t.stylePanel.closePanelAria}
|
|
63
69
|
>
|
|
64
70
|
<X className="size-3.5" />
|
|
65
71
|
</Button>
|
|
@@ -74,9 +80,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
74
80
|
)
|
|
75
81
|
}
|
|
76
82
|
>
|
|
77
|
-
<Section title=
|
|
83
|
+
<Section title={t.stylePanel.colorsSection}>
|
|
78
84
|
<ColorField
|
|
79
|
-
label=
|
|
85
|
+
label={t.stylePanel.backgroundLabel}
|
|
80
86
|
value={draft.palette.bg}
|
|
81
87
|
onChange={(v) =>
|
|
82
88
|
update((d) => {
|
|
@@ -85,7 +91,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
85
91
|
}
|
|
86
92
|
/>
|
|
87
93
|
<ColorField
|
|
88
|
-
label=
|
|
94
|
+
label={t.stylePanel.textLabel}
|
|
89
95
|
value={draft.palette.text}
|
|
90
96
|
onChange={(v) =>
|
|
91
97
|
update((d) => {
|
|
@@ -94,7 +100,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
94
100
|
}
|
|
95
101
|
/>
|
|
96
102
|
<ColorField
|
|
97
|
-
label=
|
|
103
|
+
label={t.stylePanel.accentLabel}
|
|
98
104
|
value={draft.palette.accent}
|
|
99
105
|
onChange={(v) =>
|
|
100
106
|
update((d) => {
|
|
@@ -106,9 +112,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
106
112
|
|
|
107
113
|
<Separator />
|
|
108
114
|
|
|
109
|
-
<Section title=
|
|
115
|
+
<Section title={t.stylePanel.typographySection}>
|
|
110
116
|
<FontField
|
|
111
|
-
label=
|
|
117
|
+
label={t.stylePanel.displayFontLabel}
|
|
112
118
|
value={draft.fonts.display}
|
|
113
119
|
onChange={(v) =>
|
|
114
120
|
update((d) => {
|
|
@@ -117,7 +123,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
117
123
|
}
|
|
118
124
|
/>
|
|
119
125
|
<FontField
|
|
120
|
-
label=
|
|
126
|
+
label={t.stylePanel.bodyFontLabel}
|
|
121
127
|
value={draft.fonts.body}
|
|
122
128
|
onChange={(v) =>
|
|
123
129
|
update((d) => {
|
|
@@ -126,7 +132,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
126
132
|
}
|
|
127
133
|
/>
|
|
128
134
|
<SliderField
|
|
129
|
-
label=
|
|
135
|
+
label={t.stylePanel.heroLabel}
|
|
130
136
|
value={draft.typeScale.hero}
|
|
131
137
|
min={48}
|
|
132
138
|
max={240}
|
|
@@ -139,7 +145,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
139
145
|
}
|
|
140
146
|
/>
|
|
141
147
|
<SliderField
|
|
142
|
-
label=
|
|
148
|
+
label={t.stylePanel.bodyLabel}
|
|
143
149
|
value={draft.typeScale.body}
|
|
144
150
|
min={16}
|
|
145
151
|
max={72}
|
|
@@ -155,9 +161,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
155
161
|
|
|
156
162
|
<Separator />
|
|
157
163
|
|
|
158
|
-
<Section title=
|
|
164
|
+
<Section title={t.stylePanel.shapeSection}>
|
|
159
165
|
<SliderField
|
|
160
|
-
label=
|
|
166
|
+
label={t.stylePanel.radiusLabel}
|
|
161
167
|
value={draft.radius}
|
|
162
168
|
min={0}
|
|
163
169
|
max={80}
|
|
@@ -181,6 +187,7 @@ export function DesignToggleButton({
|
|
|
181
187
|
active: boolean;
|
|
182
188
|
onToggle: () => void;
|
|
183
189
|
}) {
|
|
190
|
+
const t = useLocale();
|
|
184
191
|
if (import.meta.env.PROD) return null;
|
|
185
192
|
return (
|
|
186
193
|
<Button
|
|
@@ -188,10 +195,10 @@ export function DesignToggleButton({
|
|
|
188
195
|
variant={active ? 'default' : 'ghost'}
|
|
189
196
|
onClick={onToggle}
|
|
190
197
|
data-design-ui
|
|
191
|
-
title=
|
|
198
|
+
title={t.stylePanel.designToggleTitle}
|
|
192
199
|
>
|
|
193
200
|
<Palette className="size-3.5" />
|
|
194
|
-
<span className="hidden md:inline">
|
|
201
|
+
<span className="hidden md:inline">{t.stylePanel.designToggle}</span>
|
|
195
202
|
</Button>
|
|
196
203
|
);
|
|
197
204
|
}
|
|
@@ -247,6 +254,7 @@ function FontField({
|
|
|
247
254
|
onChange: (v: string) => void;
|
|
248
255
|
}) {
|
|
249
256
|
const matched = FONT_PRESETS.find((p) => p.value === value);
|
|
257
|
+
const tFont = useLocale();
|
|
250
258
|
return (
|
|
251
259
|
<Field label={label}>
|
|
252
260
|
<Select
|
|
@@ -266,7 +274,7 @@ function FontField({
|
|
|
266
274
|
))}
|
|
267
275
|
{!matched && (
|
|
268
276
|
<SelectItem value="__custom__" className="text-xs">
|
|
269
|
-
|
|
277
|
+
{tFont.stylePanel.fontPresetCustom}
|
|
270
278
|
</SelectItem>
|
|
271
279
|
)}
|
|
272
280
|
</SelectContent>
|
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
DropdownMenuItem,
|
|
9
9
|
DropdownMenuTrigger,
|
|
10
10
|
} from '@/components/ui/dropdown-menu';
|
|
11
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
12
|
import { cn } from '@/lib/utils';
|
|
12
13
|
|
|
13
14
|
export function ThemeToggle() {
|
|
14
15
|
const { theme, setTheme } = useTheme();
|
|
15
16
|
const [mounted, setMounted] = useState(false);
|
|
17
|
+
const t = useLocale();
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
setMounted(true);
|
|
@@ -22,8 +24,8 @@ export function ThemeToggle() {
|
|
|
22
24
|
<DropdownMenu>
|
|
23
25
|
<DropdownMenuTrigger
|
|
24
26
|
type="button"
|
|
25
|
-
aria-label=
|
|
26
|
-
title=
|
|
27
|
+
aria-label={t.themeToggle.toggleAria}
|
|
28
|
+
title={t.themeToggle.title}
|
|
27
29
|
className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative')}
|
|
28
30
|
>
|
|
29
31
|
<Sun className="size-3.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
|
@@ -35,21 +37,21 @@ export function ThemeToggle() {
|
|
|
35
37
|
data-active={mounted && theme === 'light'}
|
|
36
38
|
>
|
|
37
39
|
<Sun />
|
|
38
|
-
|
|
40
|
+
{t.themeToggle.light}
|
|
39
41
|
</DropdownMenuItem>
|
|
40
42
|
<DropdownMenuItem
|
|
41
43
|
onSelect={() => setTheme('dark')}
|
|
42
44
|
data-active={mounted && theme === 'dark'}
|
|
43
45
|
>
|
|
44
46
|
<Moon />
|
|
45
|
-
|
|
47
|
+
{t.themeToggle.dark}
|
|
46
48
|
</DropdownMenuItem>
|
|
47
49
|
<DropdownMenuItem
|
|
48
50
|
onSelect={() => setTheme('system')}
|
|
49
51
|
data-active={mounted && theme === 'system'}
|
|
50
52
|
>
|
|
51
53
|
<Monitor />
|
|
52
|
-
|
|
54
|
+
{t.themeToggle.system}
|
|
53
55
|
</DropdownMenuItem>
|
|
54
56
|
</DropdownMenuContent>
|
|
55
57
|
</DropdownMenu>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
3
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
3
4
|
import { cn } from '@/lib/utils';
|
|
4
5
|
import type { DesignSystem } from '../lib/design';
|
|
5
6
|
import type { Page } from '../lib/sdk';
|
|
@@ -27,6 +28,7 @@ export function ThumbnailRail({
|
|
|
27
28
|
orientation = 'vertical',
|
|
28
29
|
}: Props) {
|
|
29
30
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
31
|
+
const t = useLocale();
|
|
30
32
|
|
|
31
33
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
|
|
32
34
|
useEffect(() => {
|
|
@@ -55,7 +57,7 @@ export function ThumbnailRail({
|
|
|
55
57
|
type="button"
|
|
56
58
|
ref={active ? activeRef : undefined}
|
|
57
59
|
onClick={() => onSelect(i)}
|
|
58
|
-
aria-label={
|
|
60
|
+
aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
|
|
59
61
|
aria-current={active ? 'true' : undefined}
|
|
60
62
|
className={cn('group/thumb relative flex shrink-0 flex-col items-center gap-1.5')}
|
|
61
63
|
>
|
|
@@ -95,7 +97,7 @@ export function ThumbnailRail({
|
|
|
95
97
|
<ScrollArea className="h-full border-r border-hairline bg-sidebar">
|
|
96
98
|
<aside className="flex flex-col gap-2 px-3 py-3">
|
|
97
99
|
<div className="flex items-baseline justify-between px-1 pb-1">
|
|
98
|
-
<span className="eyebrow">
|
|
100
|
+
<span className="eyebrow">{t.thumbnailRail.pages}</span>
|
|
99
101
|
<span className="folio">{pages.length.toString().padStart(2, '0')}</span>
|
|
100
102
|
</div>
|
|
101
103
|
{pages.map((PageComp, i) => {
|
package/src/app/favicon.ico
CHANGED
|
Binary file
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// Exports a slide as a standalone HTML file (or a .zip bundle if the slide
|
|
2
|
-
// references bundled assets). The export is a static snapshot of each page's
|
|
3
|
-
// post-mount DOM — runtime interactivity (useState click handlers, timers,
|
|
4
|
-
// etc.) is captured at snapshot time only.
|
|
5
|
-
|
|
6
1
|
import { createElement } from 'react';
|
|
7
2
|
import { createRoot } from 'react-dom/client';
|
|
8
3
|
import { designToCssVars } from './design';
|
|
@@ -39,9 +34,7 @@ export async function exportSlideAsHtml(slide: SlideModule, slideId: string): Pr
|
|
|
39
34
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
40
35
|
const name = uniqueAssetName(absolute, usedNames);
|
|
41
36
|
assets.set(url, { name, bytes: buf });
|
|
42
|
-
} catch {
|
|
43
|
-
// ignore unreachable assets
|
|
44
|
-
}
|
|
37
|
+
} catch {}
|
|
45
38
|
}
|
|
46
39
|
|
|
47
40
|
const rewrittenPages = pagesHtml.map((html) => rewriteUrls(html, assets, 'html'));
|
|
@@ -175,7 +168,6 @@ function looksLikeAsset(url: string): boolean {
|
|
|
175
168
|
if (url.startsWith('mailto:') || url.startsWith('javascript:')) return false;
|
|
176
169
|
const abs = toAbsolute(url);
|
|
177
170
|
if (!abs) return false;
|
|
178
|
-
// Same-origin only: we can only fetch local assets.
|
|
179
171
|
try {
|
|
180
172
|
const u = new URL(abs);
|
|
181
173
|
if (u.origin !== window.location.origin) return false;
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// Exports a slide as a PDF via the browser's native print engine.
|
|
2
|
-
// Each page in `slide.default` becomes one PDF page at 1920×1080.
|
|
3
|
-
// Text stays selectable and inline SVG remains vector — `window.print()`
|
|
4
|
-
// preserves both. The user picks "Save as PDF" in the print dialog.
|
|
5
|
-
|
|
6
1
|
import { createElement } from 'react';
|
|
7
2
|
import { createRoot, type Root } from 'react-dom/client';
|
|
8
3
|
import { designToCssVars } from './design';
|
|
@@ -2,12 +2,14 @@ import { useCallback } from 'react';
|
|
|
2
2
|
|
|
3
3
|
export type EditOp =
|
|
4
4
|
| { kind: 'set-style'; key: string; value: string | null }
|
|
5
|
-
| { kind: 'set-text'; value: string }
|
|
5
|
+
| { kind: 'set-text'; value: string; prevText?: string }
|
|
6
6
|
| { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
|
|
7
7
|
| { kind: 'replace-placeholder-with-image'; assetPath: string };
|
|
8
8
|
|
|
9
9
|
export type Edit = { line: number; column: number; ops: EditOp[] };
|
|
10
10
|
|
|
11
|
+
export type EditResult = { ok: boolean; error?: string };
|
|
12
|
+
|
|
11
13
|
export class NoOpEditError extends Error {
|
|
12
14
|
constructor() {
|
|
13
15
|
super(
|
|
@@ -37,9 +39,11 @@ export function useEditor(slideId: string) {
|
|
|
37
39
|
);
|
|
38
40
|
|
|
39
41
|
// Batch many element edits into one file write and one HMR tick.
|
|
42
|
+
// Returns one result per input edit so callers can keep failed
|
|
43
|
+
// edits buffered while clearing the ones that landed.
|
|
40
44
|
const applyEdits = useCallback(
|
|
41
|
-
async (edits: Edit[]) => {
|
|
42
|
-
if (edits.length === 0) return;
|
|
45
|
+
async (edits: Edit[]): Promise<EditResult[]> => {
|
|
46
|
+
if (edits.length === 0) return [];
|
|
43
47
|
const res = await fetch('/__edit/batch', {
|
|
44
48
|
method: 'POST',
|
|
45
49
|
headers: { 'content-type': 'application/json' },
|
|
@@ -47,14 +51,12 @@ export function useEditor(slideId: string) {
|
|
|
47
51
|
});
|
|
48
52
|
const body = (await res.json().catch(() => ({}))) as {
|
|
49
53
|
error?: string;
|
|
50
|
-
|
|
51
|
-
results?: Array<{ ok: boolean; error?: string }>;
|
|
54
|
+
results?: EditResult[];
|
|
52
55
|
};
|
|
53
56
|
if (!res.ok) {
|
|
54
57
|
throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
if (failed?.error) throw new Error(failed.error);
|
|
59
|
+
return body.results ?? [];
|
|
58
60
|
},
|
|
59
61
|
[slideId],
|
|
60
62
|
);
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Helpers used by the PDF export flow to wait for the page to settle before
|
|
2
|
-
// invoking window.print(). Browser-only — no Node / headless dependency.
|
|
3
|
-
|
|
4
1
|
const DEFAULT_WAITFOR_TIMEOUT_MS = 10_000;
|
|
5
2
|
|
|
6
3
|
export async function waitForFonts(): Promise<void> {
|
|
@@ -38,7 +35,6 @@ export async function waitForDataWaitfor(
|
|
|
38
35
|
);
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
/** Returns true if `frame` has no running finite-iteration animations. */
|
|
42
38
|
export function isFrameAnimationSettled(frame: Element): boolean {
|
|
43
39
|
if (typeof document.getAnimations !== 'function') return true;
|
|
44
40
|
for (const anim of document.getAnimations()) {
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -11,8 +11,7 @@ export type SlideModule = {
|
|
|
11
11
|
default: Page[];
|
|
12
12
|
meta?: SlideMeta;
|
|
13
13
|
design?: DesignSystem;
|
|
14
|
-
// Index-aligned with `default`.
|
|
15
|
-
// page at the same position. Used by Presenter View only.
|
|
14
|
+
// Index-aligned with `default`.
|
|
16
15
|
notes?: (string | undefined)[];
|
|
17
16
|
};
|
|
18
17
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import config from 'virtual:open-slide/config';
|
|
2
|
+
import { en } from '../../locale/en';
|
|
3
|
+
import type { Locale, Plural } from '../../locale/types';
|
|
4
|
+
|
|
5
|
+
const resolved: Locale = (config.locale as Locale | undefined) ?? en;
|
|
6
|
+
|
|
7
|
+
export function useLocale(): Locale {
|
|
8
|
+
return resolved;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function format(template: string, vars: Record<string, string | number>): string {
|
|
12
|
+
return template.replace(/\{(\w+)\}/g, (m, key) => {
|
|
13
|
+
const v = vars[key];
|
|
14
|
+
return v === undefined ? m : String(v);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function plural(count: number, forms: Plural): string {
|
|
19
|
+
return count === 1 ? forms.one : forms.other;
|
|
20
|
+
}
|