transduck 0.0.5 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.0.5",
3
+ "version": "0.1.2",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react/index.d.ts",
15
+ "default": "./dist/react/index.js"
16
+ }
17
+ },
8
18
  "bin": {
9
19
  "transduck": "./dist/cli.js"
10
20
  },
@@ -21,7 +31,21 @@
21
31
  },
22
32
  "devDependencies": {
23
33
  "@types/node": "^22.0.0",
34
+ "@types/react": "^19.0.0",
35
+ "@types/react-dom": "^19.0.0",
36
+ "@testing-library/react": "^16.0.0",
37
+ "jsdom": "^25.0.0",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
24
40
  "typescript": "^5.0.0",
25
41
  "vitest": "^2.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18.0.0",
45
+ "react-dom": ">=18.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "react": { "optional": true },
49
+ "react-dom": { "optional": true }
26
50
  }
27
51
  }
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { createHash } from 'crypto';
4
- import { readFileSync, writeFileSync, existsSync } from 'fs';
5
- import { resolve, join } from 'path';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
5
+ import { resolve, join, dirname } from 'path';
6
6
  import { Command } from 'commander';
7
7
  import { stringify as yamlStringify } from 'yaml';
8
8
  import { loadConfig } from './config.js';
@@ -540,6 +540,25 @@ program.command('init')
540
540
  targetLangs: targetsRaw.split(',').map(s => s.trim()),
541
541
  });
542
542
  console.log(output);
543
+
544
+ const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
545
+ const ask2 = (q: string): Promise<string> => new Promise(r => rl2.question(q, r));
546
+
547
+ const useReact = await ask2('Are you using React/Next.js? (y/n): ');
548
+ if (useReact.toLowerCase() === 'y') {
549
+ const routePath = await ask2('API route path (default: app/api/translations/route.ts): ');
550
+ const finalPath = join(dir, routePath.trim() || 'app/api/translations/route.ts');
551
+
552
+ // Create directory
553
+ const routeDir = dirname(finalPath);
554
+ mkdirSync(routeDir, { recursive: true });
555
+
556
+ // Write route file
557
+ writeFileSync(finalPath, `import { createTransDuckHandler } from 'transduck';\n\nexport const POST = createTransDuckHandler();\n`);
558
+ console.log(`Created ${finalPath}`);
559
+ console.log('Add TransDuckProvider to your layout — see docs: transduck/react');
560
+ }
561
+ rl2.close();
543
562
  });
544
563
 
545
564
  program.command('translate')
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ export interface TransduckConfig {
14
14
  backendModel: string;
15
15
  backendTimeout: number;
16
16
  backendMaxRetries: number;
17
+ readOnly: boolean;
17
18
  }
18
19
 
19
20
  function findConfig(): string {
@@ -58,5 +59,6 @@ export function loadConfig(path?: string): TransduckConfig {
58
59
  backendModel: raw.backend.model,
59
60
  backendTimeout: raw.backend.timeout_seconds,
60
61
  backendMaxRetries: raw.backend.max_retries,
62
+ readOnly: raw.runtime?.read_only ?? false,
61
63
  };
62
64
  }
package/src/handler.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { createHash } from 'crypto';
2
+ import { loadConfig } from './config.js';
3
+ import { TranslationStore } from './storage.js';
4
+ import { translate as backendTranslate, translatePlural as backendTranslatePlural } from './backend.js';
5
+ import { validateTranslation } from './validation.js';
6
+
7
+ function hash(text: string): string {
8
+ return createHash('sha256').update(text).digest('hex');
9
+ }
10
+
11
+ interface TranslationRequestString {
12
+ text: string;
13
+ context?: string;
14
+ }
15
+
16
+ interface TranslationRequestPlural {
17
+ one: string;
18
+ other: string;
19
+ context?: string | null;
20
+ }
21
+
22
+ export interface TranslationRequest {
23
+ language: string;
24
+ strings: TranslationRequestString[];
25
+ plurals: TranslationRequestPlural[];
26
+ }
27
+
28
+ export interface TranslationResponse {
29
+ translations: Record<string, string>;
30
+ plurals: Record<string, Record<string, string>>;
31
+ }
32
+
33
+ let _store: TranslationStore | null = null;
34
+
35
+ async function getStore(configPath?: string) {
36
+ if (!_store) {
37
+ const cfg = loadConfig(configPath);
38
+ _store = new TranslationStore(cfg.storagePath);
39
+ await _store.initialize();
40
+ }
41
+ return _store;
42
+ }
43
+
44
+ /** @internal Reset the singleton store — for testing only. */
45
+ export function _resetHandlerStore(): void {
46
+ if (_store) {
47
+ _store.close();
48
+ }
49
+ _store = null;
50
+ }
51
+
52
+ export async function handleTranslationRequest(
53
+ body: TranslationRequest,
54
+ configPath?: string,
55
+ ): Promise<TranslationResponse> {
56
+ const cfg = loadConfig(configPath);
57
+ const store = await getStore(configPath);
58
+ const targetLang = body.language.toUpperCase();
59
+
60
+ const projectContextHash = hash(cfg.projectContext);
61
+ const apiKey = process.env[cfg.apiKeyEnv];
62
+
63
+ const translations: Record<string, string> = {};
64
+ const plurals: Record<string, Record<string, string>> = {};
65
+
66
+ // Translate regular strings
67
+ for (const item of body.strings) {
68
+ const stringContextHash = hash(item.context ?? '');
69
+ const key = `${item.text}||${item.context ?? ''}`;
70
+
71
+ // Cache lookup
72
+ const cached = await store.lookup({
73
+ sourceText: item.text,
74
+ sourceLang: cfg.sourceLang,
75
+ targetLang,
76
+ projectContextHash,
77
+ stringContextHash,
78
+ });
79
+
80
+ if (cached !== null) {
81
+ translations[key] = cached;
82
+ continue;
83
+ }
84
+
85
+ // Backend call
86
+ try {
87
+ const translated = await backendTranslate({
88
+ sourceText: item.text,
89
+ sourceLang: cfg.sourceLang,
90
+ targetLang,
91
+ projectContext: cfg.projectContext,
92
+ stringContext: item.context ?? null,
93
+ apiKey: apiKey!,
94
+ model: cfg.backendModel,
95
+ timeout: cfg.backendTimeout,
96
+ maxRetries: cfg.backendMaxRetries,
97
+ });
98
+
99
+ if (validateTranslation(item.text, translated)) {
100
+ await store.insert({
101
+ sourceText: item.text,
102
+ sourceLang: cfg.sourceLang,
103
+ targetLang,
104
+ projectContextHash,
105
+ stringContextHash,
106
+ translatedText: translated,
107
+ model: cfg.backendModel,
108
+ status: 'translated',
109
+ });
110
+ translations[key] = translated;
111
+ } else {
112
+ translations[key] = item.text;
113
+ }
114
+ } catch {
115
+ translations[key] = item.text;
116
+ }
117
+ }
118
+
119
+ // Translate plurals
120
+ for (const item of body.plurals) {
121
+ const stringContextHash = hash(item.context ?? '');
122
+ const sourceKey = item.one + '\x00' + item.other;
123
+ const responseKey = `${sourceKey}||${item.context ?? ''}`;
124
+
125
+ // Cache lookup
126
+ const cachedForms = await store.lookupPlural({
127
+ sourceText: sourceKey,
128
+ sourceLang: cfg.sourceLang,
129
+ targetLang,
130
+ projectContextHash,
131
+ stringContextHash,
132
+ });
133
+
134
+ if (Object.keys(cachedForms).length > 0) {
135
+ plurals[responseKey] = cachedForms;
136
+ continue;
137
+ }
138
+
139
+ // Backend call
140
+ try {
141
+ const forms = await backendTranslatePlural({
142
+ one: item.one,
143
+ other: item.other,
144
+ sourceLang: cfg.sourceLang,
145
+ targetLang,
146
+ projectContext: cfg.projectContext,
147
+ stringContext: item.context ?? null,
148
+ apiKey: apiKey!,
149
+ model: cfg.backendModel,
150
+ timeout: cfg.backendTimeout,
151
+ maxRetries: cfg.backendMaxRetries,
152
+ });
153
+
154
+ for (const [cat, translatedText] of Object.entries(forms)) {
155
+ await store.insertPlural({
156
+ sourceText: sourceKey,
157
+ sourceLang: cfg.sourceLang,
158
+ targetLang,
159
+ projectContextHash,
160
+ stringContextHash,
161
+ pluralCategory: cat,
162
+ translatedText: translatedText as string,
163
+ model: cfg.backendModel,
164
+ status: 'translated',
165
+ });
166
+ }
167
+ plurals[responseKey] = forms;
168
+ } catch {
169
+ plurals[responseKey] = { one: item.one, other: item.other };
170
+ }
171
+ }
172
+
173
+ return { translations, plurals };
174
+ }
175
+
176
+ export function createTransDuckHandler(configPath?: string) {
177
+ return async function POST(request: Request): Promise<Response> {
178
+ try {
179
+ const body = await request.json() as TranslationRequest;
180
+ const result = await handleTranslationRequest(body, configPath);
181
+ return new Response(JSON.stringify(result), {
182
+ status: 200,
183
+ headers: { 'Content-Type': 'application/json' },
184
+ });
185
+ } catch (err) {
186
+ console.error('[transduck] Handler error:', err);
187
+ return new Response(JSON.stringify({ error: 'Translation failed' }), {
188
+ status: 500,
189
+ headers: { 'Content-Type': 'application/json' },
190
+ });
191
+ }
192
+ };
193
+ }
package/src/index.ts CHANGED
@@ -71,6 +71,11 @@ export async function ait(
71
71
  });
72
72
  if (cached !== null) return interpolateVars(cached, vars);
73
73
 
74
+ // Read-only mode: skip backend, return source text
75
+ if (cfg.readOnly) {
76
+ return interpolateVars(sourceText, vars);
77
+ }
78
+
74
79
  // In-process dedup
75
80
  const lockKey = `${sourceText}|${cfg.sourceLang}|${state.targetLang}|${projectContextHash}|${stringContextHash}`;
76
81
 
@@ -125,6 +130,8 @@ export async function ait(
125
130
  return interpolateVars(result, vars);
126
131
  }
127
132
 
133
+ export { createTransDuckHandler } from './handler.js';
134
+
128
135
  export async function aitPlural(
129
136
  one: string,
130
137
  other: string,
@@ -177,6 +184,13 @@ export async function aitPlural(
177
184
  return interpolateVars(cached[category], vars);
178
185
  }
179
186
 
187
+ // Read-only mode: skip backend, fall back to source forms
188
+ if (cfg.readOnly) {
189
+ const fallbackCategory = getPluralCategory(cfg.sourceLang, count);
190
+ const fallback = fallbackCategory === 'one' ? one : other;
191
+ return interpolateVars(fallback, vars);
192
+ }
193
+
180
194
  // Cache miss — call backend
181
195
  const apiKey = process.env[cfg.apiKeyEnv];
182
196
  try {
@@ -0,0 +1 @@
1
+ export { TransDuckProvider, t, ait, tPlural, aitPlural, useTransDuck, _resetReactState } from './provider.js';
@@ -0,0 +1,287 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
4
+
5
+ // --- Module-level state (accessed by stable t()/ait() functions) ---
6
+
7
+ interface ReactState {
8
+ language: string;
9
+ sourceLang: string;
10
+ endpoint: string;
11
+ cache: Map<string, string>;
12
+ pluralCache: Map<string, Record<string, string>>;
13
+ pendingStrings: Set<string>; // "text||context"
14
+ pendingPlurals: Set<string>; // "one\x00other||context"
15
+ triggerFetch: (() => void) | null;
16
+ }
17
+
18
+ let _state: ReactState = {
19
+ language: '',
20
+ sourceLang: 'EN',
21
+ endpoint: '/api/translations',
22
+ cache: new Map(),
23
+ pluralCache: new Map(),
24
+ pendingStrings: new Set(),
25
+ pendingPlurals: new Set(),
26
+ triggerFetch: null,
27
+ };
28
+
29
+ // --- Context for triggering child re-renders ---
30
+ // The context value (a version counter) changes when translations are loaded.
31
+ // Components that call useTransDuck() subscribe to this and re-render automatically.
32
+ const TransDuckContext = createContext<number>(0);
33
+
34
+ /**
35
+ * Hook that subscribes to translation updates. Call this in any component
36
+ * that uses t()/ait() to ensure it re-renders when translations are loaded.
37
+ */
38
+ export function useTransDuck(): void {
39
+ useContext(TransDuckContext);
40
+ }
41
+
42
+ export function _resetReactState(): void {
43
+ _state = {
44
+ language: '',
45
+ sourceLang: 'EN',
46
+ endpoint: '/api/translations',
47
+ cache: new Map(),
48
+ pluralCache: new Map(),
49
+ pendingStrings: new Set(),
50
+ pendingPlurals: new Set(),
51
+ triggerFetch: null,
52
+ };
53
+ }
54
+
55
+ function interpolateVars(text: string, vars?: Record<string, string | number> | null): string {
56
+ if (!vars) return text;
57
+ let result = text;
58
+ for (const [key, value] of Object.entries(vars)) {
59
+ result = result.replaceAll(`{${key}}`, String(value));
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // --- Stable exported functions ---
65
+
66
+ export function t(
67
+ sourceText: string,
68
+ context?: string,
69
+ vars?: Record<string, string | number>,
70
+ ): string {
71
+ // Same language — return source
72
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
73
+ return interpolateVars(sourceText, vars);
74
+ }
75
+
76
+ const key = `${sourceText}||${context ?? ''}`;
77
+
78
+ // Cache hit
79
+ const cached = _state.cache.get(key);
80
+ if (cached !== undefined) {
81
+ return interpolateVars(cached, vars);
82
+ }
83
+
84
+ // Queue for fetch (useEffect in provider will flush after render)
85
+ _state.pendingStrings.add(key);
86
+
87
+ // Return source text as fallback
88
+ return interpolateVars(sourceText, vars);
89
+ }
90
+
91
+ export function tPlural(
92
+ one: string,
93
+ other: string,
94
+ count: number,
95
+ opts?: { context?: string; vars?: Record<string, string | number> },
96
+ ): string {
97
+ const context = opts?.context;
98
+ let vars: Record<string, string | number>;
99
+ if (!opts?.vars) {
100
+ vars = { count };
101
+ } else if (!('count' in opts.vars)) {
102
+ vars = { ...opts.vars, count };
103
+ } else {
104
+ vars = { ...opts.vars };
105
+ }
106
+
107
+ // Same language
108
+ if (_state.language.toUpperCase() === _state.sourceLang.toUpperCase()) {
109
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
110
+ const form = rules.select(count) === 'one' ? one : other;
111
+ return interpolateVars(form, vars);
112
+ }
113
+
114
+ const sourceKey = `${one}\x00${other}`;
115
+ const cacheKey = `${sourceKey}||${context ?? ''}`;
116
+
117
+ // Cache hit
118
+ const cachedForms = _state.pluralCache.get(cacheKey);
119
+ if (cachedForms) {
120
+ const rules = new Intl.PluralRules(_state.language.toLowerCase());
121
+ const category = rules.select(count);
122
+ const form = cachedForms[category] ?? cachedForms['other'] ?? other;
123
+ return interpolateVars(form, vars);
124
+ }
125
+
126
+ // Queue for fetch (useEffect in provider will flush after render)
127
+ _state.pendingPlurals.add(cacheKey);
128
+
129
+ // Fallback to source form
130
+ const rules = new Intl.PluralRules(_state.sourceLang.toLowerCase());
131
+ const form = rules.select(count) === 'one' ? one : other;
132
+ return interpolateVars(form, vars);
133
+ }
134
+
135
+ // Aliases
136
+ export const ait = t;
137
+ export const aitPlural = tPlural;
138
+
139
+ // --- LocalStorage helpers ---
140
+
141
+ function loadFromLocalStorage(projectName: string, language: string): void {
142
+ try {
143
+ const storageKey = `transduck:${projectName}:${language}`;
144
+ const stored = localStorage.getItem(storageKey);
145
+ if (stored) {
146
+ const data = JSON.parse(stored);
147
+ if (data.translations) {
148
+ for (const [k, v] of Object.entries(data.translations)) {
149
+ _state.cache.set(k, v as string);
150
+ }
151
+ }
152
+ if (data.plurals) {
153
+ for (const [k, v] of Object.entries(data.plurals)) {
154
+ _state.pluralCache.set(k, v as Record<string, string>);
155
+ }
156
+ }
157
+ }
158
+ } catch {
159
+ // localStorage not available or corrupt — ignore
160
+ }
161
+ }
162
+
163
+ function saveToLocalStorage(projectName: string, language: string): void {
164
+ try {
165
+ const storageKey = `transduck:${projectName}:${language}`;
166
+ const data = {
167
+ translations: Object.fromEntries(_state.cache),
168
+ plurals: Object.fromEntries(_state.pluralCache),
169
+ };
170
+ localStorage.setItem(storageKey, JSON.stringify(data));
171
+ } catch {
172
+ // localStorage not available — ignore
173
+ }
174
+ }
175
+
176
+ // --- Provider ---
177
+
178
+ interface TransDuckProviderProps {
179
+ language: string;
180
+ sourceLang?: string;
181
+ endpoint?: string;
182
+ projectName?: string;
183
+ children: ReactNode;
184
+ }
185
+
186
+ export function TransDuckProvider({
187
+ language,
188
+ sourceLang = 'EN',
189
+ endpoint = '/api/translations',
190
+ projectName = 'default',
191
+ children,
192
+ }: TransDuckProviderProps) {
193
+ const [version, setVersion] = useState(0);
194
+
195
+ // Update module-level state synchronously so t() works during first render
196
+ _state.endpoint = endpoint;
197
+ _state.sourceLang = sourceLang;
198
+
199
+ const upperLang = language.toUpperCase();
200
+ if (_state.language !== upperLang) {
201
+ _state.language = upperLang;
202
+ _state.cache.clear();
203
+ _state.pluralCache.clear();
204
+ loadFromLocalStorage(projectName, upperLang);
205
+ }
206
+
207
+ // Set up trigger
208
+ const doFetch = useCallback(async () => {
209
+ const stringsToFetch = new Set(_state.pendingStrings);
210
+ const pluralsToFetch = new Set(_state.pendingPlurals);
211
+ _state.pendingStrings.clear();
212
+ _state.pendingPlurals.clear();
213
+
214
+ // Filter out already-cached
215
+ for (const key of stringsToFetch) {
216
+ if (_state.cache.has(key)) stringsToFetch.delete(key);
217
+ }
218
+ for (const key of pluralsToFetch) {
219
+ if (_state.pluralCache.has(key)) pluralsToFetch.delete(key);
220
+ }
221
+
222
+ if (stringsToFetch.size === 0 && pluralsToFetch.size === 0) return;
223
+
224
+ // Build request body
225
+ const strings = Array.from(stringsToFetch).map(key => {
226
+ const [text, context] = key.split('||');
227
+ return { text, context: context || undefined };
228
+ });
229
+ const plurals = Array.from(pluralsToFetch).map(key => {
230
+ const [sourceKey, context] = key.split('||');
231
+ const [one, other] = sourceKey.split('\x00');
232
+ return { one, other, context: context || undefined };
233
+ });
234
+
235
+ try {
236
+ const response = await fetch(endpoint, {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({
240
+ language: _state.language,
241
+ strings,
242
+ plurals,
243
+ }),
244
+ });
245
+
246
+ if (!response.ok) {
247
+ console.warn(`[transduck] Translation fetch failed: ${response.status}`);
248
+ return;
249
+ }
250
+
251
+ const data = await response.json();
252
+
253
+ // Store translations
254
+ if (data.translations) {
255
+ for (const [key, value] of Object.entries(data.translations)) {
256
+ _state.cache.set(key, value as string);
257
+ }
258
+ }
259
+ if (data.plurals) {
260
+ for (const [key, value] of Object.entries(data.plurals)) {
261
+ _state.pluralCache.set(key, value as Record<string, string>);
262
+ }
263
+ }
264
+
265
+ saveToLocalStorage(projectName, _state.language);
266
+ setVersion(v => v + 1);
267
+ } catch (err) {
268
+ console.warn('[transduck] Translation fetch error:', err);
269
+ }
270
+ }, [endpoint, projectName]);
271
+
272
+ // Register trigger
273
+ _state.triggerFetch = doFetch;
274
+
275
+ // Flush pending after render
276
+ useEffect(() => {
277
+ if (_state.pendingStrings.size > 0 || _state.pendingPlurals.size > 0) {
278
+ doFetch();
279
+ }
280
+ });
281
+
282
+ return (
283
+ <TransDuckContext.Provider value={version}>
284
+ {children}
285
+ </TransDuckContext.Provider>
286
+ );
287
+ }
package/src/scanner.ts CHANGED
@@ -31,6 +31,14 @@ const AIT_PLURAL = /ait(?:_p|P)lural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
31
31
  // {% ait "text" %} or {% ait "text" context="ctx" %}
32
32
  const DJANGO_TAG = /\{%\s*ait\s+(['"])(.*?)\1(?:\s+context=(['"])(.*?)\3)?\s*%\}/g;
33
33
 
34
+ // t("text") or t("text", "ctx") — only matched in files with transduck/react import
35
+ const T_POSITIONAL = /(?<![a-zA-Z_.$])t\s*\(\s*(['"])(.*?)\1(?:\s*,\s*(['"])(.*?)\3)?/g;
36
+
37
+ // tPlural("one", "other") — only matched in files with transduck/react import
38
+ const T_PLURAL = /(?<![a-zA-Z_.$])tPlural\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3/g;
39
+
40
+ const HAS_TRANSDUCK_REACT = /from\s+['"]transduck\/react['"]/;
41
+
34
42
  // File extensions that use JS-style positional context
35
43
  const JS_EXTENSIONS = new Set(['.js', '.ts', '.tsx', '.jsx']);
36
44
 
@@ -112,6 +120,27 @@ export function extractStrings(content: string, filename: string): ScanEntry[] {
112
120
  results.push({ text, context, line: lineNum });
113
121
  }
114
122
 
123
+ // 4. t() and tPlural() — only in files that import from transduck/react
124
+ if (HAS_TRANSDUCK_REACT.test(content)) {
125
+ // t() calls
126
+ for (const m of content.matchAll(new RegExp(T_POSITIONAL.source, 'g'))) {
127
+ const pos = m.index!;
128
+ // Skip if overlaps with plural spans
129
+ if (pluralSpans.some(([start, end]) => pos >= start && pos < end)) continue;
130
+ const text = m[2];
131
+ const context = m[4] || null;
132
+ const lineNum = content.slice(0, pos).split('\n').length;
133
+ results.push({ text, context, line: lineNum });
134
+ }
135
+ // tPlural() calls
136
+ for (const m of content.matchAll(new RegExp(T_PLURAL.source, 'g'))) {
137
+ const one = m[2];
138
+ const other = m[4];
139
+ const lineNum = content.slice(0, m.index!).split('\n').length;
140
+ results.push({ plural: true, one, other, context: null, line: lineNum });
141
+ }
142
+ }
143
+
115
144
  return results;
116
145
  }
117
146