@jjlmoya/utils-streaming 1.11.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/pages/[locale]/[slug].astro +24 -15
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +3 -19
- 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/tests/seo_length.test.ts +1 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/title_quality.test.ts +1 -1
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tool/sorteo/bibliography.astro +2 -10
- package/src/tool/sorteo/bibliography.ts +12 -0
- package/src/tool/sorteo/i18n/de.ts +12 -24
- package/src/tool/sorteo/i18n/en.ts +2 -14
- package/src/tool/sorteo/i18n/es.ts +2 -14
- package/src/tool/sorteo/i18n/fr.ts +4 -16
- package/src/tool/sorteo/i18n/id.ts +2 -14
- package/src/tool/sorteo/i18n/it.ts +2 -14
- package/src/tool/sorteo/i18n/ja.ts +3 -15
- package/src/tool/sorteo/i18n/ko.ts +2 -14
- package/src/tool/sorteo/i18n/nl.ts +2 -14
- package/src/tool/sorteo/i18n/pl.ts +9 -21
- package/src/tool/sorteo/i18n/pt.ts +2 -14
- package/src/tool/sorteo/i18n/ru.ts +16 -28
- package/src/tool/sorteo/i18n/sv.ts +2 -14
- package/src/tool/sorteo/i18n/tr.ts +2 -14
- package/src/tool/sorteo/i18n/zh.ts +9 -21
- package/src/tool/sorteo/index.ts +3 -0
- package/src/tool/sorteo/seo.astro +2 -1
- package/src/tool/sorteo/ui-manager.ts +1 -1
- package/src/tool/sorteo/ui.ts +0 -2
- package/src/tool/tebasCheck/bibliography.astro +2 -10
- package/src/tool/tebasCheck/bibliography.ts +12 -0
- package/src/tool/tebasCheck/i18n/de.ts +13 -25
- package/src/tool/tebasCheck/i18n/en.ts +2 -14
- package/src/tool/tebasCheck/i18n/es.ts +3 -15
- package/src/tool/tebasCheck/i18n/fr.ts +10 -22
- package/src/tool/tebasCheck/i18n/id.ts +2 -14
- package/src/tool/tebasCheck/i18n/it.ts +2 -14
- package/src/tool/tebasCheck/i18n/ja.ts +2 -14
- package/src/tool/tebasCheck/i18n/ko.ts +2 -14
- package/src/tool/tebasCheck/i18n/nl.ts +2 -14
- package/src/tool/tebasCheck/i18n/pl.ts +2 -14
- package/src/tool/tebasCheck/i18n/pt.ts +2 -14
- package/src/tool/tebasCheck/i18n/ru.ts +6 -18
- package/src/tool/tebasCheck/i18n/sv.ts +2 -14
- package/src/tool/tebasCheck/i18n/tr.ts +2 -14
- package/src/tool/tebasCheck/i18n/zh.ts +12 -24
- package/src/tool/tebasCheck/index.ts +3 -0
- package/src/tool/tebasCheck/seo.astro +2 -1
- package/src/tool/tebasCheck/types.ts +0 -2
- package/src/tool/tebasCheck/ui-manager.ts +1 -1
- package/src/types.ts +2 -3
|
@@ -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
|
+
});
|
|
@@ -11,6 +11,7 @@ describe('SEO Content Length Validation', () => {
|
|
|
11
11
|
Object.keys(entry.i18n).forEach((locale) => {
|
|
12
12
|
it(`${locale}: SEO section should exist`, async () => {
|
|
13
13
|
const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
|
|
14
|
+
if (!loader) return;
|
|
14
15
|
const content = await loader();
|
|
15
16
|
if (!content.seo) return;
|
|
16
17
|
expect(Array.isArray(content.seo)).toBe(true);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface ToolExportValidationResult {
|
|
4
|
+
passed: boolean;
|
|
5
|
+
failures: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function validateComponentType(
|
|
9
|
+
toolId: string,
|
|
10
|
+
componentName: string,
|
|
11
|
+
component: unknown,
|
|
12
|
+
failures: string[],
|
|
13
|
+
): void {
|
|
14
|
+
if (typeof component !== 'function') {
|
|
15
|
+
failures.push(`${toolId}: ${componentName} is not a function (${typeof component})`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function validateComponentExecution(
|
|
20
|
+
toolId: string,
|
|
21
|
+
componentName: string,
|
|
22
|
+
fn: () => Promise<unknown>,
|
|
23
|
+
failures: string[],
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
const result = await fn();
|
|
27
|
+
if (!result || typeof result !== 'object') {
|
|
28
|
+
failures.push(`${toolId}: ${componentName} import returned invalid result`);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failures.push(`${toolId}: ${componentName} execution error - ${error instanceof Error ? error.message : 'unknown'}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function validateToolExports(tools: ToolDefinition[]): Promise<ToolExportValidationResult> {
|
|
36
|
+
const failures: string[] = [];
|
|
37
|
+
|
|
38
|
+
for (const tool of tools) {
|
|
39
|
+
validateComponentType(tool.entry.id, 'Component', tool.Component, failures);
|
|
40
|
+
validateComponentType(tool.entry.id, 'SEOComponent', tool.SEOComponent, failures);
|
|
41
|
+
validateComponentType(tool.entry.id, 'BibliographyComponent', tool.BibliographyComponent, failures);
|
|
42
|
+
|
|
43
|
+
const componentFn = tool.Component as () => Promise<unknown>;
|
|
44
|
+
const seoFn = tool.SEOComponent as () => Promise<unknown>;
|
|
45
|
+
const bibFn = tool.BibliographyComponent as () => Promise<unknown>;
|
|
46
|
+
|
|
47
|
+
await validateComponentExecution(tool.entry.id, 'Component', componentFn, failures);
|
|
48
|
+
await validateComponentExecution(tool.entry.id, 'SEOComponent', seoFn, failures);
|
|
49
|
+
await validateComponentExecution(tool.entry.id, 'BibliographyComponent', bibFn, failures);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
passed: failures.length === 0,
|
|
54
|
+
failures,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -41,7 +41,7 @@ describe('Project Titles - Separator Validation', () => {
|
|
|
41
41
|
let match;
|
|
42
42
|
while ((match = pattern.exec(content)) !== null) {
|
|
43
43
|
const title = match[1];
|
|
44
|
-
if (title.includes('|') || title.includes('-')) {
|
|
44
|
+
if (title && (title.includes('|') || title.includes('-'))) {
|
|
45
45
|
findings.push(title);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import { validateToolExports } from './shared-test-helpers';
|
|
4
|
+
|
|
5
|
+
describe('Tool Exports Pattern Validation', () => {
|
|
6
|
+
describe('Component Exports Format', () => {
|
|
7
|
+
ALL_TOOLS.forEach((tool) => {
|
|
8
|
+
it(`${tool.entry.id}: Component should be a lazy-loaded function`, () => {
|
|
9
|
+
expect(typeof tool.Component).toBe('function');
|
|
10
|
+
expect(tool.Component).toBeInstanceOf(Function);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it(`${tool.entry.id}: SEOComponent should be a lazy-loaded function`, () => {
|
|
14
|
+
expect(typeof tool.SEOComponent).toBe('function');
|
|
15
|
+
expect(tool.SEOComponent).toBeInstanceOf(Function);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it(`${tool.entry.id}: BibliographyComponent should be a lazy-loaded function`, () => {
|
|
19
|
+
expect(typeof tool.BibliographyComponent).toBe('function');
|
|
20
|
+
expect(tool.BibliographyComponent).toBeInstanceOf(Function);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Dynamic Import Validation', () => {
|
|
26
|
+
it('all tools must have functional dynamic imports', async () => {
|
|
27
|
+
const result = await validateToolExports(ALL_TOOLS);
|
|
28
|
+
if (!result.passed) {
|
|
29
|
+
throw new Error(`Tool export validation failed:\n${result.failures.join('\n')}`);
|
|
30
|
+
}
|
|
31
|
+
expect(result.passed).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
-
import {
|
|
4
|
-
import type { KnownLocale } from '../../types';
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
locale?: KnownLocale;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const { locale = 'es' } = Astro.props;
|
|
11
|
-
const content = await sorteo.i18n[locale]?.();
|
|
3
|
+
import { bibliography } from './bibliography';
|
|
12
4
|
---
|
|
13
5
|
|
|
14
|
-
|
|
6
|
+
<SharedBibliography links={bibliography} />
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Web Crypto API: getRandomValues()',
|
|
6
|
+
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Fisher-Yates Shuffle Algorithm',
|
|
10
|
+
url: 'https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
4
|
import type { SorteoUI } from '../ui';
|
|
@@ -11,7 +12,7 @@ const faqData = [
|
|
|
11
12
|
{
|
|
12
13
|
question: 'Ist dieses Gewinnspiel wirklich zufällig?',
|
|
13
14
|
answer:
|
|
14
|
-
'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.',
|
|
15
16
|
},
|
|
16
17
|
{
|
|
17
18
|
question: 'Kann ich diesen Generator auf Twitch oder YouTube verwenden?',
|
|
@@ -31,7 +32,7 @@ const faqData = [
|
|
|
31
32
|
{
|
|
32
33
|
question: 'Wie viele Namen kann ich zur Liste hinzufügen?',
|
|
33
34
|
answer:
|
|
34
|
-
'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.',
|
|
35
36
|
},
|
|
36
37
|
{
|
|
37
38
|
question: 'Werden meine Daten oder die Teilnehmerliste gespeichert?',
|
|
@@ -50,7 +51,7 @@ const howToData = [
|
|
|
50
51
|
text: 'Wählen Sie aus, wie viele Gewinner Sie benötigen und ob Sie Duplikate oder leere Zeilen filtern möchten.',
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
|
-
name: 'Die
|
|
54
|
+
name: 'Die \"Glücksfee\" starten',
|
|
54
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.',
|
|
55
56
|
},
|
|
56
57
|
{
|
|
@@ -97,19 +98,8 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
97
98
|
slug,
|
|
98
99
|
title,
|
|
99
100
|
description,
|
|
100
|
-
faqTitle: 'Häufig gestellte Fragen',
|
|
101
101
|
faq: faqData,
|
|
102
|
-
|
|
103
|
-
bibliography: [
|
|
104
|
-
{
|
|
105
|
-
name: 'Web Crypto API: getRandomValues()',
|
|
106
|
-
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: 'Fisher-Yates Shuffle Algorithmus',
|
|
110
|
-
url: 'https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle',
|
|
111
|
-
},
|
|
112
|
-
],
|
|
102
|
+
bibliography,
|
|
113
103
|
howTo: howToData,
|
|
114
104
|
schemas: [faqSchema as any, howToSchema as any, appSchema as any],
|
|
115
105
|
seo: [
|
|
@@ -120,7 +110,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
120
110
|
},
|
|
121
111
|
{
|
|
122
112
|
type: 'paragraph',
|
|
123
|
-
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.',
|
|
124
114
|
},
|
|
125
115
|
{
|
|
126
116
|
type: 'paragraph',
|
|
@@ -175,9 +165,9 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
175
165
|
{
|
|
176
166
|
type: 'list',
|
|
177
167
|
items: [
|
|
178
|
-
'<strong>Schritt 1
|
|
179
|
-
'<strong>Schritt 2
|
|
180
|
-
'<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.',
|
|
181
171
|
],
|
|
182
172
|
},
|
|
183
173
|
{
|
|
@@ -187,12 +177,12 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
187
177
|
},
|
|
188
178
|
{
|
|
189
179
|
type: 'paragraph',
|
|
190
|
-
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.',
|
|
191
181
|
},
|
|
192
182
|
{
|
|
193
183
|
type: 'tip',
|
|
194
184
|
title: 'So weisen Sie Gewichte zu',
|
|
195
|
-
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>',
|
|
196
186
|
},
|
|
197
187
|
{
|
|
198
188
|
type: 'title',
|
|
@@ -214,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
214
204
|
},
|
|
215
205
|
{
|
|
216
206
|
type: 'paragraph',
|
|
217
|
-
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.',
|
|
218
208
|
},
|
|
219
209
|
{
|
|
220
210
|
type: 'paragraph',
|
|
@@ -222,8 +212,6 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
222
212
|
},
|
|
223
213
|
],
|
|
224
214
|
ui: {
|
|
225
|
-
faqTitle: 'Häufig gestellte Fragen',
|
|
226
|
-
bibliographyTitle: 'Technische Referenzen',
|
|
227
215
|
title: 'Zufalls Gewinnspiel',
|
|
228
216
|
totalParticipants: 'Teilnehmer (einmalig)',
|
|
229
217
|
ready: 'BEREIT',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
4
|
import type { SorteoUI } from '../ui';
|
|
@@ -97,19 +98,8 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
97
98
|
slug,
|
|
98
99
|
title,
|
|
99
100
|
description,
|
|
100
|
-
faqTitle: 'Frequently Asked Questions',
|
|
101
101
|
faq: faqData,
|
|
102
|
-
|
|
103
|
-
bibliography: [
|
|
104
|
-
{
|
|
105
|
-
name: 'Web Crypto API: getRandomValues()',
|
|
106
|
-
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: 'Fisher-Yates Shuffle Algorithm',
|
|
110
|
-
url: 'https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle',
|
|
111
|
-
},
|
|
112
|
-
],
|
|
102
|
+
bibliography,
|
|
113
103
|
howTo: howToData,
|
|
114
104
|
schemas: [faqSchema as any, howToSchema as any, appSchema as any],
|
|
115
105
|
seo: [
|
|
@@ -222,8 +212,6 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
222
212
|
},
|
|
223
213
|
],
|
|
224
214
|
ui: {
|
|
225
|
-
faqTitle: 'Frequently Asked Questions',
|
|
226
|
-
bibliographyTitle: 'Technical References',
|
|
227
215
|
title: 'Random Giveaway',
|
|
228
216
|
totalParticipants: 'Total Unique Participants',
|
|
229
217
|
ready: 'READY',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
4
|
import type { SorteoUI } from '../ui';
|
|
@@ -97,19 +98,8 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
97
98
|
slug,
|
|
98
99
|
title,
|
|
99
100
|
description,
|
|
100
|
-
faqTitle: 'Preguntas Frecuentes',
|
|
101
101
|
faq: faqData,
|
|
102
|
-
|
|
103
|
-
bibliography: [
|
|
104
|
-
{
|
|
105
|
-
name: 'Web Crypto API: getRandomValues()',
|
|
106
|
-
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: 'Fisher-Yates Shuffle Algorithm',
|
|
110
|
-
url: 'https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle',
|
|
111
|
-
},
|
|
112
|
-
],
|
|
102
|
+
bibliography,
|
|
113
103
|
howTo: howToData,
|
|
114
104
|
schemas: [faqSchema as any, howToSchema as any, appSchema as any],
|
|
115
105
|
seo: [
|
|
@@ -222,8 +212,6 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
222
212
|
},
|
|
223
213
|
],
|
|
224
214
|
ui: {
|
|
225
|
-
faqTitle: 'Preguntas Frecuentes',
|
|
226
|
-
bibliographyTitle: 'Referencias Técnicas',
|
|
227
215
|
title: 'Sorteo Aleatorio',
|
|
228
216
|
totalParticipants: 'Total Participantes Únicos',
|
|
229
217
|
ready: 'LISTO',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
4
|
import type { SorteoUI } from '../ui';
|
|
@@ -97,19 +98,8 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
97
98
|
slug,
|
|
98
99
|
title,
|
|
99
100
|
description,
|
|
100
|
-
faqTitle: 'Questions Fréquemment Posées',
|
|
101
101
|
faq: faqData,
|
|
102
|
-
|
|
103
|
-
bibliography: [
|
|
104
|
-
{
|
|
105
|
-
name: 'Web Crypto API: getRandomValues()',
|
|
106
|
-
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues',
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: 'Fisher-Yates Shuffle Algorithm',
|
|
110
|
-
url: 'https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle',
|
|
111
|
-
},
|
|
112
|
-
],
|
|
102
|
+
bibliography,
|
|
113
103
|
howTo: howToData,
|
|
114
104
|
schemas: [faqSchema as any, howToSchema as any, appSchema as any],
|
|
115
105
|
seo: [
|
|
@@ -182,7 +172,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
182
172
|
},
|
|
183
173
|
{
|
|
184
174
|
type: 'title',
|
|
185
|
-
text: 'Entrées Pondérées
|
|
175
|
+
text: 'Entrées Pondérées: Donner un Avantage à Certains Participants',
|
|
186
176
|
level: 3,
|
|
187
177
|
},
|
|
188
178
|
{
|
|
@@ -214,7 +204,7 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
214
204
|
},
|
|
215
205
|
{
|
|
216
206
|
type: 'paragraph',
|
|
217
|
-
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".',
|
|
218
208
|
},
|
|
219
209
|
{
|
|
220
210
|
type: 'paragraph',
|
|
@@ -222,8 +212,6 @@ export const content: ToolLocaleContent<SorteoUI> = {
|
|
|
222
212
|
},
|
|
223
213
|
],
|
|
224
214
|
ui: {
|
|
225
|
-
faqTitle: 'Questions Fréquemment Posées',
|
|
226
|
-
bibliographyTitle: 'Références Techniques',
|
|
227
215
|
title: 'Tirage au Sort Aléatoire',
|
|
228
216
|
totalParticipants: 'Total Participants Uniques',
|
|
229
217
|
ready: 'PRÊT',
|