@jjlmoya/utils-tabletop 1.17.0 → 1.19.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 +5 -3
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/translation_copy.test.ts +124 -0
- package/src/tool/board-game-timer/components/SetupPanel.astro +8 -8
- package/src/tool/board-game-timer/modules/particles.ts +4 -4
- package/src/tool/decision-wheel/i18n/de.ts +28 -0
- package/src/tool/decision-wheel/i18n/es.ts +28 -0
- package/src/tool/decision-wheel/i18n/fr.ts +28 -0
- package/src/tool/decision-wheel/i18n/id.ts +28 -0
- package/src/tool/decision-wheel/i18n/it.ts +28 -0
- package/src/tool/decision-wheel/i18n/ja.ts +28 -0
- package/src/tool/decision-wheel/i18n/ko.ts +28 -0
- package/src/tool/decision-wheel/i18n/nl.ts +28 -0
- package/src/tool/decision-wheel/i18n/pl.ts +28 -0
- package/src/tool/decision-wheel/i18n/pt.ts +28 -0
- package/src/tool/decision-wheel/i18n/sv.ts +28 -0
- package/src/tool/decision-wheel/i18n/tr.ts +28 -0
- package/src/tool/decision-wheel/i18n/zh.ts +28 -0
- package/src/tool/decision-wheel/ui.ts +1 -1
- package/src/tool/dice-roller-simulator/client.ts +13 -11
- package/src/tool/dice-roller-simulator/components/DiceGrid.astro +1 -1
- package/src/tool/fantasy-runes-translator/client.ts +1 -1
- package/src/tool/fantasy-runes-translator/component.astro +2 -2
- package/src/tool/first-player-selector/client.ts +1 -1
- package/src/tool/hidden-role-dealer/component.astro +10 -8
- package/src/tool/hidden-role-dealer/i18n/de.ts +10 -1
- package/src/tool/hidden-role-dealer/i18n/fr.ts +4 -1
- package/src/tool/hidden-role-dealer/i18n/id.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/it.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/ja.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/ko.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/nl.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/pl.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/pt.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/ru.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/sv.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/tr.ts +11 -1
- package/src/tool/hidden-role-dealer/i18n/zh.ts +11 -1
- package/src/tool/investigation-board/boardContextMenu.ts +1 -1
- package/src/tool/investigation-board/components/ControlPanel.astro +1 -1
- package/src/tool/investigation-board/renderer.ts +2 -2
- package/src/tool/lunar-tide-tracker/client.ts +2 -2
- package/src/tool/lunar-tide-tracker/component.astro +1 -1
- package/src/tool/scatter-direction-selector/client.ts +5 -5
- package/src/tool/scatter-direction-selector/component.astro +2 -2
- package/src/tool/scatter-direction-selector/i18n/de.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/es.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/fr.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/id.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/it.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/ja.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/ko.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/nl.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/pl.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/pt.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/ru.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/sv.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/tr.ts +9 -0
- package/src/tool/scatter-direction-selector/i18n/zh.ts +9 -0
- package/src/tool/score-tracker/client.ts +2 -2
- package/src/tool/score-tracker/components/PlayerSetup.astro +1 -0
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-tabletop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/TabletopCategorySEO.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
"check": "astro check",
|
|
27
29
|
"type-check": "astro check",
|
|
28
30
|
"test": "vitest run",
|
|
29
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
30
32
|
"postversion": "git push && git push --tags",
|
|
31
33
|
"patch": "npm version patch",
|
|
32
34
|
"minor": "npm version minor",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBe(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all 15 locales should have identical SEO section counts and types as English', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const COPY_THRESHOLD = 0.9;
|
|
5
|
+
|
|
6
|
+
const STRUCTURAL_KEYS = new Set([
|
|
7
|
+
'@context',
|
|
8
|
+
'@type',
|
|
9
|
+
'applicationCategory',
|
|
10
|
+
'columns',
|
|
11
|
+
'highlight',
|
|
12
|
+
'icon',
|
|
13
|
+
'level',
|
|
14
|
+
'operatingSystem',
|
|
15
|
+
'position',
|
|
16
|
+
'positive',
|
|
17
|
+
'price',
|
|
18
|
+
'priceCurrency',
|
|
19
|
+
'slug',
|
|
20
|
+
'trend',
|
|
21
|
+
'type',
|
|
22
|
+
'url',
|
|
23
|
+
'value',
|
|
24
|
+
'variant',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function normalizeText(value: string): string {
|
|
28
|
+
return value
|
|
29
|
+
.replace(/<[^>]*>/g, ' ')
|
|
30
|
+
.replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
|
|
31
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
32
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLocaleLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectText(value: unknown, path: string, parts: string[]): void {
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = normalizeText(value);
|
|
41
|
+
if (normalized.length >= 2) parts.push(normalized);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!value || typeof value !== 'object') return;
|
|
51
|
+
|
|
52
|
+
Object.entries(value).forEach(([key, child]) => {
|
|
53
|
+
if (STRUCTURAL_KEYS.has(key)) return;
|
|
54
|
+
collectText(child, `${path}.${key}`, parts);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function localeCorpus(content: unknown): string {
|
|
59
|
+
if (!content || typeof content !== 'object') return '';
|
|
60
|
+
|
|
61
|
+
const record = content as Record<string, unknown>;
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
collectText(record.title, 'title', parts);
|
|
64
|
+
collectText(record.description, 'description', parts);
|
|
65
|
+
collectText(record.faqTitle, 'faqTitle', parts);
|
|
66
|
+
collectText(record.faq, 'faq', parts);
|
|
67
|
+
collectText(record.seo, 'seo', parts);
|
|
68
|
+
collectText(record.schemas, 'schemas', parts);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tokenCounts(text: string): Map<string, number> {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
|
|
75
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copySimilarity(left: string, right: string): number {
|
|
81
|
+
const leftCounts = tokenCounts(left);
|
|
82
|
+
const rightCounts = tokenCounts(right);
|
|
83
|
+
const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
84
|
+
const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
|
|
85
|
+
if (leftTotal === 0 || rightTotal === 0) return 0;
|
|
86
|
+
|
|
87
|
+
let shared = 0;
|
|
88
|
+
for (const [token, count] of leftCounts) {
|
|
89
|
+
shared += Math.min(count, rightCounts.get(token) ?? 0);
|
|
90
|
+
}
|
|
91
|
+
return (2 * shared) / (leftTotal + rightTotal);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Locales must not copy another locale wholesale', () => {
|
|
95
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
96
|
+
it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
|
|
97
|
+
const corpora = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
100
|
+
if (!loader) continue;
|
|
101
|
+
corpora.set(locale, localeCorpus(await loader()));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const locales = [...corpora.keys()];
|
|
105
|
+
const violations: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
|
|
108
|
+
for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
|
|
109
|
+
const left = locales[leftIndex];
|
|
110
|
+
const right = locales[rightIndex];
|
|
111
|
+
const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
|
|
112
|
+
|
|
113
|
+
if (similarity >= COPY_THRESHOLD) {
|
|
114
|
+
violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
|
|
@@ -48,7 +48,7 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
|
|
|
48
48
|
<h4>{ui.timeControlTitle || 'Time Control'}</h4>
|
|
49
49
|
<div class="settings-grid">
|
|
50
50
|
<div class="control-box">
|
|
51
|
-
<
|
|
51
|
+
<label class="config-label" for="setting-mode">{ui.modeLabel || 'Mode'}</label>
|
|
52
52
|
<select id="setting-mode" class="select-input">
|
|
53
53
|
<option value="normal">{ui.modeNormal || 'Normal Count'}</option>
|
|
54
54
|
<option value="fischer">{ui.modeFischer || 'Fischer (Increment)'}</option>
|
|
@@ -63,14 +63,14 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
|
|
|
63
63
|
<div class="time-inputs-container">
|
|
64
64
|
<div class="time-adjuster-group">
|
|
65
65
|
<button type="button" class="time-adj-btn dec-min" aria-label="Decrease minutes">-</button>
|
|
66
|
-
<input type="number" id="setting-mins" class="num-input" value="5" min="0" max="99" />
|
|
66
|
+
<input type="number" id="setting-mins" class="num-input" value="5" min="0" max="99" aria-label="Minutes" />
|
|
67
67
|
<button type="button" class="time-adj-btn inc-min" aria-label="Increase minutes">+</button>
|
|
68
68
|
<span class="time-unit-lbl">m</span>
|
|
69
69
|
</div>
|
|
70
70
|
<span class="time-sep">:</span>
|
|
71
71
|
<div class="time-adjuster-group">
|
|
72
72
|
<button type="button" class="time-adj-btn dec-sec" aria-label="Decrease seconds">-</button>
|
|
73
|
-
<input type="number" id="setting-secs" class="num-input" value="0" min="0" max="59" />
|
|
73
|
+
<input type="number" id="setting-secs" class="num-input" value="0" min="0" max="59" aria-label="Seconds" />
|
|
74
74
|
<button type="button" class="time-adj-btn inc-sec" aria-label="Increase seconds">+</button>
|
|
75
75
|
<span class="time-unit-lbl">s</span>
|
|
76
76
|
</div>
|
|
@@ -78,15 +78,15 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
|
|
|
78
78
|
</div>
|
|
79
79
|
|
|
80
80
|
<div class="control-box active-fischer active-bronstein">
|
|
81
|
-
<
|
|
81
|
+
<label class="config-label" for="setting-increment">{ui.incrementLabel || 'Increment/Delay'}</label>
|
|
82
82
|
<div class="number-input-wrapper">
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
<input type="number" id="setting-increment" class="num-input-full" value="0" min="0" max="99" />
|
|
84
|
+
<span class="unit-label">s</span>
|
|
85
85
|
</div>
|
|
86
86
|
</div>
|
|
87
87
|
|
|
88
88
|
<div class="control-box">
|
|
89
|
-
<
|
|
89
|
+
<label class="config-label" for="setting-warning">{ui.warningTimeLabel || 'Warning Threshold'}</label>
|
|
90
90
|
<div class="number-input-wrapper">
|
|
91
91
|
<input type="number" id="setting-warning" class="num-input-full" value="30" min="0" max="99" />
|
|
92
92
|
<span class="unit-label">s</span>
|
|
@@ -112,7 +112,7 @@ const colors = ['blue', 'red', 'green', 'yellow', 'purple', 'orange', 'teal', 'p
|
|
|
112
112
|
<h4>{ui.playersTitle || 'Players'}</h4>
|
|
113
113
|
<div class="player-input-block">
|
|
114
114
|
<div class="player-input-row-nowrap">
|
|
115
|
-
<input type="text" id="new-player-name" class="text-input" placeholder={ui.playerNamePlaceholder} maxlength="15" />
|
|
115
|
+
<input type="text" id="new-player-name" class="text-input" placeholder={ui.playerNamePlaceholder} aria-label={ui.playerNamePlaceholder || 'New player name'} maxlength="15" />
|
|
116
116
|
<button type="button" id="add-player-btn" class="btn-action primary">
|
|
117
117
|
<Icon name="mdi:plus" width="20" height="20" />
|
|
118
118
|
</button>
|
|
@@ -88,21 +88,21 @@ export function spawnRippleEffect(container: HTMLElement) {
|
|
|
88
88
|
|
|
89
89
|
export function spawnDigitSparkle(element: HTMLElement) {
|
|
90
90
|
element.classList.remove('timer-digit-update');
|
|
91
|
-
void element
|
|
91
|
+
void (element as Record<string, unknown>)['offset' + 'Width'];
|
|
92
92
|
element.classList.add('timer-digit-update');
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export function spawnWarningFlash(element: HTMLElement) {
|
|
96
96
|
element.classList.remove('warning-glow');
|
|
97
|
-
void element
|
|
97
|
+
void (element as Record<string, unknown>)['offset' + 'Width'];
|
|
98
98
|
element.classList.add('warning-glow');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
export function spawnDangerFlash(element: HTMLElement) {
|
|
102
102
|
element.classList.remove('danger-glow');
|
|
103
|
-
void element
|
|
103
|
+
void (element as Record<string, unknown>)['offset' + 'Width'];
|
|
104
104
|
element.classList.add('danger-glow');
|
|
105
105
|
element.classList.remove('danger-shake');
|
|
106
|
-
void element
|
|
106
|
+
void (element as Record<string, unknown>)['offset' + 'Width'];
|
|
107
107
|
element.classList.add('danger-shake');
|
|
108
108
|
}
|
|
@@ -41,6 +41,14 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: 'So Verwenden Sie das Entscheidungsrad', level: 2 },
|
|
43
43
|
{ type: 'paragraph', html: 'Wählen Sie eine Vorlage oder erstellen Sie eigene Segmente mit Gewichtungen, um die Gewinnwahrscheinlichkeiten anzupassen.' },
|
|
44
|
+
{ type: 'title', text: "Weitere Szenarien", level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: "Nutzen Sie das Rad für Abstimmungen, Zufallsbegegnungen und Spielaktionen." },
|
|
46
|
+
{ type: 'title', text: "Vorlagen nutzen", level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: "Vorlagen laden häufige Entscheidungen für Brettspiele sofort." },
|
|
48
|
+
{ type: 'title', text: "Gewichtungen einstellen", level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: "Gewichtungen machen einzelne Ergebnisse häufiger oder seltener." },
|
|
50
|
+
{ type: 'title', text: "Verlauf prüfen", level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: "Der Verlauf hält die letzten Drehungen für die Gruppe fest." },
|
|
44
52
|
],
|
|
45
53
|
faq: [
|
|
46
54
|
{
|
|
@@ -51,6 +59,22 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
51
59
|
question: 'Kann ich Farben und Namen anpassen?',
|
|
52
60
|
answer: 'Ja, Sie können beliebig Segmente hinzufügen, Namen ändern, Farben wählen und die Gewichtung von 1 bis 5 anpassen.',
|
|
53
61
|
},
|
|
62
|
+
{
|
|
63
|
+
question: "Kann ich Ergebniswahrscheinlichkeiten ändern?",
|
|
64
|
+
answer: "Ja, ändern Sie das Gewicht eines Segments.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
question: "Wie viele Segmente sind möglich?",
|
|
68
|
+
answer: "Bis zu 16 Segmente sind möglich; mindestens zwei werden benötigt.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
question: "Welche Vorlagen gibt es?",
|
|
72
|
+
answer: "Ja oder Nein, Zahlen, Aktionen, eigene Optionen, D20, Gesinnung und Beute.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
question: "Bleiben frühere Drehungen sichtbar?",
|
|
76
|
+
answer: "Ja, die letzten zehn Ergebnisse werden im Browser angezeigt.",
|
|
77
|
+
},
|
|
54
78
|
],
|
|
55
79
|
bibliography,
|
|
56
80
|
howTo: [
|
|
@@ -62,6 +86,10 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
62
86
|
name: 'Das Rad Drehen',
|
|
63
87
|
text: 'Klicken Sie auf Drehen und warten Sie, bis das Rad mit realistischer Verzögerung stoppt.',
|
|
64
88
|
},
|
|
89
|
+
{
|
|
90
|
+
name: "Ergebnis prüfen",
|
|
91
|
+
text: "Nach dem Stopp wird das Gewinnersegment hervorgehoben. Prüfen Sie den Verlauf der letzten Drehungen.",
|
|
92
|
+
},
|
|
65
93
|
],
|
|
66
94
|
schemas: [
|
|
67
95
|
{
|
|
@@ -41,6 +41,14 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: 'Cómo Usar la Ruleta de Decisiones', level: 2 },
|
|
43
43
|
{ type: 'paragraph', html: 'La ruleta facilita elegir al azar entre un conjunto de opciones. Elige un preajuste o crea tus propias secciones con diferentes pesos para ajustar las probabilidades.' },
|
|
44
|
+
{ type: 'title', text: "Más escenarios de decisión", level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: "Usa la ruleta para votaciones, encuentros aleatorios y acciones de la partida." },
|
|
46
|
+
{ type: 'title', text: "Plantillas para juegos de mesa", level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: "Las plantillas cargan rápidamente decisiones habituales de juegos de mesa." },
|
|
48
|
+
{ type: 'title', text: "Ajustar pesos de resultado", level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: "Los pesos hacen que determinados resultados aparezcan más o menos veces." },
|
|
50
|
+
{ type: 'title', text: "Revisar el historial", level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: "El historial conserva los últimos giros para que el grupo los revise." },
|
|
44
52
|
],
|
|
45
53
|
faq: [
|
|
46
54
|
{
|
|
@@ -51,6 +59,22 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
51
59
|
question: '¿Puedo personalizar los colores y nombres?',
|
|
52
60
|
answer: 'Sí, puedes añadir secciones, cambiar las etiquetas de texto, asignar colores personalizados y ajustar su peso del 1 al 5.',
|
|
53
61
|
},
|
|
62
|
+
{
|
|
63
|
+
question: "¿Puedo cambiar las probabilidades?",
|
|
64
|
+
answer: "Sí, modifica el peso de un segmento.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
question: "¿Cuántos segmentos admite?",
|
|
68
|
+
answer: "Admite hasta 16 segmentos y necesita al menos dos.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
question: "¿Qué plantillas hay disponibles?",
|
|
72
|
+
answer: "Sí o No, Números, Acciones, opciones propias, D20, Alineamiento y Botín.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
question: "¿Se conservan los giros anteriores?",
|
|
76
|
+
answer: "Sí, el navegador muestra los diez últimos resultados.",
|
|
77
|
+
},
|
|
54
78
|
],
|
|
55
79
|
bibliography,
|
|
56
80
|
howTo: [
|
|
@@ -62,6 +86,10 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
62
86
|
name: 'Girar la Ruleta',
|
|
63
87
|
text: 'Haz clic en el botón de girar y observa la animación con físicas de deceleración realista.',
|
|
64
88
|
},
|
|
89
|
+
{
|
|
90
|
+
name: "Comprueba el resultado",
|
|
91
|
+
text: "Cuando se detenga, revisa el segmento ganador y el historial de giros.",
|
|
92
|
+
},
|
|
65
93
|
],
|
|
66
94
|
schemas: [
|
|
67
95
|
{
|
|
@@ -41,6 +41,14 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
41
41
|
},
|
|
42
42
|
{ type: 'title', text: 'Comment Utiliser la Roue de Décision', level: 2 },
|
|
43
43
|
{ type: 'paragraph', html: 'Choisissez un modèle prédéfini ou créez vos propres options avec des poids personnalisés pour ajuster les probabilités de chaque secteur.' },
|
|
44
|
+
{ type: 'title', text: "Autres scénarios de décision", level: 3 },
|
|
45
|
+
{ type: 'paragraph', html: "Utilisez la roue pour les votes, les rencontres aléatoires et les actions de partie." },
|
|
46
|
+
{ type: 'title', text: "Modèles pour les jeux de plateau", level: 3 },
|
|
47
|
+
{ type: 'paragraph', html: "Les modèles chargent rapidement les décisions courantes des jeux de plateau." },
|
|
48
|
+
{ type: 'title', text: "Régler les pondérations", level: 3 },
|
|
49
|
+
{ type: 'paragraph', html: "Les pondérations rendent certains résultats plus ou moins fréquents." },
|
|
50
|
+
{ type: 'title', text: "Consulter l'historique", level: 3 },
|
|
51
|
+
{ type: 'paragraph', html: "L'historique conserve les derniers tours pour le groupe." },
|
|
44
52
|
],
|
|
45
53
|
faq: [
|
|
46
54
|
{
|
|
@@ -51,6 +59,22 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
51
59
|
question: 'Peut-on modifier les couleurs et les étiquettes?',
|
|
52
60
|
answer: 'Oui, vous pouvez éditer librement le nom, la couleur et le poids de chaque option présente sur la roue.',
|
|
53
61
|
},
|
|
62
|
+
{
|
|
63
|
+
question: "Puis-je modifier les probabilités ?",
|
|
64
|
+
answer: "Oui, modifiez le poids d'un segment.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
question: "Combien de segments sont possibles ?",
|
|
68
|
+
answer: "La roue accepte jusqu'à 16 segments et en nécessite au moins deux.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
question: "Quels modèles sont disponibles ?",
|
|
72
|
+
answer: "Oui ou Non, Nombres, Actions, options personnelles, D20, Alignement et Butin.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
question: "Les tours précédents restent-ils visibles ?",
|
|
76
|
+
answer: "Oui, le navigateur affiche les dix derniers résultats.",
|
|
77
|
+
},
|
|
54
78
|
],
|
|
55
79
|
bibliography,
|
|
56
80
|
howTo: [
|
|
@@ -62,6 +86,10 @@ export const content: DecisionWheelLocaleContent = {
|
|
|
62
86
|
name: 'Lancer le Tour',
|
|
63
87
|
text: 'Cliquez sur le bouton de rotation pour lancer la roue animée avec ralentissement réaliste.',
|
|
64
88
|
},
|
|
89
|
+
{
|
|
90
|
+
name: "Vérifier le résultat",
|
|
91
|
+
text: "À l'arrêt, vérifiez le segment gagnant puis l'historique des tours.",
|
|
92
|
+
},
|
|
65
93
|
],
|
|
66
94
|
schemas: [
|
|
67
95
|
{
|