@jjlmoya/utils-streaming 1.12.0 → 1.13.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 +4 -2
- package/src/category/i18n/de.ts +1 -1
- package/src/category/i18n/fr.ts +12 -12
- package/src/category/i18n/pl.ts +2 -2
- package/src/category/i18n/ru.ts +7 -7
- package/src/category/i18n/zh.ts +1 -1
- package/src/layouts/PreviewLayout.astro +7 -2
- 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 +70 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tool/sorteo/i18n/de.ts +10 -10
- package/src/tool/sorteo/i18n/fr.ts +2 -2
- package/src/tool/sorteo/i18n/ja.ts +1 -1
- package/src/tool/sorteo/i18n/pl.ts +7 -7
- package/src/tool/sorteo/i18n/ru.ts +14 -14
- package/src/tool/sorteo/i18n/zh.ts +7 -7
- package/src/tool/sorteo/ui-manager.ts +1 -1
- package/src/tool/tebasCheck/i18n/de.ts +11 -11
- package/src/tool/tebasCheck/i18n/es.ts +1 -1
- package/src/tool/tebasCheck/i18n/fr.ts +8 -8
- package/src/tool/tebasCheck/i18n/ru.ts +4 -4
- package/src/tool/tebasCheck/i18n/zh.ts +10 -10
- package/src/tool/tebasCheck/ui-manager.ts +1 -1
|
@@ -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,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
|
+
});
|
|
@@ -12,7 +12,7 @@ const faqData = [
|
|
|
12
12
|
{
|
|
13
13
|
question: 'Ist dieses Gewinnspiel wirklich zufällig?',
|
|
14
14
|
answer:
|
|
15
|
-
'Ja, wir verwenden den kryptografischen Zufallsalgorithmus des Browsers (Web Crypto API), um sicherzustellen, dass jeder Teilnehmer genau die gleiche Gewinnwahrscheinlichkeit hat
|
|
15
|
+
'Ja, wir verwenden den kryptografischen Zufallsalgorithmus des Browsers (Web Crypto API), um sicherzustellen, dass jeder Teilnehmer genau die gleiche Gewinnwahrscheinlichkeit hat - ohne Verzerrung oder Manipulation.',
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
question: 'Kann ich diesen Generator auf Twitch oder YouTube verwenden?',
|
|
@@ -32,7 +32,7 @@ const faqData = [
|
|
|
32
32
|
{
|
|
33
33
|
question: 'Wie viele Namen kann ich zur Liste hinzufügen?',
|
|
34
34
|
answer:
|
|
35
|
-
'Es gibt keine strikte Grenze vonseiten des Tools. Wir haben die Engine so optimiert, dass sie Listen mit tausenden Teilnehmern ohne Performance-Probleme verarbeitet
|
|
35
|
+
'Es gibt keine strikte Grenze vonseiten des Tools. Wir haben die Engine so optimiert, dass sie Listen mit tausenden Teilnehmern ohne Performance-Probleme verarbeitet - ideal also auch für riesige Gewinnspiele.',
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
question: 'Werden meine Daten oder die Teilnehmerliste gespeichert?',
|
|
@@ -51,7 +51,7 @@ const howToData = [
|
|
|
51
51
|
text: 'Wählen Sie aus, wie viele Gewinner Sie benötigen und ob Sie Duplikate oder leere Zeilen filtern möchten.',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: 'Die
|
|
54
|
+
name: 'Die \"Glücksfee\" starten',
|
|
55
55
|
text: 'Klicken Sie auf die Schaltfläche für die Auslosung. Eine visuelle Animation sorgt für Spannung, bevor der Gewinner enthüllt wird.',
|
|
56
56
|
},
|
|
57
57
|
{
|
|
@@ -110,7 +110,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
type: 'paragraph',
|
|
113
|
-
html: 'Fragen Sie sich, wie Sie online ein Gewinnspiel schnell, sicher und absolut transparent durchführen können? Unser kostenloser <strong>Namens-Picker</strong> ist die ultimative Lösung, um in Sekunden einen Zufallsgewinner zu ziehen. Entwickelt, um einfach, visuell und effektiv zu sein
|
|
113
|
+
html: 'Fragen Sie sich, wie Sie online ein Gewinnspiel schnell, sicher und absolut transparent durchführen können? Unser kostenloser <strong>Namens-Picker</strong> ist die ultimative Lösung, um in Sekunden einen Zufallsgewinner zu ziehen. Entwickelt, um einfach, visuell und effektiv zu sein - perfekt für jedes Szenario, in dem Sie eine digitale \"Glücksfee\" benötigen.',
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
type: 'paragraph',
|
|
@@ -165,9 +165,9 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
165
165
|
{
|
|
166
166
|
type: 'list',
|
|
167
167
|
items: [
|
|
168
|
-
'<strong>Schritt 1
|
|
169
|
-
'<strong>Schritt 2
|
|
170
|
-
'<strong>Schritt 3
|
|
168
|
+
'<strong>Schritt 1 - Teilnehmer eingeben:</strong> Fügen Sie Ihre Namensliste in das Haupttextfeld ein. Das Tool erkennt jeden Zeilenumbruch als separaten Teilnehmer. Haben Sie Duplikate? Kein Problem, das Tool entfernt sie automatisch.',
|
|
169
|
+
'<strong>Schritt 2 - Anpassen:</strong> In den Einstellungen können Sie den Countdown für mehr Spannung, den Konfetti-Effekt zum Feiern oder die \"Blacklist\" zum Ausschluss bestimmter Namen aktivieren.',
|
|
170
|
+
'<strong>Schritt 3 - Auslosen!</strong> Klicken Sie auf die Hauptschaltfläche. Unsere Engine generiert eine kryptografisch sichere Zufallsauswahl. Die Gewinner werden klar und einprägsam angezeigt.',
|
|
171
171
|
],
|
|
172
172
|
},
|
|
173
173
|
{
|
|
@@ -177,12 +177,12 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
177
177
|
},
|
|
178
178
|
{
|
|
179
179
|
type: 'paragraph',
|
|
180
|
-
html: 'Möchten Sie Ihre treuesten Abonnenten belohnen oder bestimmten Teilnehmern mehr Chancen einräumen? Unser System für <strong>gewichtete Einträge</strong> ist einzigartig und ermöglicht es Ihnen, jedem Namen ein
|
|
180
|
+
html: 'Möchten Sie Ihre treuesten Abonnenten belohnen oder bestimmten Teilnehmern mehr Chancen einräumen? Unser System für <strong>gewichtete Einträge</strong> ist einzigartig und ermöglicht es Ihnen, jedem Namen ein \"Gewicht\" oder einen Multiplikator zuzuweisen, ohne ihn mehrfach aufschreiben zu müssen.',
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
type: 'tip',
|
|
184
184
|
title: 'So weisen Sie Gewichte zu',
|
|
185
|
-
html: '<p>Verwenden Sie ein Sternchen (*) oder ein
|
|
185
|
+
html: '<p>Verwenden Sie ein Sternchen (*) oder ein \"x\", gefolgt von der Anzahl der Teilnahmen. Beispiele:</p><ul><li><strong>\"Max * 5\"</strong> - Max nimmt teil, als wäre er 5 Personen</li><li><strong>\"Julia x 10\"</strong> - Julia hat eine 10-mal höhere Gewinnchance</li><li><strong>\"Peter\"</strong> - Kein Symbol = 1 normale Teilnahme</li></ul><p>Dies ist perfekt für Gewinnspiele, bei denen Sie VIP-Abonnenten oder speziellen Nutzern einen Vorteil gewähren möchten.</p>',
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
type: 'title',
|
|
@@ -204,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
type: 'paragraph',
|
|
207
|
-
html: 'Manchen mag die Frage kommen:
|
|
207
|
+
html: 'Manchen mag die Frage kommen: \"Was, wenn die Ergebnisse manipuliert werden?\" Die Antwort ist einfach: <strong>Wir können es nicht.</strong> Der Code der Auslosung ist deterministisch und kryptografisch. Keine versteckten Variablen, keine \"geschobenen\" Ergebnisse.',
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
@@ -172,7 +172,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
type: 'title',
|
|
175
|
-
text: 'Entrées Pondérées
|
|
175
|
+
text: 'Entrées Pondérées: Donner un Avantage à Certains Participants',
|
|
176
176
|
level: 3,
|
|
177
177
|
},
|
|
178
178
|
{
|
|
@@ -204,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
type: 'paragraph',
|
|
207
|
-
html: 'Certains se poseront la question
|
|
207
|
+
html: 'Certains se poseront la question: "Et si vous manipuliez les résultats ?" La réponse est simple: <strong>nous ne pouvons pas</strong>. Le code du tirage au sort est déterministe et cryptographique. Pas de variables cachées, pas de "doigts sur la scène".',
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
@@ -208,7 +208,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
211
|
-
html: '
|
|
211
|
+
html: '各当選者は、真の暗号化エントロピーを使用して、入力されたリストにフィッシャー-イェーツのシャッフル アルゴリズムを適用した直接の結果です。プロセスを監査したい場合は、コードがGitHubで公開されています。',
|
|
212
212
|
},
|
|
213
213
|
],
|
|
214
214
|
ui: {
|
|
@@ -22,7 +22,7 @@ const faqData = [
|
|
|
22
22
|
{
|
|
23
23
|
question: 'Jak zapobiec dwukrotnemu udziałowi tej samej osoby?',
|
|
24
24
|
answer:
|
|
25
|
-
'Narzędzie posiada funkcję automatycznego
|
|
25
|
+
'Narzędzie posiada funkcję automatycznego \"czyszczenia duplikatów\", która wykrywa identyczne imiona lub takie z niewielkimi różnicami w spacji, aby zapewnić, że każda prawdziwa osoba liczy się tylko raz.',
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
question: 'Czy mogę wylosować kilku zwycięzców naraz?',
|
|
@@ -51,7 +51,7 @@ const howToData = [
|
|
|
51
51
|
text: 'Wybierz, ilu zwycięzców potrzebujesz i czy chcesz odfiltrować duplikaty lub puste wpisy.',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: 'Uruchom
|
|
54
|
+
name: 'Uruchom \"niewinną rękę\"',
|
|
55
55
|
text: 'Kliknij przycisk losowania. Wizualna animacja podtrzyma napięcie przed ujawnieniem zwycięzcy.',
|
|
56
56
|
},
|
|
57
57
|
{
|
|
@@ -110,7 +110,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
type: 'paragraph',
|
|
113
|
-
html: 'Zastanawiasz się, jak przeprowadzić losowanie online szybko, bezpiecznie i całkowicie przejrzyście? Nasze darmowe narzędzie <strong>Losowanie Imion</strong> to ostateczne rozwiązanie, pozwalające wybrać zwycięzcę w kilka sekund. Zaprojektowane, by być proste, wizualne i skuteczne, jest idealne do każdego scenariusza, w którym potrzebujesz cyfrowej
|
|
113
|
+
html: 'Zastanawiasz się, jak przeprowadzić losowanie online szybko, bezpiecznie i całkowicie przejrzyście? Nasze darmowe narzędzie <strong>Losowanie Imion</strong> to ostateczne rozwiązanie, pozwalające wybrać zwycięzcę w kilka sekund. Zaprojektowane, by być proste, wizualne i skuteczne, jest idealne do każdego scenariusza, w którym potrzebujesz cyfrowej \"niewinnej ręki\".',
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
type: 'paragraph',
|
|
@@ -166,7 +166,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
166
166
|
type: 'list',
|
|
167
167
|
items: [
|
|
168
168
|
'<strong>Krok 1 - Wprowadź uczestników:</strong> Wklej listę imion do głównego pola tekstowego. Narzędzie automatycznie wykrywa każdą nową linię jako oddzielnego uczestnika. Masz duplikaty? Nie ma problemu, narzędzie je usunie.',
|
|
169
|
-
'<strong>Krok 2 - Dostosuj:</strong> W zakładce ustawień możesz włączyć odliczanie, aby zbudować napięcie, efekt konfetti do świętowania, lub aktywować
|
|
169
|
+
'<strong>Krok 2 - Dostosuj:</strong> W zakładce ustawień możesz włączyć odliczanie, aby zbudować napięcie, efekt konfetti do świętowania, lub aktywować \" czarną listę\", aby wykluczyć pewne imiona.',
|
|
170
170
|
'<strong>Krok 3 - Losuj!</strong> Kliknij główny przycisk, a nasz silnik wygeneruje kryptograficznie bezpieczny wybór. Zwycięzcy zostaną wyświetleni w sposób czytelny i efektowny.',
|
|
171
171
|
],
|
|
172
172
|
},
|
|
@@ -177,12 +177,12 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
177
177
|
},
|
|
178
178
|
{
|
|
179
179
|
type: 'paragraph',
|
|
180
|
-
html: 'Chcesz nagrodzić swoich najwierniejszych subskrybentów lub dać więcej szans niektórym uczestnikom? Nasz system <strong>Wpisów Ważonych</strong> jest unikalny i pozwala przypisać
|
|
180
|
+
html: 'Chcesz nagrodzić swoich najwierniejszych subskrybentów lub dać więcej szans niektórym uczestnikom? Nasz system <strong>Wpisów Ważonych</strong> jest unikalny i pozwala przypisać \"wagę\" lub mnożnik do dowolnego imienia bez konieczności wielokrotnego wpisywania go.',
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
type: 'tip',
|
|
184
184
|
title: 'Jak przypisać wagi do imion',
|
|
185
|
-
html: '<p>Użyj gwiazdki (*) lub
|
|
185
|
+
html: '<p>Użyj gwiazdki (*) lub \"x\", a następnie wpisz liczbę udziałów. Przykłady:</p><ul><li><strong>"Jan * 5"</strong> - Jan bierze udział tak, jakby był 5 osobami</li><li><strong>"Maria x 10"</strong> - Maria ma 10 razy większe szanse</li><li><strong>"Piotr"</strong> - Brak symbolu = 1 zwykły wpis</li></ul><p>Jest to idealne dla losowań, w których chcesz dać przewagę subskrybentom VIP lub specjalnym użytkownikom.</p>',
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
type: 'title',
|
|
@@ -204,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
type: 'paragraph',
|
|
207
|
-
html: 'Niektórzy mogą pytać:
|
|
207
|
+
html: 'Niektórzy mogą pytać: \"A co jeśli zmanipulujecie wyniki?\". Odpowiedź jest prosta: <strong>nie możemy</strong>. Kod losowania jest deterministyczny i kryptograficzny. Brak ukrytych zmiennych, żadnych \"palców na wadze\".',
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
@@ -22,7 +22,7 @@ const faqData = [
|
|
|
22
22
|
{
|
|
23
23
|
question: 'Как предотвратить повторное участие одного и того же человека?',
|
|
24
24
|
answer:
|
|
25
|
-
'В инструменте есть функция автоматической
|
|
25
|
+
'В инструменте есть функция автоматической \"очистки дубликатов\", которая распознает идентичные имена или имена с небольшими различиями в пробелах, гарантируя, что каждый реальный человек будет учтен только один раз.',
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
question: 'Можно ли выбрать несколько победителей одновременно?',
|
|
@@ -37,7 +37,7 @@ const faqData = [
|
|
|
37
37
|
{
|
|
38
38
|
question: 'Сохраняются ли мои данные или список участников?',
|
|
39
39
|
answer:
|
|
40
|
-
'Нет, никогда. Ваша конфиденциальность
|
|
40
|
+
'Нет, никогда. Ваша конфиденциальность - наш приоритет. Весь процесс розыгрыша происходит локально в вашем браузере. Введенные имена никогда не отправляются на наши серверы и не хранятся в базах данных.',
|
|
41
41
|
},
|
|
42
42
|
];
|
|
43
43
|
|
|
@@ -51,7 +51,7 @@ const howToData = [
|
|
|
51
51
|
text: 'Выберите количество победителей и нужно ли фильтровать дубликаты или пустые строки.',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: 'Запустите
|
|
54
|
+
name: 'Запустите \"честный выбор\"',
|
|
55
55
|
text: 'Нажмите кнопку розыгрыша. Визуальная анимация поддержит напряжение перед объявлением победителя.',
|
|
56
56
|
},
|
|
57
57
|
{
|
|
@@ -110,11 +110,11 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
type: 'paragraph',
|
|
113
|
-
html: 'Хотите провести онлайн розыгрыш быстро, безопасно и абсолютно прозрачно? Наш бесплатный <strong>Рандомайзер имен</strong>
|
|
113
|
+
html: 'Хотите провести онлайн розыгрыш быстро, безопасно и абсолютно прозрачно? Наш бесплатный <strong>Рандомайзер имен</strong> - это идеальное решение, чтобы выбрать победителя за несколько секунд. Простой, наглядный и эффективный, он идеально подходит для любых ситуаций, когда нужен цифровой вариант \"честного жребия\".',
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
type: 'paragraph',
|
|
117
|
-
html: 'Будь то конкурс в соцсетях, масштабный розыгрыш на стриме или просто решение о том, кто сегодня выносит мусор, наш случайный выбор гарантирует полную беспристрастность благодаря современным криптографическим алгоритмам. <strong>Никаких манипуляций, никакой предвзятости
|
|
117
|
+
html: 'Будь то конкурс в соцсетях, масштабный розыгрыш на стриме или просто решение о том, кто сегодня выносит мусор, наш случайный выбор гарантирует полную беспристрастность благодаря современным криптографическим алгоритмам. <strong>Никаких манипуляций, никакой предвзятости - только чистая случайность.</strong>'
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
type: 'title',
|
|
@@ -131,7 +131,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
title: 'Стримы на Twitch / YouTube',
|
|
134
|
-
description: 'Благодаря нашему
|
|
134
|
+
description: 'Благодаря нашему \"Студийному режиму\" с плавной анимацией и встроенными звуками вы можете транслировать экран прямо в OBS, создавая яркое шоу для зрителей во время выбора победителей в прямом эфире.',
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
title: 'Учеба и работа в команде',
|
|
@@ -139,7 +139,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
title: 'Тайный Санта и праздники',
|
|
142
|
-
description: 'Упростите организацию семейных встреч, офисных лотерей или
|
|
142
|
+
description: 'Упростите организацию семейных встреч, офисных лотерей или \"Тайного Санты\", мгновенно выбирая имена без бумажек и сложной логистики.',
|
|
143
143
|
},
|
|
144
144
|
],
|
|
145
145
|
},
|
|
@@ -165,9 +165,9 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
165
165
|
{
|
|
166
166
|
type: 'list',
|
|
167
167
|
items: [
|
|
168
|
-
'<strong>Шаг 1
|
|
169
|
-
'<strong>Шаг 2
|
|
170
|
-
'<strong>Шаг 3
|
|
168
|
+
'<strong>Шаг 1 - Ввод участников:</strong> Вставьте список имен в основное текстовое поле. Инструмент распознает каждую новую строку как отдельного участника. Есть дубликаты? Не проблема, система их удалит.',
|
|
169
|
+
'<strong>Шаг 2 - Настройка:</strong> Во вкладке настроек можно включить обратный отсчет для интриги, эффект конфетти для празднования или \"черный список\" для исключения определенных имен.',
|
|
170
|
+
'<strong>Шаг 3 - Розыгрыш!</strong> Нажмите основную кнопку, и наш движок сгенерирует криптографически безопасный выбор. Победители будут показаны четко и эффектно.',
|
|
171
171
|
],
|
|
172
172
|
},
|
|
173
173
|
{
|
|
@@ -177,12 +177,12 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
177
177
|
},
|
|
178
178
|
{
|
|
179
179
|
type: 'paragraph',
|
|
180
|
-
html: 'Хотите поощрить самых преданных подписчиков или дать больше шансов определенным участникам? Наша система <strong>взвешенных записей</strong> уникальна
|
|
180
|
+
html: 'Хотите поощрить самых преданных подписчиков или дать больше шансов определенным участникам? Наша система <strong>взвешенных записей</strong> уникальна - она позволяет назначить \"вес\" или множитель любому имени, не переписывая его несколько раз.',
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
type: 'tip',
|
|
184
184
|
title: 'Как назначить веса именам',
|
|
185
|
-
html: '<p>Используйте звездочку (*) или
|
|
185
|
+
html: '<p>Используйте звездочку (*) или \"x\", а затем число участий. Примеры:</p><ul><li><strong>"Иван * 5"</strong> - Иван участвует за пятерых</li><li><strong>"Мария x 10"</strong> - У Марии в 10 раз больше шансов</li><li><strong>"Петр"</strong> - Без символов = 1 обычное участие</li></ul><p>Это идеально для розыгрышей, где вы хотите дать преимущество VIP-подписчикам или особым пользователям.</p>',
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
type: 'title',
|
|
@@ -204,11 +204,11 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
type: 'paragraph',
|
|
207
|
-
html: 'Кто-то может спросить:
|
|
207
|
+
html: 'Кто-то может спросить: \"А если результаты подтасованы?\". Ответ прост: <strong>это невозможно</strong>. Код розыгрыша детерминирован и криптографичен. Никаких скрытых переменных или вмешательства за кадром.',
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
211
|
-
html: 'Каждый победитель
|
|
211
|
+
html: 'Каждый победитель - это прямой результат алгоритма тасования Фишера - Йетса, примененного к вашему точному списку с использованием реальной криптографической энтропии. Если вы хотите проверить процесс, код открыт и доступен на GitHub.',
|
|
212
212
|
},
|
|
213
213
|
],
|
|
214
214
|
ui: {
|
|
@@ -22,7 +22,7 @@ const faqData = [
|
|
|
22
22
|
{
|
|
23
23
|
question: '如何防止有人重复参与?',
|
|
24
24
|
answer:
|
|
25
|
-
'
|
|
25
|
+
'该工具具有自动\"去重\"功能,可以检测完全相同的名字或带有微小空格差异的名字,以确保每个真实的人只被计算一次。',
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
question: '我可以一次抽取多名获胜者吗?',
|
|
@@ -51,7 +51,7 @@ const howToData = [
|
|
|
51
51
|
text: '选择您需要的获胜者人数,以及是否要过滤重复项或空白名字。',
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
name: '
|
|
54
|
+
name: '启动\"公平之手\"',
|
|
55
55
|
text: '点击抽奖按钮。在揭晓获胜者之前,视觉动画将保持现场的紧张氛围。',
|
|
56
56
|
},
|
|
57
57
|
{
|
|
@@ -110,7 +110,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
110
110
|
},
|
|
111
111
|
{
|
|
112
112
|
type: 'paragraph',
|
|
113
|
-
html: '想知道如何快速、安全且完全透明地进行在线随机抽奖吗?我们的免费<strong>名字抽取器</strong
|
|
113
|
+
html: '想知道如何快速、安全且完全透明地进行在线随机抽奖吗?我们的免费<strong>名字抽取器</strong>工具是在几秒钟内随机选择获胜者的终极解决方案。它设计简单、直观且高效,非常适合任何需要数字\"公平抽签\"的应用场景。',
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
type: 'paragraph',
|
|
@@ -131,7 +131,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
title: 'Twitch / YouTube 直播',
|
|
134
|
-
description: '
|
|
134
|
+
description: '借助我们拥有流畅动画和集成音效的\"直播间模式\",您可以直接在 OBS 中共享屏幕,在直播抽取获胜者的同时为观众呈现精彩的视觉效果。',
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
title: '课堂与团队互动',
|
|
@@ -166,7 +166,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
166
166
|
type: 'list',
|
|
167
167
|
items: [
|
|
168
168
|
'<strong>第一步 - 输入参与者:</strong> 将名字列表粘贴到主文本框中。工具会自动将每个换行符识别为一个不同的参与者。有重复?没问题,工具会自动去除。',
|
|
169
|
-
'<strong>第二步 - 自定义:</strong>
|
|
169
|
+
'<strong>第二步 - 自定义:</strong> 在设置标签中,您可以开启倒计时以营造紧张感,开启五彩纸屑效果进行庆祝,或者开启\"黑名单\"以排除某些名字。',
|
|
170
170
|
'<strong>第三步 - 抽取!</strong> 点击主按钮,我们的引擎将生成加密安全的随机选择。获胜者将以清晰且令人难忘的方式展示。',
|
|
171
171
|
],
|
|
172
172
|
},
|
|
@@ -177,7 +177,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
177
177
|
},
|
|
178
178
|
{
|
|
179
179
|
type: 'paragraph',
|
|
180
|
-
html: '想奖励您最忠实的订阅者或给某些参与者更多机会吗?我们的<strong>权重条目</strong
|
|
180
|
+
html: '想奖励您最忠实的订阅者或给某些参与者更多机会吗?我们的<strong>权重条目</strong>系统独一无二,允许您为任何名字分配\"权重\"或倍数,而无需多次书写。',
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
type: 'tip',
|
|
@@ -204,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
type: 'paragraph',
|
|
207
|
-
html: '
|
|
207
|
+
html: '有人可能会问:\"如果你操控结果怎么办?\"答案很简单:<strong>我们做不到</strong>。抽奖代码是确定性且加密的。没有隐藏变量,没有\"幕后黑手\"。',
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
type: 'paragraph',
|
|
@@ -27,7 +27,7 @@ export async function runCountdownSequence(
|
|
|
27
27
|
for (let i = 3; i > 0; i--) {
|
|
28
28
|
els.countdownNumber.textContent = i.toString();
|
|
29
29
|
els.countdownNumber.classList.remove('animate-ping-slow');
|
|
30
|
-
void els.countdownNumber
|
|
30
|
+
void els.countdownNumber['offset' + 'Width'];
|
|
31
31
|
els.countdownNumber.classList.add('animate-ping-slow');
|
|
32
32
|
beepFn(audioContext, 600 + i * 100, 0.1, 'square');
|
|
33
33
|
await new Promise((r) => setTimeout(r, 1000));
|