typengine 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shuhei Akutagawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # Typengine
2
+
3
+ Core library for building typing apps/games on any JavaScript runtime.
4
+
5
+ ## Usage (draft)
6
+
7
+ ```ts
8
+ import { Sentence, createJapaneseSentenceDefinition } from "typengine";
9
+
10
+ const definition = createJapaneseSentenceDefinition("寿司", "すし");
11
+ const sentence = new Sentence(definition);
12
+
13
+ sentence.input("s");
14
+ sentence.input("u");
15
+ sentence.input("s");
16
+ const result = sentence.input("i");
17
+ // result example:
18
+ // {
19
+ // accepted: boolean; // whether this keystroke was accepted
20
+ // }
21
+ ```
22
+
23
+ ```ts
24
+ import { Session, Sentence, createJapaneseSentenceDefinition } from "typengine";
25
+
26
+ const sentences = [
27
+ new Sentence(createJapaneseSentenceDefinition("寿司", "すし")),
28
+ new Sentence(createJapaneseSentenceDefinition("天ぷら", "てんぷら")),
29
+ ];
30
+
31
+ const session = new Session(sentences);
32
+ session.start();
33
+ session.input("s");
34
+ ```
@@ -0,0 +1,113 @@
1
+ //#region lib/input.d.ts
2
+
3
+ type InputResult = {
4
+ accepted: boolean;
5
+ };
6
+ //#endregion
7
+ //#region lib/types.d.ts
8
+ type CharacterDefinition = {
9
+ reading: string;
10
+ patterns: string[];
11
+ };
12
+ type SentenceDefinition = {
13
+ text: string;
14
+ reading: string;
15
+ characters: CharacterDefinition[];
16
+ };
17
+ //#endregion
18
+ //#region lib/character.d.ts
19
+ type CharacterOptions = {
20
+ onCharacterStarted?: (payload: {
21
+ startedAt: number;
22
+ }) => void;
23
+ onCharacterTyped?: (payload: {
24
+ typedAt: number;
25
+ key: string;
26
+ }) => void;
27
+ onCharacterMistyped?: (payload: {
28
+ typedAt: number;
29
+ key: string;
30
+ }) => void;
31
+ onCharacterCompleted?: (payload: {
32
+ completedAt: number;
33
+ }) => void;
34
+ };
35
+ declare class Character {
36
+ readonly definition: CharacterDefinition;
37
+ private readonly options;
38
+ private state;
39
+ constructor(definition: CharacterDefinition, options?: CharacterOptions);
40
+ start(): void;
41
+ input(value: string): InputResult;
42
+ get typed(): string;
43
+ get remainingPatterns(): string[];
44
+ get previewPattern(): string;
45
+ get completed(): boolean;
46
+ }
47
+ //#endregion
48
+ //#region lib/sentence.d.ts
49
+ type SentenceOptions = {
50
+ onSentenceStarted?: (payload: {
51
+ startedAt: number;
52
+ }) => void;
53
+ onSentenceCompleted?: (payload: {
54
+ completedAt: number;
55
+ }) => void;
56
+ onCharacterStarted?: (payload: {
57
+ startedAt: number;
58
+ }) => void;
59
+ onCharacterTyped?: (payload: {
60
+ typedAt: number;
61
+ key: string;
62
+ }) => void;
63
+ onCharacterMistyped?: (payload: {
64
+ typedAt: number;
65
+ key: string;
66
+ }) => void;
67
+ onCharacterCompleted?: (payload: {
68
+ completedAt: number;
69
+ }) => void;
70
+ };
71
+ declare class Sentence {
72
+ readonly definition: SentenceDefinition;
73
+ private readonly characters;
74
+ private readonly options;
75
+ constructor(definition: SentenceDefinition, options?: SentenceOptions);
76
+ start(): void;
77
+ input(value: string): InputResult;
78
+ get typed(): string;
79
+ get currentCharacter(): Character | null;
80
+ get position(): number;
81
+ get completed(): boolean;
82
+ get previewPattern(): string;
83
+ get text(): string;
84
+ get reading(): string;
85
+ }
86
+ //#endregion
87
+ //#region lib/session.d.ts
88
+ type SessionOptions = {
89
+ onSessionStarted?: (payload: {
90
+ startedAt: number;
91
+ }) => void;
92
+ onSessionCompleted?: (payload: {
93
+ completedAt: number;
94
+ }) => void;
95
+ };
96
+ type SessionInputResult = {
97
+ accepted: boolean;
98
+ };
99
+ declare class Session {
100
+ readonly sentences: Sentence[];
101
+ private readonly options;
102
+ constructor(sentences: Sentence[], options?: SessionOptions);
103
+ start(): void;
104
+ input(value: string): SessionInputResult;
105
+ get currentSentence(): Sentence | null;
106
+ get position(): number;
107
+ get completed(): boolean;
108
+ }
109
+ //#endregion
110
+ //#region lib/createJapaneseSentenceDefinition.d.ts
111
+ declare const createJapaneseSentenceDefinition: (text: string, reading: string) => SentenceDefinition;
112
+ //#endregion
113
+ export { Character, type CharacterDefinition, type CharacterOptions, type InputResult, Sentence, type SentenceDefinition, type SentenceOptions, Session, type SessionInputResult, type SessionOptions, createJapaneseSentenceDefinition };
package/dist/index.js ADDED
@@ -0,0 +1,492 @@
1
+ //#region lib/input.ts
2
+ const input = (state, value) => {
3
+ if (state.remainingPatterns.some((pattern) => pattern.length === 0)) return { accepted: false };
4
+ const nextPatterns = state.remainingPatterns.filter((pattern) => pattern.startsWith(value)).map((pattern) => pattern.slice(value.length));
5
+ if (nextPatterns.length === 0) return { accepted: false };
6
+ state.remainingPatterns = nextPatterns;
7
+ state.typedValue += value;
8
+ return { accepted: true };
9
+ };
10
+
11
+ //#endregion
12
+ //#region lib/character.ts
13
+ var Character = class {
14
+ definition;
15
+ options;
16
+ state;
17
+ constructor(definition, options = {}) {
18
+ this.definition = definition;
19
+ this.options = options;
20
+ this.state = {
21
+ remainingPatterns: [...definition.patterns],
22
+ typedValue: ""
23
+ };
24
+ }
25
+ start() {
26
+ this.options.onCharacterStarted?.({ startedAt: Date.now() });
27
+ }
28
+ input(value) {
29
+ const result = input(this.state, value);
30
+ const typedAt = Date.now();
31
+ if (!result.accepted) {
32
+ this.options.onCharacterMistyped?.({
33
+ typedAt,
34
+ key: value
35
+ });
36
+ return result;
37
+ }
38
+ this.options.onCharacterTyped?.({
39
+ typedAt,
40
+ key: value
41
+ });
42
+ if (this.completed) this.options.onCharacterCompleted?.({ completedAt: typedAt });
43
+ return result;
44
+ }
45
+ get typed() {
46
+ return this.state.typedValue;
47
+ }
48
+ get remainingPatterns() {
49
+ return [...this.state.remainingPatterns];
50
+ }
51
+ get previewPattern() {
52
+ const [pattern] = this.state.remainingPatterns;
53
+ return `${this.state.typedValue}${pattern ?? ""}`;
54
+ }
55
+ get completed() {
56
+ return this.state.remainingPatterns.some((pattern) => pattern.length === 0);
57
+ }
58
+ };
59
+
60
+ //#endregion
61
+ //#region lib/sentence.ts
62
+ var Sentence = class {
63
+ definition;
64
+ characters;
65
+ options;
66
+ constructor(definition, options = {}) {
67
+ this.definition = definition;
68
+ this.options = options;
69
+ const characterOptions = {
70
+ onCharacterStarted: options.onCharacterStarted,
71
+ onCharacterTyped: options.onCharacterTyped,
72
+ onCharacterMistyped: options.onCharacterMistyped,
73
+ onCharacterCompleted: options.onCharacterCompleted
74
+ };
75
+ this.characters = definition.characters.map((character) => new Character(character, characterOptions));
76
+ }
77
+ start() {
78
+ const current = this.currentCharacter;
79
+ if (!current) throw new Error("Cannot start an empty sentence.");
80
+ this.options.onSentenceStarted?.({ startedAt: Date.now() });
81
+ current.start();
82
+ }
83
+ input(value) {
84
+ const current = this.currentCharacter;
85
+ if (!current) throw new Error("Cannot input to a completed sentence.");
86
+ const result = current.input(value);
87
+ if (!result.accepted) return result;
88
+ if (!current.completed) return result;
89
+ const next = this.currentCharacter;
90
+ if (next) {
91
+ next.start();
92
+ return result;
93
+ }
94
+ this.options.onSentenceCompleted?.({ completedAt: Date.now() });
95
+ return result;
96
+ }
97
+ get typed() {
98
+ return this.characters.map((character) => character.typed).join("");
99
+ }
100
+ get currentCharacter() {
101
+ return this.characters[this.position] ?? null;
102
+ }
103
+ get position() {
104
+ for (let index = 0; index < this.characters.length; index += 1) if (!this.characters[index].completed) return index;
105
+ return -1;
106
+ }
107
+ get completed() {
108
+ return this.position < 0;
109
+ }
110
+ get previewPattern() {
111
+ return this.characters.map((character) => character.previewPattern).join("");
112
+ }
113
+ get text() {
114
+ return this.definition.text;
115
+ }
116
+ get reading() {
117
+ return this.definition.reading;
118
+ }
119
+ };
120
+
121
+ //#endregion
122
+ //#region lib/session.ts
123
+ var Session = class {
124
+ sentences;
125
+ options;
126
+ constructor(sentences, options = {}) {
127
+ if (sentences.length === 0) throw new Error("Session requires at least one sentence.");
128
+ this.sentences = sentences;
129
+ this.options = options;
130
+ }
131
+ start() {
132
+ const current = this.currentSentence;
133
+ if (!current) throw new Error("Cannot start a completed session.");
134
+ this.options.onSessionStarted?.({ startedAt: Date.now() });
135
+ current.start();
136
+ }
137
+ input(value) {
138
+ const current = this.currentSentence;
139
+ if (!current) throw new Error("Cannot input to a completed session.");
140
+ const result = current.input(value);
141
+ if (!result.accepted) return result;
142
+ if (!current.completed) return result;
143
+ const next = this.currentSentence;
144
+ if (next) {
145
+ next.start();
146
+ return result;
147
+ }
148
+ this.options.onSessionCompleted?.({ completedAt: Date.now() });
149
+ return result;
150
+ }
151
+ get currentSentence() {
152
+ return this.sentences[this.position] ?? null;
153
+ }
154
+ get position() {
155
+ for (let index = 0; index < this.sentences.length; index += 1) if (!this.sentences[index].completed) return index;
156
+ return -1;
157
+ }
158
+ get completed() {
159
+ return this.position < 0;
160
+ }
161
+ };
162
+
163
+ //#endregion
164
+ //#region lib/createJapaneseSentenceDefinition.ts
165
+ const KANA_ROMAJI_MAP = {
166
+ あ: ["a"],
167
+ い: ["i"],
168
+ う: ["u"],
169
+ え: ["e"],
170
+ お: ["o"],
171
+ か: ["ka"],
172
+ き: ["ki"],
173
+ く: ["ku"],
174
+ け: ["ke"],
175
+ こ: ["ko"],
176
+ さ: ["sa"],
177
+ し: ["shi", "si"],
178
+ す: ["su"],
179
+ せ: ["se"],
180
+ そ: ["so"],
181
+ た: ["ta"],
182
+ ち: ["chi", "ti"],
183
+ つ: ["tsu", "tu"],
184
+ て: ["te"],
185
+ と: ["to"],
186
+ な: ["na"],
187
+ に: ["ni"],
188
+ ぬ: ["nu"],
189
+ ね: ["ne"],
190
+ の: ["no"],
191
+ は: ["ha"],
192
+ ひ: ["hi"],
193
+ ふ: ["fu", "hu"],
194
+ へ: ["he"],
195
+ ほ: ["ho"],
196
+ ま: ["ma"],
197
+ み: ["mi"],
198
+ む: ["mu"],
199
+ め: ["me"],
200
+ も: ["mo"],
201
+ や: ["ya"],
202
+ ゆ: ["yu"],
203
+ よ: ["yo"],
204
+ ら: ["ra"],
205
+ り: ["ri"],
206
+ る: ["ru"],
207
+ れ: ["re"],
208
+ ろ: ["ro"],
209
+ わ: ["wa"],
210
+ を: ["wo", "o"],
211
+ ん: ["n"],
212
+ が: ["ga"],
213
+ ぎ: ["gi"],
214
+ ぐ: ["gu"],
215
+ げ: ["ge"],
216
+ ご: ["go"],
217
+ ざ: ["za"],
218
+ じ: ["ji", "zi"],
219
+ ず: ["zu"],
220
+ ぜ: ["ze"],
221
+ ぞ: ["zo"],
222
+ だ: ["da"],
223
+ ぢ: ["ji", "di"],
224
+ づ: ["zu", "du"],
225
+ で: ["de"],
226
+ ど: ["do"],
227
+ ば: ["ba"],
228
+ び: ["bi"],
229
+ ぶ: ["bu"],
230
+ べ: ["be"],
231
+ ぼ: ["bo"],
232
+ ぱ: ["pa"],
233
+ ぴ: ["pi"],
234
+ ぷ: ["pu"],
235
+ ぺ: ["pe"],
236
+ ぽ: ["po"],
237
+ きゃ: [
238
+ "kya",
239
+ "kilya",
240
+ "kixya"
241
+ ],
242
+ きゅ: [
243
+ "kyu",
244
+ "kilyu",
245
+ "kixyu"
246
+ ],
247
+ きょ: [
248
+ "kyo",
249
+ "kilyo",
250
+ "kixyo"
251
+ ],
252
+ ぎゃ: [
253
+ "gya",
254
+ "gilya",
255
+ "gixya"
256
+ ],
257
+ ぎゅ: [
258
+ "gyu",
259
+ "gilyu",
260
+ "gixyu"
261
+ ],
262
+ ぎょ: [
263
+ "gyo",
264
+ "gilyo",
265
+ "gixyo"
266
+ ],
267
+ しゃ: [
268
+ "sha",
269
+ "sya",
270
+ "shilya",
271
+ "shixya",
272
+ "silya",
273
+ "sixya"
274
+ ],
275
+ しゅ: [
276
+ "shu",
277
+ "syu",
278
+ "shilyu",
279
+ "shixyu",
280
+ "silyu",
281
+ "sixyu"
282
+ ],
283
+ しょ: [
284
+ "sho",
285
+ "syo",
286
+ "shilyo",
287
+ "shixyo",
288
+ "silyo",
289
+ "sixyo"
290
+ ],
291
+ じゃ: [
292
+ "ja",
293
+ "jya",
294
+ "zya",
295
+ "jilya",
296
+ "jixya",
297
+ "zilya",
298
+ "zixya"
299
+ ],
300
+ じゅ: [
301
+ "ju",
302
+ "jyu",
303
+ "zyu",
304
+ "jilyu",
305
+ "jixyu",
306
+ "zilyu",
307
+ "zixyu"
308
+ ],
309
+ じょ: [
310
+ "jo",
311
+ "jyo",
312
+ "zyo",
313
+ "jilyo",
314
+ "jixyo",
315
+ "zilyo",
316
+ "zixyo"
317
+ ],
318
+ ちゃ: [
319
+ "cha",
320
+ "cya",
321
+ "tya",
322
+ "chilya",
323
+ "chixya",
324
+ "tilya",
325
+ "tixya"
326
+ ],
327
+ ちゅ: [
328
+ "chu",
329
+ "cyu",
330
+ "tyu",
331
+ "chilyu",
332
+ "chixyu",
333
+ "tilyu",
334
+ "tixyu"
335
+ ],
336
+ ちょ: [
337
+ "cho",
338
+ "cyo",
339
+ "tyo",
340
+ "chilyo",
341
+ "chixyo",
342
+ "tilyo",
343
+ "tixyo"
344
+ ],
345
+ にゃ: [
346
+ "nya",
347
+ "nilya",
348
+ "nixya"
349
+ ],
350
+ にゅ: [
351
+ "nyu",
352
+ "nilyu",
353
+ "nixyu"
354
+ ],
355
+ にょ: [
356
+ "nyo",
357
+ "nilyo",
358
+ "nixyo"
359
+ ],
360
+ ひゃ: [
361
+ "hya",
362
+ "hilya",
363
+ "hixya"
364
+ ],
365
+ ひゅ: [
366
+ "hyu",
367
+ "hilyu",
368
+ "hixyu"
369
+ ],
370
+ ひょ: [
371
+ "hyo",
372
+ "hilyo",
373
+ "hixyo"
374
+ ],
375
+ みゃ: [
376
+ "mya",
377
+ "milya",
378
+ "mixya"
379
+ ],
380
+ みゅ: [
381
+ "myu",
382
+ "milyu",
383
+ "mixyu"
384
+ ],
385
+ みょ: [
386
+ "myo",
387
+ "milyo",
388
+ "mixyo"
389
+ ],
390
+ りゃ: [
391
+ "rya",
392
+ "rilya",
393
+ "rixya"
394
+ ],
395
+ りゅ: [
396
+ "ryu",
397
+ "rilyu",
398
+ "rixyu"
399
+ ],
400
+ りょ: [
401
+ "ryo",
402
+ "rilyo",
403
+ "rixyo"
404
+ ],
405
+ びゃ: [
406
+ "bya",
407
+ "bilya",
408
+ "bixya"
409
+ ],
410
+ びゅ: [
411
+ "byu",
412
+ "bilyu",
413
+ "bixyu"
414
+ ],
415
+ びょ: [
416
+ "byo",
417
+ "bilyo",
418
+ "bixyo"
419
+ ],
420
+ ぴゃ: [
421
+ "pya",
422
+ "pilya",
423
+ "pixya"
424
+ ],
425
+ ぴゅ: [
426
+ "pyu",
427
+ "pilyu",
428
+ "pixyu"
429
+ ],
430
+ ぴょ: [
431
+ "pyo",
432
+ "pilyo",
433
+ "pixyo"
434
+ ]
435
+ };
436
+ const SMALL_TSU_PATTERNS = [
437
+ "ltu",
438
+ "ltsu",
439
+ "xtu",
440
+ "xtsu"
441
+ ];
442
+ const SMALL_TSU_CONSONANTS = new Set([
443
+ "k",
444
+ "s",
445
+ "t",
446
+ "h",
447
+ "m",
448
+ "y",
449
+ "r",
450
+ "w"
451
+ ]);
452
+ const createSmallTsuCharacterDefinition = (nextPatterns) => {
453
+ const consonantPatterns = /* @__PURE__ */ new Set();
454
+ if (nextPatterns) for (const pattern of nextPatterns) {
455
+ const consonant = pattern[0];
456
+ if (consonant && SMALL_TSU_CONSONANTS.has(consonant)) consonantPatterns.add(consonant);
457
+ }
458
+ return {
459
+ reading: "っ",
460
+ patterns: [...SMALL_TSU_PATTERNS, ...consonantPatterns]
461
+ };
462
+ };
463
+ const createJapaneseSentenceDefinition = (text, reading) => {
464
+ const characters = [];
465
+ for (let index = 0; index < reading.length; index += 1) {
466
+ const char = reading[index];
467
+ const nextChar = reading[index + 1];
468
+ if (char === "っ") {
469
+ const nextPatterns = nextChar ? KANA_ROMAJI_MAP[nextChar] : void 0;
470
+ characters.push(createSmallTsuCharacterDefinition(nextPatterns));
471
+ continue;
472
+ }
473
+ const digraph = nextChar ? `${char}${nextChar}` : null;
474
+ const digraphCandidates = digraph ? KANA_ROMAJI_MAP[digraph] : void 0;
475
+ const candidates = digraphCandidates ?? KANA_ROMAJI_MAP[char];
476
+ if (!candidates) throw new Error(`Unsupported hiragana: ${char}`);
477
+ const readingUnit = digraphCandidates && nextChar ? `${char}${nextChar}` : char;
478
+ if (digraphCandidates) index += 1;
479
+ characters.push({
480
+ reading: readingUnit,
481
+ patterns: candidates
482
+ });
483
+ }
484
+ return {
485
+ text,
486
+ reading,
487
+ characters
488
+ };
489
+ };
490
+
491
+ //#endregion
492
+ export { Character, Sentence, Session, createJapaneseSentenceDefinition };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "typengine",
3
+ "version": "0.0.2",
4
+ "description": "Core library for building typing apps and games.",
5
+ "keywords": [
6
+ "japanese",
7
+ "keyboard",
8
+ "romaji",
9
+ "typing",
10
+ "typing-game",
11
+ "typing-practice"
12
+ ],
13
+ "license": "MIT",
14
+ "files": [
15
+ "dist",
16
+ "LICENSE",
17
+ "README.md"
18
+ ],
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.0.3",
30
+ "@typescript/native-preview": "7.0.0-dev.20251226.1",
31
+ "oxfmt": "^0.20.0",
32
+ "oxlint": "^1.35.0",
33
+ "tsdown": "^0.15.12",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "scripts": {
37
+ "build": "tsdown",
38
+ "test": "node --test lib/**/*.test.ts",
39
+ "typecheck": "tsgo --noEmit",
40
+ "lint": "oxlint . && oxfmt --check .",
41
+ "fix": "oxlint . --fix && oxfmt --write .",
42
+ "ready": "pnpm fix && pnpm typecheck && pnpm test",
43
+ "ready:check": "pnpm lint && pnpm typecheck && pnpm test"
44
+ }
45
+ }