lyrics-ime-typing-engine 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 toshi7878
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.
22
+
23
+
@@ -0,0 +1,2 @@
1
+ import type { BuiltImeLine, Options, RawMapLine } from "../type";
2
+ export declare const buildImeLines: (mapData: RawMapLine[], options: Options) => Promise<BuiltImeLine[]>;
@@ -0,0 +1,59 @@
1
+ import { normalizeTypingText } from "../normalize-word";
2
+ import { zip } from "../utils/array";
3
+ export const buildImeLines = async (mapData, options) => {
4
+ const { minLineDuration = 5 } = options;
5
+ let lineWords = [];
6
+ let lineTimes = [];
7
+ const lines = [];
8
+ for (const [i, currentLine] of mapData.entries()) {
9
+ const nextLine = mapData[i + 1];
10
+ const nextToNextLine = mapData[i + 2];
11
+ const normalizedTypingLyrics = normalizeTypingText(deleteRubyTag(currentLine.lyrics), options);
12
+ const isTypingLine = isValidTypingLine(normalizedTypingLyrics, currentLine.word);
13
+ if (isTypingLine) {
14
+ lineWords.push(normalizedTypingLyrics);
15
+ lineTimes.push(Number(currentLine.time));
16
+ }
17
+ const shouldBreakLine = shouldCreateNewLine(lineWords, minLineDuration, nextLine, getNextTime(nextLine, nextToNextLine), lineTimes);
18
+ if (shouldBreakLine) {
19
+ const lastTime = nextLine ? Number(nextLine.time) : Number(currentLine.time) + 10; // 次の行がない場合は10秒後
20
+ const WordsWithTimes = zip(lineTimes, lineWords).map(([time, word], index) => {
21
+ const nextTime = lineTimes[index + 1];
22
+ return {
23
+ startTime: time,
24
+ word,
25
+ endTime: nextTime ? nextTime : lastTime,
26
+ };
27
+ });
28
+ lines.push(WordsWithTimes);
29
+ lineWords = [];
30
+ lineTimes = [];
31
+ }
32
+ }
33
+ console.log(lines);
34
+ return lines;
35
+ };
36
+ const isValidTypingLine = (formattedLyrics, word) => {
37
+ return formattedLyrics !== "" && formattedLyrics !== "end" && word.replace(/\s/g, "") !== "";
38
+ };
39
+ const getNextTime = (nextLine, lineAfterNext) => {
40
+ if (!nextLine)
41
+ return 0;
42
+ return lineAfterNext && nextLine.word.replace(/\s/g, "") === "" ? Number(lineAfterNext.time) : Number(nextLine.time);
43
+ };
44
+ const shouldCreateNewLine = (lineChars, minLineDuration, nextLine, nextTime, lineTimes) => {
45
+ return lineChars.length > 0 && (!nextLine || minLineDuration < nextTime - (lineTimes?.[0] ?? 0));
46
+ };
47
+ const deleteRubyTag = (text) => {
48
+ const rubyMatches = text.match(/<*ruby(?: .+?)?>.*?<.*?\/ruby*>/g);
49
+ if (!rubyMatches)
50
+ return text;
51
+ let result = text;
52
+ for (const rubyTag of rubyMatches) {
53
+ const start = rubyTag.indexOf(">") + 1;
54
+ const end = rubyTag.indexOf("<rt>");
55
+ const rubyText = rubyTag.slice(start, end);
56
+ result = result.replace(rubyTag, rubyText);
57
+ }
58
+ return result;
59
+ };
@@ -0,0 +1,7 @@
1
+ import type { BuiltImeLine } from "../type";
2
+ export declare const buildImeWords: (lines: BuiltImeLine[], generateLyricsWithReadings: (comparisonLyrics: string[][]) => Promise<{
3
+ lyrics: string[];
4
+ readings: string[];
5
+ }>, { insertEnglishSpaces }: {
6
+ insertEnglishSpaces?: boolean;
7
+ }) => Promise<string[][][][]>;
@@ -0,0 +1,60 @@
1
+ import { zip } from "../utils/array";
2
+ export const buildImeWords = async (lines, generateLyricsWithReadings, { insertEnglishSpaces = false }) => {
3
+ const comparisonLyrics = lines.map((line) => {
4
+ const lyrics = line.flatMap((chunk) => chunk.word.split(" ")).filter((char) => char !== "");
5
+ if (insertEnglishSpaces) {
6
+ return insertSpacesEng(lyrics);
7
+ }
8
+ return lyrics;
9
+ });
10
+ const lyricsWithReadings = await generateLyricsWithReadings(comparisonLyrics);
11
+ return mergeWordsWithReadingReplacements({ lyricsWithReadings, comparisonLyrics });
12
+ };
13
+ const insertSpacesEng = (words) => {
14
+ const insertedSpaceWords = words;
15
+ for (const [i, currentWord] of insertedSpaceWords.entries()) {
16
+ const isCurrentWordAllHankaku = /^[!-~]*$/.test(currentWord);
17
+ const nextWord = insertedSpaceWords[i + 1];
18
+ if (isCurrentWordAllHankaku && nextWord && nextWord[0]) {
19
+ if (/^[!-~]*$/.test(nextWord[0])) {
20
+ insertedSpaceWords[i] = insertedSpaceWords[i] + " ";
21
+ }
22
+ }
23
+ }
24
+ return insertedSpaceWords;
25
+ };
26
+ const mergeWordsWithReadingReplacements = ({ lyricsWithReadings, comparisonLyrics, }) => {
27
+ const repl = parseRepl(lyricsWithReadings);
28
+ // 第1段階: 文字列内の漢字をプレースホルダーに置換
29
+ const markedLyrics = comparisonLyrics.map((lyrics) => lyrics.map((lyric) => {
30
+ let marked = lyric;
31
+ if (/[一-龥]/.test(lyric)) {
32
+ for (const [m, replItem] of repl.entries()) {
33
+ marked = marked.replace(RegExp(replItem[0] ?? "", "g"), `\t@@${m}@@\t`);
34
+ }
35
+ }
36
+ return marked;
37
+ }));
38
+ // 第2段階: プレースホルダーを読み配列に変換
39
+ const result = markedLyrics.map((lyrics) => lyrics.map((lyric) => {
40
+ const tokens = lyric.split("\t").filter((x) => x !== "");
41
+ return tokens.map((token) => {
42
+ if (token.slice(0, 2) === "@@" && token.slice(-2) === "@@") {
43
+ const index = parseFloat(token.slice(2));
44
+ const replItem = repl[index];
45
+ return replItem ?? [token];
46
+ }
47
+ return [token];
48
+ });
49
+ }));
50
+ return result;
51
+ };
52
+ const parseRepl = (tokenizedWords) => {
53
+ const repl = new Set();
54
+ for (const [lyric, reading] of zip(tokenizedWords.lyrics, tokenizedWords.readings)) {
55
+ if (/[一-龥]/.test(lyric)) {
56
+ repl.add([lyric, reading]);
57
+ }
58
+ }
59
+ return Array.from(repl).sort((a, b) => (b[0]?.length ?? 0) - (a[0]?.length ?? 0));
60
+ };
@@ -0,0 +1,4 @@
1
+ import type { BuiltImeWord, WordResult } from "../type";
2
+ export declare const getTotalNotes: (words: BuiltImeWord[]) => number;
3
+ export declare const createFlatWords: (words: BuiltImeWord[]) => string[];
4
+ export declare const createInitWordResults: (flatWords: string[]) => WordResult[];
@@ -0,0 +1,14 @@
1
+ export const getTotalNotes = (words) => {
2
+ return words.flat(2).reduce((acc, word) => acc + (word?.[0]?.length ?? 0), 0);
3
+ };
4
+ export const createFlatWords = (words) => {
5
+ return words.flat(1).map((word) => {
6
+ return word.map((chars) => chars[0]).join("");
7
+ });
8
+ };
9
+ export const createInitWordResults = (flatWords) => {
10
+ return Array.from({ length: flatWords.length }, () => ({
11
+ inputs: [],
12
+ evaluation: "Skip",
13
+ }));
14
+ };
@@ -0,0 +1,18 @@
1
+ import type { Options, WordResult } from "../type";
2
+ type EvaluateInputResult = {
3
+ wordResultUpdates: Array<{
4
+ index: number;
5
+ result: WordResult;
6
+ }>;
7
+ nextWordIndex?: number;
8
+ typeCountDelta: number;
9
+ typeCountStatsDelta: number;
10
+ notificationsToAppend: string[];
11
+ };
12
+ export declare const evaluateImeInput: (input: string, typingWord: {
13
+ expectedWords: string[][][];
14
+ currentWordIndex: number;
15
+ }, wordResults: WordResult[], map: {
16
+ flatWords: string[];
17
+ }, options: Omit<Options, "minLineDuration">) => EvaluateInputResult;
18
+ export {};
@@ -0,0 +1,143 @@
1
+ import { normalizeTypingText } from "../normalize-word";
2
+ import { kanaToHira } from "../utils/string";
3
+ export const evaluateImeInput = (input, typingWord, wordResults, map, options) => {
4
+ let remainingInput = normalizeInputText(input, options);
5
+ const wordResultUpdates = [];
6
+ const notificationsToAppend = [];
7
+ let nextWordIndex;
8
+ let typeCountDelta = 0;
9
+ let typeCountStatsDelta = 0;
10
+ const { currentWordIndex, expectedWords } = typingWord;
11
+ if (currentWordIndex >= map.flatWords.length) {
12
+ return {
13
+ wordResultUpdates,
14
+ nextWordIndex,
15
+ typeCountDelta,
16
+ typeCountStatsDelta,
17
+ notificationsToAppend,
18
+ };
19
+ }
20
+ for (const [i, targetWords] of expectedWords.entries()) {
21
+ if (i < currentWordIndex)
22
+ continue;
23
+ if (!remainingInput)
24
+ break;
25
+ const correct = evaluateInputAgainstTarget(remainingInput, targetWords);
26
+ if (correct.evaluation === "None") {
27
+ const prevEvaluation = wordResults[i - 1]?.evaluation;
28
+ const isPrevFailure = prevEvaluation === "None" || prevEvaluation === "Skip";
29
+ const currentResult = wordResults[i];
30
+ if (!currentResult)
31
+ continue;
32
+ const result = isPrevFailure
33
+ ? { inputs: [], evaluation: "Skip" }
34
+ : { inputs: [...currentResult.inputs, remainingInput], evaluation: "None" };
35
+ wordResultUpdates.push({ index: i, result });
36
+ wordResults[i] = result;
37
+ continue;
38
+ }
39
+ const prevResultEntry = [...wordResults.entries()]
40
+ .reverse()
41
+ .find(([index, result]) => i > index && result.evaluation !== "Skip");
42
+ const [prevIndex, prevResult] = prevResultEntry ?? [];
43
+ if (prevIndex !== undefined && prevResult?.evaluation === "None") {
44
+ const fixedText = getBeforeTarget(prevResult.inputs[prevResult.inputs.length - 1] ?? "", correct.correcting);
45
+ const result = {
46
+ evaluation: "None",
47
+ inputs: [...prevResult.inputs.slice(0, -1), fixedText].filter((v) => v !== ""),
48
+ };
49
+ wordResultUpdates.push({ index: prevIndex, result });
50
+ wordResults[prevIndex] = result;
51
+ }
52
+ const lyricsIndex = remainingInput.indexOf(correct.correcting);
53
+ remainingInput = remainingInput.slice(lyricsIndex + correct.correcting.length);
54
+ {
55
+ const result = { inputs: [correct.correcting], evaluation: correct.evaluation };
56
+ wordResultUpdates.push({ index: i, result });
57
+ wordResults[i] = result;
58
+ }
59
+ const joinedJudgeWord = targetWords.map((chars) => chars[0]).join("");
60
+ const isGood = correct.evaluation === "Good";
61
+ const wordTypeCount = joinedJudgeWord.length / (isGood ? 1.5 : 1);
62
+ nextWordIndex = i + 1;
63
+ typeCountDelta += wordTypeCount;
64
+ typeCountStatsDelta += Math.round(wordTypeCount);
65
+ notificationsToAppend.push(`${i}: ${correct.evaluation}! ${correct.correcting}`);
66
+ }
67
+ return {
68
+ wordResultUpdates,
69
+ nextWordIndex,
70
+ typeCountDelta,
71
+ typeCountStatsDelta,
72
+ notificationsToAppend,
73
+ };
74
+ };
75
+ function escapeRegExp(string) {
76
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& はマッチした部分文字列を示します
77
+ }
78
+ function evaluateInputAgainstTarget(input, targetWords) {
79
+ let evaluation = "Great";
80
+ let correcting = "";
81
+ let reSearchFlag = false;
82
+ let remainingInput = input;
83
+ for (const [i, judgedWord] of targetWords.entries()) {
84
+ for (const [m, target] of judgedWord.entries()) {
85
+ let search = remainingInput.search(escapeRegExp(target));
86
+ // Great判定
87
+ if (m === 0) {
88
+ if (i === 0 && search > 0) {
89
+ remainingInput = remainingInput.slice(search);
90
+ search = 0;
91
+ }
92
+ if (search === 0) {
93
+ correcting += target;
94
+ remainingInput = remainingInput.slice(target.length);
95
+ break;
96
+ }
97
+ }
98
+ if (search > 0 && correcting) {
99
+ reSearchFlag = true;
100
+ }
101
+ // 最後の候補でGood/None判定
102
+ if (m === judgedWord.length - 1) {
103
+ const commentHira = kanaToHira(remainingInput.toLowerCase());
104
+ const targetHira = kanaToHira(target.toLowerCase());
105
+ let replSearch = commentHira.search(escapeRegExp(targetHira));
106
+ if (i === 0 && replSearch > 0) {
107
+ remainingInput = remainingInput.slice(replSearch);
108
+ replSearch = 0;
109
+ }
110
+ if (replSearch > 0 && i && correcting) {
111
+ reSearchFlag = true;
112
+ }
113
+ if (replSearch === 0) {
114
+ correcting += remainingInput.slice(0, target.length);
115
+ remainingInput = remainingInput.slice(target.length);
116
+ evaluation = "Good";
117
+ break;
118
+ }
119
+ if (reSearchFlag) {
120
+ // 再帰的に再判定
121
+ return evaluateInputAgainstTarget(remainingInput, targetWords);
122
+ }
123
+ return { correcting, evaluation: "None", currentComment: remainingInput };
124
+ }
125
+ }
126
+ }
127
+ return { correcting, evaluation, currentComment: remainingInput };
128
+ }
129
+ const normalizeInputText = (text, options) => {
130
+ let normalizedText = text;
131
+ normalizedText = normalizeTypingText(normalizedText, options);
132
+ //全角の前後のスペースを削除
133
+ normalizedText = normalizedText.replace(/(\s+)([^!-~])/g, "$2").replace(/([^!-~])(\s+)/g, "$1");
134
+ //テキストの末尾が半角ならば末尾に半角スペース追加
135
+ if (/[!-~]$/.test(normalizedText)) {
136
+ normalizedText = normalizedText + " ";
137
+ }
138
+ return normalizedText;
139
+ };
140
+ function getBeforeTarget(str, target) {
141
+ const index = str.indexOf(target);
142
+ return index !== -1 ? str.slice(0, index) : "";
143
+ }
@@ -0,0 +1,6 @@
1
+ export { buildImeLines } from "./build/build-map-lines";
2
+ export { buildImeWords } from "./build/build-words";
3
+ export { createFlatWords, createInitWordResults, getTotalNotes } from "./build/modules";
4
+ export { evaluateImeInput } from "./evaluete/evaluate-ime-input";
5
+ export { getExpectedWords } from "./timer/get-expected-word";
6
+ export type { BuiltImeLine, BuiltImeWord, Options, RawMapLine, WordResult, } from "./type";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { buildImeLines } from "./build/build-map-lines";
2
+ export { buildImeWords } from "./build/build-words";
3
+ export { createFlatWords, createInitWordResults, getTotalNotes } from "./build/modules";
4
+ export { evaluateImeInput } from "./evaluete/evaluate-ime-input";
5
+ export { getExpectedWords } from "./timer/get-expected-word";
@@ -0,0 +1,5 @@
1
+ export declare const normalizeTypingText: (text: string, options: {
2
+ isCaseSensitive: boolean;
3
+ includeRegexPattern: string;
4
+ enableIncludeRegex: boolean;
5
+ }) => string;
@@ -0,0 +1,48 @@
1
+ const REGEX_LIST = ["^-ぁ-んゔ", "ァ-ンヴ", "一-龥", "\\w", "\\d", " ", "々%&@&=+ー~~\u00C0-\u00FF"];
2
+ const HANGUL = ["\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uFFA0-\uFFDC\uFFA0-\uFFDC"];
3
+ const CYRILLIC_ALPHABET = ["\u0400-\u04FF"];
4
+ const LYRICS_FORMAT_REGEX = REGEX_LIST.concat(HANGUL).concat(CYRILLIC_ALPHABET); // TODO: .concat(this.customRegex);
5
+ const FILTER_SYMBOLS = "×";
6
+ export const normalizeTypingText = (text, options) => {
7
+ let normalizedText = text;
8
+ normalizedText = normalizedText.replace(/<[^>]*?style>[\s\S]*?<[^>]*?\/style[^>]*?>/g, ""); //styleタグ全体削除
9
+ normalizedText = normalizedText.replace(/[((].*?[))]/g, ""); //()()の歌詞を削除
10
+ normalizedText = normalizedText.replace(/<[^>]*>(.*?)<[^>]*?\/[^>]*>/g, "$1"); //HTMLタグの中の文字を取り出す
11
+ normalizedText = normalizedText.replace(/<[^>]*>/, "");
12
+ normalizedText = normalizeSymbols(normalizedText);
13
+ if (options.isCaseSensitive) {
14
+ normalizedText = normalizedText.normalize("NFKC");
15
+ }
16
+ else {
17
+ normalizedText = normalizedText.normalize("NFKC").toLowerCase();
18
+ }
19
+ // アルファベットと全角文字の間にスペースを追加
20
+ normalizedText = normalizedText.replace(/([a-zA-Z])([ぁ-んゔァ-ンヴ一-龥])/g, "$1 $2"); // アルファベットの後に日本語文字がある場合
21
+ normalizedText = normalizedText.replace(/([ぁ-んゔァ-ンヴ一-龥])([a-zA-Z])/g, "$1 $2"); // 日本語文字の後にアルファベットがある場合
22
+ normalizedText = normalizedText.replace(new RegExp(FILTER_SYMBOLS, "g"), ""); //記号削除 TODO: ホワイトリストに含まれる機能はFILTERしない
23
+ if (options.enableIncludeRegex) {
24
+ normalizedText = normalizedText.replace(new RegExp(`[${LYRICS_FORMAT_REGEX.concat([options.includeRegexPattern.replace(/./g, "\\$&")]).join("")}]`, "g"), ""); //regexListに含まれていない文字を削除
25
+ }
26
+ else {
27
+ normalizedText = normalizedText.replace(new RegExp(`[${LYRICS_FORMAT_REGEX.join("")}]`, "g"), ""); //regexListに含まれていない文字を削除
28
+ }
29
+ return normalizedText;
30
+ };
31
+ const normalizeSymbols = (text) => {
32
+ return text
33
+ .replaceAll("…", "...")
34
+ .replaceAll("‥", "..")
35
+ .replaceAll("・", "・")
36
+ .replaceAll("“", '"')
37
+ .replaceAll("”", '"')
38
+ .replaceAll("’", "'")
39
+ .replaceAll("〜", "~")
40
+ .replaceAll("「", "「")
41
+ .replaceAll("」", "」")
42
+ .replaceAll("、", "、")
43
+ .replaceAll("。", "。")
44
+ .replaceAll("-", "ー")
45
+ .replaceAll(" ", " ")
46
+ .replaceAll(/ {2,}/g, " ")
47
+ .trim();
48
+ };
@@ -0,0 +1,2 @@
1
+ import { BuiltImeWord } from "../type";
2
+ export declare const getExpectedWords: (count: number, words: BuiltImeWord[]) => string[][][];
@@ -0,0 +1,3 @@
1
+ export const getExpectedWords = (count, words) => {
2
+ return words.slice(0, count).flat(1);
3
+ };
package/dist/type.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface RawMapLine<TOptions = unknown> {
2
+ time: string | number;
3
+ lyrics: string;
4
+ word: string;
5
+ options?: TOptions;
6
+ }
7
+ export type BuiltImeLine = {
8
+ startTime: number;
9
+ word: string;
10
+ endTime: number;
11
+ }[];
12
+ export type BuiltImeWord = string[][][];
13
+ export type WordResult = {
14
+ inputs: string[];
15
+ evaluation: "Great" | "Good" | "Skip" | "None";
16
+ };
17
+ export type Options = {
18
+ minLineDuration?: number;
19
+ isCaseSensitive: boolean;
20
+ includeRegexPattern: string;
21
+ enableIncludeRegex: boolean;
22
+ };
package/dist/type.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const zip: <T, U>(arr1: T[], arr2: U[]) => [T, U][];
@@ -0,0 +1,5 @@
1
+ export const zip = (arr1, arr2) => {
2
+ const length = Math.min(arr1.length, arr2.length);
3
+ // biome-ignore lint/style/noNonNullAssertion: <lengthが同じ前提>
4
+ return Array.from({ length }, (_, i) => [arr1[i], arr2[i]]);
5
+ };
@@ -0,0 +1 @@
1
+ export declare const kanaToHira: (text: string) => string;
@@ -0,0 +1,11 @@
1
+ export const kanaToHira = (text) => {
2
+ return text
3
+ .replaceAll(/[\u30A1-\u30F6]/g, (match) => {
4
+ const codePoint = match.codePointAt(0);
5
+ if (codePoint === undefined)
6
+ return match;
7
+ const chr = codePoint - 0x60;
8
+ return String.fromCodePoint(chr);
9
+ })
10
+ .replaceAll("ヴ", "ゔ");
11
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "lyrics-ime-typing-engine",
3
+ "description": "A typing engine designed for music-based typing games. Provides time-tagged typing map generation and kana/roma input evaluation synchronized with music playback.",
4
+ "version": "1.0.1",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "sideEffects": false,
20
+ "keywords": [],
21
+ "author": "toshi7878",
22
+ "license": "MIT",
23
+ "devDependencies": {
24
+ "@biomejs/biome": "^2.3.7",
25
+ "typescript": "^5.9.3"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/Toshi7878/lyrics-ime-typing-engine",
33
+ "directory": "lyrics-ime-typing-engine"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "check": "biome check --diagnostic-level=warn --reporter=summary",
38
+ "check:fix": "biome check --diagnostic-level=warn --reporter=summary --write",
39
+ "typecheck": "tsc --noEmit",
40
+ "upload": "pnpm build && pnpm publish",
41
+ "version:patch": "pnpm version patch",
42
+ "version:minor": "pnpm version minor",
43
+ "version:major": "pnpm version major",
44
+ "push:tags": "git push --follow-tags"
45
+ }
46
+ }