@jjlmoya/utils-tabletop 1.13.0 → 1.15.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/package.json +1 -1
- package/src/category/i18n/fr.ts +3 -3
- package/src/category/i18n/zh.ts +7 -7
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/no_en_dash.test.ts +35 -6
- package/src/tests/script_density.test.ts +94 -0
- package/src/tool/board-game-timer/board-game-timer.css +94 -0
- package/src/tool/board-game-timer/i18n/fr.ts +3 -3
- package/src/tool/board-game-timer/i18n/zh.ts +2 -2
- package/src/tool/decision-wheel/decision-wheel.css +89 -0
- package/src/tool/decision-wheel/i18n/ru.ts +207 -38
- package/src/tool/dice-roller-simulator/dice-roller-simulator.css +111 -0
- package/src/tool/dice-roller-simulator/i18n/fr.ts +6 -6
- package/src/tool/dice-roller-simulator/i18n/ru.ts +5 -5
- package/src/tool/dice-roller-simulator/i18n/zh.ts +4 -4
- package/src/tool/fantasy-runes-translator/i18n/fr.ts +4 -4
- package/src/tool/first-player-selector/i18n/fr.ts +6 -6
- package/src/tool/first-player-selector/i18n/ru.ts +5 -5
- package/src/tool/hidden-role-dealer/hidden-role-dealer.css +81 -0
- package/src/tool/hidden-role-dealer/i18n/fr.ts +3 -3
- package/src/tool/hidden-role-dealer/i18n/ru.ts +1 -1
- package/src/tool/hidden-role-dealer/i18n/zh.ts +1 -1
- package/src/tool/investigation-board/i18n/fr.ts +4 -4
- package/src/tool/investigation-board/i18n/ru.ts +4 -4
- package/src/tool/investigation-board/i18n/zh.ts +2 -2
- package/src/tool/investigation-board/investigation-board.css +81 -1
- package/src/tool/lunar-tide-tracker/i18n/fr.ts +4 -4
- package/src/tool/lunar-tide-tracker/i18n/tr.ts +60 -60
- package/src/tool/rpg-initiative-tracker/i18n/de.ts +1 -1
- package/src/tool/rpg-initiative-tracker/i18n/fr.ts +3 -3
- package/src/tool/scatter-direction-selector/component.astro +1 -1
- package/src/tool/scatter-direction-selector/i18n/pl.ts +3 -3
- package/src/tool/scatter-direction-selector/i18n/ru.ts +3 -3
- package/src/tool/scatter-direction-selector/i18n/tr.ts +3 -3
- package/src/tool/scatter-direction-selector/scatter-direction-selector.css +87 -0
- package/src/tool/score-tracker/i18n/es.ts +62 -62
- package/src/tool/score-tracker/i18n/fr.ts +2 -2
- package/src/tool/score-tracker/i18n/ru.ts +110 -110
- package/src/tool/score-tracker/i18n/tr.ts +53 -53
- package/src/tool/score-tracker/score-tracker.css +83 -0
package/package.json
CHANGED
package/src/category/i18n/fr.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CategoryLocaleContent } from '../../types';
|
|
|
2
2
|
|
|
3
3
|
const slug = 'jeux-de-societe';
|
|
4
4
|
const title = 'Utilitaires et Outils pour Jeux de Société';
|
|
5
|
-
const description = 'Outils gratuits pour jeux de société et de rôle
|
|
5
|
+
const description = 'Outils gratuits pour jeux de société et de rôle: lancez des dés virtuels avec des statistiques en direct, gérez les temps de tour avec un chronomètre multijoueur et bien plus. Fonctionne dans votre navigateur, sans inscription, 100 % privé.';
|
|
6
6
|
|
|
7
7
|
export const content: CategoryLocaleContent = {
|
|
8
8
|
slug,
|
|
@@ -14,8 +14,8 @@ export const content: CategoryLocaleContent = {
|
|
|
14
14
|
{ type: 'title', text: 'Lancez des Dés Virtuels avec des Statistiques en Temps Réel', level: 2 },
|
|
15
15
|
{ type: 'paragraph', html: 'Marre de chercher vos dés sous la table ou d\'attendre que quelqu\'un additionne ses résultats ? Le lanceur de dés virtuel vous permet de jeter n\'importe quelle combinaison de dés polyédriques instantanément, avec modificateurs, avantage, désavantage et un historique complet. L\'analyseur de probabilités intégré vous montre vos chances réelles avant de lancer. Parfait pour Dungeons & Dragons, Pathfinder, Call of Cthulhu, Shadowrun ou tout jeu reposant sur le hasard.' },
|
|
16
16
|
{ type: 'title', text: 'Gardez le Rythme avec un Chronomètre de Tour Intelligent', level: 2 },
|
|
17
|
-
{ type: 'paragraph', html: 'On connaît tous ce joueur qui prend une éternité à chaque tour. Le chronomètre de jeu vous permet de fixer des limites de temps justes et amusantes, avec des modes qui s\'adaptent à chaque style
|
|
18
|
-
{ type: 'title', text: 'La Vie Privée Avant Tout
|
|
17
|
+
{ type: 'paragraph', html: 'On connaît tous ce joueur qui prend une éternité à chaque tour. Le chronomètre de jeu vous permet de fixer des limites de temps justes et amusantes, avec des modes qui s\'adaptent à chaque style: du simple compte à rebours partagé aux systèmes d\'échecs avec incrément Fischer ou délai Bronstein. Fonctionne pour les duels à deux en écran partagé ou pour les groupes jusqu\'à huit joueurs avec contrôle centralisé. Après la partie, consultez des statistiques détaillées sur les temps et le rythme de jeu.' },
|
|
18
|
+
{ type: 'title', text: 'La Vie Privée Avant Tout: Vos Données Restent sur Votre Appareil', level: 2 },
|
|
19
19
|
{ type: 'paragraph', html: 'L\'un des grands atouts de cette bibliothèque est que tout le traitement s\'effectue dans votre navigateur. Aucune donnée n\'est envoyée à un serveur, pas de cookies de pistage, pas d\'email ni d\'inscription requis. Utilisez les outils hors ligne, partagez votre écran avec la table ou projetez sur un téléviseur - rien d\'autre à gérer que le jeu lui-même. Et d\'autres outils arrivent, toujours conçus pour simplifier la vie des joueurs de société.' },
|
|
20
20
|
{ type: 'stats', items: [
|
|
21
21
|
{ label: 'Outils', value: '2+', icon: 'mdi:tools' },
|
package/src/category/i18n/zh.ts
CHANGED
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
const slug = 'tabletop';
|
|
4
4
|
const title = '桌游工具与实用程序';
|
|
5
|
-
const description = '免费的桌游和TRPG
|
|
5
|
+
const description = '免费的桌游和TRPG工具:虚拟骰子带实时统计,多人回合计时器等。在浏览器中运行,无需注册,100%隐私保护。';
|
|
6
6
|
|
|
7
7
|
export const content: CategoryLocaleContent = {
|
|
8
8
|
slug,
|
|
9
9
|
title,
|
|
10
10
|
description,
|
|
11
11
|
seo: [
|
|
12
|
-
{ type: 'title', text: '桌游工具 |
|
|
13
|
-
{ type: 'paragraph', html: '
|
|
14
|
-
{ type: 'title', text: '
|
|
12
|
+
{ type: 'title', text: '桌游工具 | 在线掷骰子和回合计时器', level: 2 },
|
|
13
|
+
{ type: 'paragraph', html: '游戏之夜本该充满乐趣,而不是到处找丢失的骰子或等待别人计算结果。这款实用工具库为您和您的团队提供快速、精心设计的工具,即刻可用,无需任何回报。无论您是手忙脚乱的 dungeon master,还是只想轻松掷骰的玩家,这些工具都是为您打造的。' },
|
|
14
|
+
{ type: 'title', text: '使用虚拟骰子进行实时统计分析', level: 2 },
|
|
15
15
|
{ type: 'paragraph', html: '厌倦了在桌子下找骰子或等待别人加总结果?虚拟骰子掷投器让您立即投掷任何组合的多面骰子,支持调整值、优势、劣势和完整历史记录。内置概率分析器在投掷前显示您的真实成功率。非常适合 Dungeons & Dragons、Pathfinder、Call of Cthulhu、Shadowrun 或任何依赖运气的游戏。' },
|
|
16
|
-
{ type: 'title', text: '
|
|
17
|
-
{ type: 'paragraph', html: '
|
|
16
|
+
{ type: 'title', text: '使用智能回合计时器掌控游戏节奏', level: 2 },
|
|
17
|
+
{ type: 'paragraph', html: '我们都知道那个每回合都要花上大量时间的玩家。游戏计时器让您设定公平又有趣的时间限制,提供适应各种风格的模式:从简单的共享倒计时到 Fischer 增量或 Bronstein 延迟等国际象棋系统。支持双人分屏对决和最多八人的中央控制模式。游戏结束后,可查看时间和游戏节奏的详细统计数据。' },
|
|
18
18
|
{ type: 'title', text: '隐私至上:所有数据保留在您的设备上', level: 2 },
|
|
19
|
-
{ type: 'paragraph', html: '该库的最大优势之一是所有处理都在您的浏览器内完成。不会有数据发送到服务器,没有跟踪 Cookie
|
|
19
|
+
{ type: 'paragraph', html: '该库的最大优势之一是所有处理都在您的浏览器内完成。不会有数据发送到服务器,没有跟踪 Cookie,无需电子邮件或注册。可离线使用工具,与桌上玩家共享屏幕,或投影到电视上,除了游戏本身,无需担心任何事。更多的工具正在开发中,始终以方便桌游玩家为设计目标。' },
|
|
20
20
|
{ type: 'stats', items: [
|
|
21
21
|
{ label: '工具', value: '2+', icon: 'mdi:tools' },
|
|
22
22
|
{ label: '玩家', value: '2-8', icon: 'mdi:account-group' },
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
|
|
5
|
+
|
|
6
|
+
const DIACRITIC_RULES = {
|
|
7
|
+
de: {
|
|
8
|
+
language: 'German',
|
|
9
|
+
expectedCharacters: 'ä ö ü ß',
|
|
10
|
+
characters: /[äöüÄÖÜß]/g,
|
|
11
|
+
minPerThousandLetters: 0.1,
|
|
12
|
+
},
|
|
13
|
+
es: {
|
|
14
|
+
language: 'Spanish',
|
|
15
|
+
expectedCharacters: 'á é í ó ú ü ñ',
|
|
16
|
+
characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
|
|
17
|
+
minPerThousandLetters: 0.1,
|
|
18
|
+
},
|
|
19
|
+
fr: {
|
|
20
|
+
language: 'French',
|
|
21
|
+
expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
|
|
22
|
+
characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
|
|
23
|
+
minPerThousandLetters: 0.1,
|
|
24
|
+
},
|
|
25
|
+
it: {
|
|
26
|
+
language: 'Italian',
|
|
27
|
+
expectedCharacters: 'à è é ì í î ò ó ù ú',
|
|
28
|
+
characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
|
|
29
|
+
minPerThousandLetters: 0.1,
|
|
30
|
+
},
|
|
31
|
+
pl: {
|
|
32
|
+
language: 'Polish',
|
|
33
|
+
expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
|
|
34
|
+
characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
|
|
35
|
+
minPerThousandLetters: 0.1,
|
|
36
|
+
},
|
|
37
|
+
pt: {
|
|
38
|
+
language: 'Portuguese',
|
|
39
|
+
expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
|
|
40
|
+
characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
|
|
41
|
+
minPerThousandLetters: 0.1,
|
|
42
|
+
},
|
|
43
|
+
sv: {
|
|
44
|
+
language: 'Swedish',
|
|
45
|
+
expectedCharacters: 'å ä ö',
|
|
46
|
+
characters: /[åäöÅÄÖ]/g,
|
|
47
|
+
minPerThousandLetters: 0.1,
|
|
48
|
+
},
|
|
49
|
+
tr: {
|
|
50
|
+
language: 'Turkish',
|
|
51
|
+
expectedCharacters: 'ç ğ ı İ ö ş ü',
|
|
52
|
+
characters: /[çğıöşüÇĞİÖŞÜ]/g,
|
|
53
|
+
minPerThousandLetters: 0.1,
|
|
54
|
+
},
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const LETTERS = /\p{L}/gu;
|
|
58
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
59
|
+
|
|
60
|
+
function collectStrings(value: unknown): string[] {
|
|
61
|
+
if (typeof value === 'string') return [value];
|
|
62
|
+
if (!value || typeof value !== 'object') return [];
|
|
63
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
64
|
+
return Object.values(value).flatMap(collectStrings);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeText(value: unknown): string {
|
|
68
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
72
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function letterCount(text: string): number {
|
|
76
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
|
|
80
|
+
return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
|
|
84
|
+
const letters = letterCount(text);
|
|
85
|
+
if (letters === 0) return 0;
|
|
86
|
+
return diacriticCount(text, locale) / letters * 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('Diacritics density validation', () => {
|
|
90
|
+
ALL_TOOLS.forEach((tool) => {
|
|
91
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
92
|
+
Object.keys(DIACRITIC_RULES).forEach((locale) => {
|
|
93
|
+
it(`${locale} keeps the expected accent and special-letter set`, async () => {
|
|
94
|
+
const typedLocale = locale as LocaleWithDiacritics;
|
|
95
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
96
|
+
if (!loader) return;
|
|
97
|
+
|
|
98
|
+
const content = await loader();
|
|
99
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
100
|
+
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
|
+
const letters = letterCount(text);
|
|
102
|
+
const matches = diacriticCount(text, typedLocale);
|
|
103
|
+
const density = diacriticsPerThousandLetters(text, typedLocale);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
density,
|
|
107
|
+
[
|
|
108
|
+
`Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
109
|
+
`The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
|
|
110
|
+
`This locale should include some of these characters: ${rule.expectedCharacters}.`,
|
|
111
|
+
'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
|
|
112
|
+
].join(' '),
|
|
113
|
+
).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const INVERTED_PUNCTUATION_LOCALES = {
|
|
5
|
+
es: {
|
|
6
|
+
language: 'Spanish',
|
|
7
|
+
questionStart: '¿',
|
|
8
|
+
questionEnd: '?',
|
|
9
|
+
exclamationStart: '¡',
|
|
10
|
+
exclamationEnd: '!',
|
|
11
|
+
},
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
|
|
15
|
+
|
|
16
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
17
|
+
|
|
18
|
+
function collectStrings(value: unknown): string[] {
|
|
19
|
+
if (typeof value === 'string') return [value];
|
|
20
|
+
if (!value || typeof value !== 'object') return [];
|
|
21
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
22
|
+
return Object.values(value).flatMap(collectStrings);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function translatableStrings(content: Record<string, unknown>): string[] {
|
|
26
|
+
return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sentenceStart(text: string, endIndex: number): string {
|
|
30
|
+
const beforeMark = text.slice(0, endIndex).trimEnd();
|
|
31
|
+
const boundary = Math.max(
|
|
32
|
+
beforeMark.lastIndexOf('.'),
|
|
33
|
+
beforeMark.lastIndexOf(':'),
|
|
34
|
+
beforeMark.lastIndexOf(';'),
|
|
35
|
+
beforeMark.lastIndexOf('\n'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return beforeMark.slice(boundary + 1).trimStart();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findMissingInvertedMarks(
|
|
42
|
+
text: string,
|
|
43
|
+
startMark: string,
|
|
44
|
+
endMark: string,
|
|
45
|
+
): string[] {
|
|
46
|
+
return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
|
|
47
|
+
.map((match) => sentenceStart(text, match.index ?? 0))
|
|
48
|
+
.filter((segment) => segment.length > 0 && !segment.includes(startMark))
|
|
49
|
+
.map((segment) => `${segment}${endMark}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('Inverted punctuation validation', () => {
|
|
53
|
+
ALL_TOOLS.forEach((tool) => {
|
|
54
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
55
|
+
Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
|
|
56
|
+
it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
|
|
57
|
+
const typedLocale = locale as InvertedPunctuationLocale;
|
|
58
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
59
|
+
if (!loader) return;
|
|
60
|
+
|
|
61
|
+
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
|
+
const content = await loader();
|
|
63
|
+
const strings = translatableStrings(content as Record<string, unknown>);
|
|
64
|
+
const missingQuestions = strings.flatMap((text) =>
|
|
65
|
+
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
|
+
);
|
|
67
|
+
const missingExclamations = strings.flatMap((text) =>
|
|
68
|
+
findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
|
|
69
|
+
);
|
|
70
|
+
const failures = [...missingQuestions, ...missingExclamations];
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
[
|
|
75
|
+
`Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
76
|
+
`Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
|
|
77
|
+
`Examples: ${failures.slice(0, 5).join(' | ')}`,
|
|
78
|
+
].join(' '),
|
|
79
|
+
).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -22,20 +22,49 @@ function getFiles(dir: string): string[] {
|
|
|
22
22
|
return results;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function isContentFile(filePath: string): boolean {
|
|
26
|
+
return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
const srcDir = path.join(process.cwd(), 'src');
|
|
26
30
|
const scriptsDir = path.join(process.cwd(), 'scripts');
|
|
27
31
|
const filesToTest = [
|
|
28
|
-
...getFiles(srcDir),
|
|
29
|
-
...getFiles(scriptsDir),
|
|
32
|
+
...getFiles(srcDir).filter(isContentFile),
|
|
33
|
+
...getFiles(scriptsDir).filter(isContentFile),
|
|
30
34
|
];
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
const aiTypographyGarbage = [
|
|
37
|
+
'\u2013',
|
|
38
|
+
'\u2014',
|
|
39
|
+
'\u2026',
|
|
40
|
+
'\u201C',
|
|
41
|
+
'\u201D',
|
|
42
|
+
'\u2018',
|
|
43
|
+
'\u2019',
|
|
44
|
+
'\u00AB',
|
|
45
|
+
'\u00BB',
|
|
46
|
+
'\u200B',
|
|
47
|
+
'\u201E',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('Typography Garbage Character Validation', () => {
|
|
33
51
|
filesToTest.forEach((filePath) => {
|
|
34
52
|
const relativePath = path.relative(process.cwd(), filePath);
|
|
35
|
-
it(`should not contain
|
|
53
|
+
it(`should not contain typography garbage characters in ${relativePath}`, () => {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
55
|
+
const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
|
|
56
|
+
expect(hasAiPatterns).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`should not contain space before colon in ${relativePath}`, () => {
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const spaceBeforeColon = / : /.test(content);
|
|
62
|
+
expect(spaceBeforeColon).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`should not contain double hyphen in ${relativePath}`, () => {
|
|
36
66
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
37
|
-
|
|
38
|
-
expect(hasEnDash).toBe(false);
|
|
67
|
+
expect(content).not.toContain('--');
|
|
39
68
|
});
|
|
40
69
|
});
|
|
41
70
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1603,6 +1603,100 @@
|
|
|
1603
1603
|
animation: sparkle-float 4s ease-in-out infinite;
|
|
1604
1604
|
}
|
|
1605
1605
|
|
|
1606
|
+
@media (max-width: 600px) {
|
|
1607
|
+
main:has(.timer-main-wrapper),
|
|
1608
|
+
.tool-page:has(.timer-main-wrapper),
|
|
1609
|
+
.section-tool:has(.timer-main-wrapper),
|
|
1610
|
+
.tool-page:has(.timer-main-wrapper) .utility-header,
|
|
1611
|
+
.tool-page:has(.timer-main-wrapper) .utility-header-title,
|
|
1612
|
+
.tool-page:has(.timer-main-wrapper) .utility-header-base,
|
|
1613
|
+
.tool-page:has(.timer-main-wrapper) .utility-header-highlight,
|
|
1614
|
+
.tool-page:has(.timer-main-wrapper) .utility-header-description {
|
|
1615
|
+
min-width: 0;
|
|
1616
|
+
max-width: 100%;
|
|
1617
|
+
overflow-wrap: anywhere;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
main:has(.timer-main-wrapper) {
|
|
1621
|
+
overflow-x: hidden;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
.tool-page:has(.timer-main-wrapper) .utility-header-title {
|
|
1625
|
+
font-size: clamp(2rem, 12vw, 3rem);
|
|
1626
|
+
letter-spacing: 0;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
.timer-main-wrapper {
|
|
1630
|
+
padding: 1rem;
|
|
1631
|
+
gap: 1.25rem;
|
|
1632
|
+
border-radius: 1rem;
|
|
1633
|
+
min-width: 0;
|
|
1634
|
+
overflow: hidden;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
.timer-setup-panel,
|
|
1638
|
+
.setup-card,
|
|
1639
|
+
.settings-group,
|
|
1640
|
+
.control-box,
|
|
1641
|
+
.player-input-block {
|
|
1642
|
+
min-width: 0;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
.setup-card {
|
|
1646
|
+
padding: 1rem;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
.presets-grid,
|
|
1650
|
+
.settings-grid,
|
|
1651
|
+
.general-stats-grid {
|
|
1652
|
+
grid-template-columns: 1fr;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
.player-input-row-nowrap,
|
|
1656
|
+
.time-inputs-container,
|
|
1657
|
+
.multiplayer-header-bar,
|
|
1658
|
+
.game-meta-indicators,
|
|
1659
|
+
.header-action-group {
|
|
1660
|
+
flex-wrap: wrap;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
.text-input,
|
|
1664
|
+
.select-input,
|
|
1665
|
+
.number-input-wrapper,
|
|
1666
|
+
.time-adjuster-group {
|
|
1667
|
+
min-width: 0;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
.time-adjuster-group {
|
|
1671
|
+
flex: 1 1 7.5rem;
|
|
1672
|
+
justify-content: center;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
.duel-controls-bar {
|
|
1676
|
+
padding: 0.5rem;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
.side-timer {
|
|
1680
|
+
font-size: clamp(2.6rem, 16vw, 4rem);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
.multiplayer-grid {
|
|
1684
|
+
grid-template-columns: 1fr;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.giant-next-btn {
|
|
1688
|
+
min-width: 0;
|
|
1689
|
+
width: 100%;
|
|
1690
|
+
padding: 1rem;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
.stats-modal-card,
|
|
1694
|
+
.overlay-card {
|
|
1695
|
+
width: calc(100% - 1.5rem);
|
|
1696
|
+
padding: 1.25rem;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1606
1700
|
@keyframes sparkle-float {
|
|
1607
1701
|
0%, 100% {
|
|
1608
1702
|
opacity: 0;
|
|
@@ -99,7 +99,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
type: 'proscons',
|
|
102
|
-
title: 'Avantages et inconvénients
|
|
102
|
+
title: 'Avantages et inconvénients: Incrément Fischer vs Retard Bronstein',
|
|
103
103
|
items: [
|
|
104
104
|
{
|
|
105
105
|
pro: 'L\'incrément Fischer récompense la rapidité en accumulant du temps pour les tours futurs.',
|
|
@@ -211,7 +211,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
|
|
|
211
211
|
howTo: [
|
|
212
212
|
{
|
|
213
213
|
name: 'Configurer la partie',
|
|
214
|
-
text: 'Sélectionnez un préréglage (Casual, Blitz, Stratégique, Sablier) ou définissez vos paramètres personnalisés
|
|
214
|
+
text: 'Sélectionnez un préréglage (Casual, Blitz, Stratégique, Sablier) ou définissez vos paramètres personnalisés: temps de base, incrément/retard, et seuil d\'alerte.',
|
|
215
215
|
},
|
|
216
216
|
{
|
|
217
217
|
name: 'Ajouter et organiser les joueurs',
|
|
@@ -285,7 +285,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
|
|
|
285
285
|
{
|
|
286
286
|
'@type': 'HowToStep',
|
|
287
287
|
'name': 'Configurer la partie',
|
|
288
|
-
'text': 'Sélectionnez un préréglage (Casual, Blitz, Stratégique, Sablier) ou définissez vos paramètres personnalisés
|
|
288
|
+
'text': 'Sélectionnez un préréglage (Casual, Blitz, Stratégique, Sablier) ou définissez vos paramètres personnalisés: temps de base, incrément/retard, et seuil d\'alerte.'
|
|
289
289
|
},
|
|
290
290
|
{
|
|
291
291
|
'@type': 'HowToStep',
|
|
@@ -219,7 +219,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
|
|
|
219
219
|
},
|
|
220
220
|
{
|
|
221
221
|
name: '启动并交替回合',
|
|
222
|
-
text: '
|
|
222
|
+
text: '开始游戏。在双人模式中,点击你的屏幕半区以结束回合。在多人模式中,点击中央巨大的"结束回合"按钮。',
|
|
223
223
|
},
|
|
224
224
|
],
|
|
225
225
|
schemas: [
|
|
@@ -295,7 +295,7 @@ export const content: ToolLocaleContent<BoardGameTimerUI> = {
|
|
|
295
295
|
{
|
|
296
296
|
'@type': 'HowToStep',
|
|
297
297
|
'name': '启动并交替回合',
|
|
298
|
-
'text': '
|
|
298
|
+
'text': '开始游戏。在双人模式中,点击你的屏幕半区以结束回合。在多人模式中,点击中央巨大的"结束回合"按钮。'
|
|
299
299
|
}
|
|
300
300
|
]
|
|
301
301
|
}
|
|
@@ -646,3 +646,92 @@
|
|
|
646
646
|
font-size: 0.85rem;
|
|
647
647
|
font-weight: 800;
|
|
648
648
|
}
|
|
649
|
+
|
|
650
|
+
@media (max-width: 600px) {
|
|
651
|
+
main:has(.wheel-dashboard),
|
|
652
|
+
.tool-page:has(.wheel-dashboard),
|
|
653
|
+
.section-tool:has(.wheel-dashboard),
|
|
654
|
+
.tool-page:has(.wheel-dashboard) .utility-header,
|
|
655
|
+
.tool-page:has(.wheel-dashboard) .utility-header-title,
|
|
656
|
+
.tool-page:has(.wheel-dashboard) .utility-header-base,
|
|
657
|
+
.tool-page:has(.wheel-dashboard) .utility-header-highlight,
|
|
658
|
+
.tool-page:has(.wheel-dashboard) .utility-header-description {
|
|
659
|
+
min-width: 0;
|
|
660
|
+
max-width: 100%;
|
|
661
|
+
overflow-wrap: anywhere;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
main:has(.wheel-dashboard) {
|
|
665
|
+
overflow-x: hidden;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.tool-page:has(.wheel-dashboard) .utility-header-title {
|
|
669
|
+
font-size: clamp(2rem, 12vw, 3rem);
|
|
670
|
+
letter-spacing: 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.wheel-dashboard {
|
|
674
|
+
padding: 1rem;
|
|
675
|
+
gap: 1.5rem;
|
|
676
|
+
border-radius: 1rem;
|
|
677
|
+
min-width: 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.wheel-layout,
|
|
681
|
+
.canvas-card,
|
|
682
|
+
.wheel-canvas-section,
|
|
683
|
+
.wheel-canvas-container,
|
|
684
|
+
.tab-container,
|
|
685
|
+
.editor-card {
|
|
686
|
+
min-width: 0;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.canvas-card,
|
|
690
|
+
.tab-container {
|
|
691
|
+
padding: 1rem;
|
|
692
|
+
border-radius: 1rem;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.wheel-canvas {
|
|
696
|
+
width: min(320px, calc(100vw - 6rem));
|
|
697
|
+
height: min(320px, calc(100vw - 6rem));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.spin-button {
|
|
701
|
+
min-height: 3.5rem;
|
|
702
|
+
height: auto;
|
|
703
|
+
padding: 0.85rem;
|
|
704
|
+
font-size: 1rem;
|
|
705
|
+
white-space: normal;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.tab-headers,
|
|
709
|
+
.preset-group {
|
|
710
|
+
flex-wrap: wrap;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.tab-header {
|
|
714
|
+
flex: 1 1 7rem;
|
|
715
|
+
padding: 0.6rem 0.75rem;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.segment-row {
|
|
719
|
+
align-items: stretch;
|
|
720
|
+
flex-wrap: wrap;
|
|
721
|
+
gap: 0.5rem;
|
|
722
|
+
padding: 0.75rem;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.segment-input {
|
|
726
|
+
flex: 1 1 calc(100% - 2.5rem);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.segment-weight {
|
|
730
|
+
flex: 1 1 6rem;
|
|
731
|
+
width: auto;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.btn-remove-segment {
|
|
735
|
+
flex: 1 1 5rem;
|
|
736
|
+
}
|
|
737
|
+
}
|