@jjlmoya/utils-motor 1.1.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 +68 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/i18n/de.ts +17 -0
- package/src/category/i18n/en.ts +17 -0
- package/src/category/i18n/es.ts +17 -0
- package/src/category/i18n/fr.ts +17 -0
- package/src/category/i18n/id.ts +17 -0
- package/src/category/i18n/it.ts +17 -0
- package/src/category/i18n/ja.ts +17 -0
- package/src/category/i18n/ko.ts +17 -0
- package/src/category/i18n/nl.ts +17 -0
- package/src/category/i18n/pl.ts +17 -0
- package/src/category/i18n/pt.ts +17 -0
- package/src/category/i18n/ru.ts +17 -0
- package/src/category/i18n/sv.ts +17 -0
- package/src/category/i18n/tr.ts +17 -0
- package/src/category/i18n/zh.ts +17 -0
- package/src/category/index.ts +24 -0
- package/src/category/seo.astro +15 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +11 -0
- package/src/entries.ts +7 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +23 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +153 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/faq_count.test.ts +19 -0
- package/src/tests/i18n_coverage.test.ts +36 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +29 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/seo_length.test.ts +23 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/spanish_leakage.test.ts +177 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +17 -0
- package/src/tests/translation_copy.test.ts +125 -0
- package/src/tool/brakingDistanceCalculator/bibliography.astro +6 -0
- package/src/tool/brakingDistanceCalculator/bibliography.ts +12 -0
- package/src/tool/brakingDistanceCalculator/braking-distance-calculator.css +601 -0
- package/src/tool/brakingDistanceCalculator/component.astro +117 -0
- package/src/tool/brakingDistanceCalculator/controller.ts +114 -0
- package/src/tool/brakingDistanceCalculator/dom-views.ts +58 -0
- package/src/tool/brakingDistanceCalculator/entry.ts +30 -0
- package/src/tool/brakingDistanceCalculator/evaluator.ts +45 -0
- package/src/tool/brakingDistanceCalculator/i18n/de.ts +45 -0
- package/src/tool/brakingDistanceCalculator/i18n/en.ts +208 -0
- package/src/tool/brakingDistanceCalculator/i18n/es.ts +46 -0
- package/src/tool/brakingDistanceCalculator/i18n/factory.ts +114 -0
- package/src/tool/brakingDistanceCalculator/i18n/fr.ts +40 -0
- package/src/tool/brakingDistanceCalculator/i18n/id.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/it.ts +39 -0
- package/src/tool/brakingDistanceCalculator/i18n/ja.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/ko.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/nl.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/pl.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/pt.ts +39 -0
- package/src/tool/brakingDistanceCalculator/i18n/ru.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/sv.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/tr.ts +38 -0
- package/src/tool/brakingDistanceCalculator/i18n/zh.ts +38 -0
- package/src/tool/brakingDistanceCalculator/index.ts +10 -0
- package/src/tool/brakingDistanceCalculator/logic.test.ts +84 -0
- package/src/tool/brakingDistanceCalculator/logic.ts +82 -0
- package/src/tool/brakingDistanceCalculator/seo.astro +15 -0
- package/src/tool/brakingDistanceCalculator/storage.ts +28 -0
- package/src/tool/brakingDistanceCalculator/ui.ts +42 -0
- package/src/tools.ts +4 -0
- package/src/types.ts +72 -0
|
@@ -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 unknown 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
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const ENTRIES = [
|
|
5
|
+
{ id: 'motorCategory', i18n: DATA.motorCategory.i18n },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
describe('SEO Content Length Validation', () => {
|
|
9
|
+
ENTRIES.forEach((entry) => {
|
|
10
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
11
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
12
|
+
it(`${locale}: SEO section should exist`, async () => {
|
|
13
|
+
const loader = (entry.i18n as Record<string, (() => Promise<{ seo?: unknown[] }>) | undefined>)[locale];
|
|
14
|
+
if (!loader) return;
|
|
15
|
+
const content = await loader();
|
|
16
|
+
if (!content.seo) return;
|
|
17
|
+
expect(Array.isArray(content.seo)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('Slug Language Code Format Validation', () => {
|
|
6
|
+
ALL_TOOLS.forEach((tool) => {
|
|
7
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
+
it('slug should not end with 2-letter language codes like -ja, -ru, -ko', async () => {
|
|
9
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
10
|
+
|
|
11
|
+
for (const locale of locales) {
|
|
12
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
13
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
14
|
+
|
|
15
|
+
expect(
|
|
16
|
+
content.slug,
|
|
17
|
+
`Tool "${tool.entry.id}" locale "${locale}" slug ("${content.slug}") cannot end with a 2-letter language code (e.g., -ja, -ru, -ko).`,
|
|
18
|
+
).not.toMatch(/-[a-z]{2}$/);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
const sharingLocales = ['ja', 'ko', 'zh'];
|
|
6
|
+
|
|
7
|
+
interface ValidateParams {
|
|
8
|
+
toolId: string;
|
|
9
|
+
locale: string;
|
|
10
|
+
content: ToolLocaleContent;
|
|
11
|
+
enSlug: string;
|
|
12
|
+
slugs: Map<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const validateLocaleSlug = ({
|
|
16
|
+
toolId,
|
|
17
|
+
locale,
|
|
18
|
+
content,
|
|
19
|
+
enSlug,
|
|
20
|
+
slugs,
|
|
21
|
+
}: ValidateParams) => {
|
|
22
|
+
expect(
|
|
23
|
+
content.slug,
|
|
24
|
+
`Tool "${toolId}" locale "${locale}" has an invalid slug ("${content.slug}"). Slugs must be transliterated (only a-z, 0-9, and -).`,
|
|
25
|
+
).toMatch(/^[a-z0-9-]+$/);
|
|
26
|
+
|
|
27
|
+
if (locale === 'en') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (sharingLocales.includes(locale)) {
|
|
32
|
+
expect(
|
|
33
|
+
content.slug,
|
|
34
|
+
`Tool "${toolId}" locale "${locale}" must use the same slug as "en" ("${enSlug}").`,
|
|
35
|
+
).toBe(enSlug);
|
|
36
|
+
} else {
|
|
37
|
+
expect(
|
|
38
|
+
content.slug,
|
|
39
|
+
`Tool "${toolId}" locale "${locale}" has the same slug as "en" ("${enSlug}"). Cada slug tiene que estar en su propia idioma`,
|
|
40
|
+
).not.toBe(enSlug);
|
|
41
|
+
|
|
42
|
+
if (slugs.has(content.slug)) {
|
|
43
|
+
const previousLocale = slugs.get(content.slug);
|
|
44
|
+
expect(
|
|
45
|
+
false,
|
|
46
|
+
`Tool "${toolId}" locales "${locale}" and "${previousLocale}" share the same slug ("${content.slug}"). Cada slug tiene que estar en su propia idioma`,
|
|
47
|
+
).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
slugs.set(content.slug, locale);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('Slug Localization and Uniqueness Validation', () => {
|
|
54
|
+
ALL_TOOLS.forEach((tool) => {
|
|
55
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
56
|
+
it('every locale should have a unique, translated slug', async () => {
|
|
57
|
+
const slugs = new Map<string, string>();
|
|
58
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
59
|
+
|
|
60
|
+
let enSlug = '';
|
|
61
|
+
if (locales.includes('en')) {
|
|
62
|
+
const enLoader = tool.entry.i18n['en' as keyof typeof tool.entry.i18n];
|
|
63
|
+
const enContent = (await enLoader?.()) as ToolLocaleContent;
|
|
64
|
+
enSlug = enContent.slug;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const locale of locales) {
|
|
68
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
69
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
70
|
+
validateLocaleSlug({
|
|
71
|
+
toolId: tool.entry.id,
|
|
72
|
+
locale,
|
|
73
|
+
content,
|
|
74
|
+
enSlug,
|
|
75
|
+
slugs,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'bibliographyTitle',
|
|
26
|
+
'faq',
|
|
27
|
+
'howTo',
|
|
28
|
+
'seo',
|
|
29
|
+
'schemas',
|
|
30
|
+
'ui',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const COPY_THRESHOLD = 0.9;
|
|
34
|
+
|
|
35
|
+
const SPANISH_MARKERS = [
|
|
36
|
+
['sangre', /\bsangre\b/gi],
|
|
37
|
+
['molino', /\bmolino\b/gi],
|
|
38
|
+
['grano', /\bgrano\b/gi],
|
|
39
|
+
['paladar', /\bpaladar\b/gi],
|
|
40
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
41
|
+
['trozos', /\btrozos\b/gi],
|
|
42
|
+
['frescura', /\bfrescura\b/gi],
|
|
43
|
+
['ingresa', /\bingresa\b/gi],
|
|
44
|
+
['selecciona', /\bselecciona\b/gi],
|
|
45
|
+
['herramienta', /\bherramienta\b/gi],
|
|
46
|
+
['según', /\bsegún\b/gi],
|
|
47
|
+
['después', /\bdespués\b/gi],
|
|
48
|
+
['puedes', /\bpuedes\b/gi],
|
|
49
|
+
['debes', /\bdebes\b/gi],
|
|
50
|
+
['tus', /\btus\b/gi],
|
|
51
|
+
['a la vez', /\ba la vez\b/gi],
|
|
52
|
+
['los datos', /\blos datos\b/gi],
|
|
53
|
+
['las opciones', /\blas opciones\b/gi],
|
|
54
|
+
['el resultado', /\bel resultado\b/gi],
|
|
55
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
56
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
57
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
type UnknownRecord = Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
function normalize(value: string): string {
|
|
63
|
+
return value
|
|
64
|
+
.replace(/<[^>]*>/g, ' ')
|
|
65
|
+
.replace(/ /gi, ' ')
|
|
66
|
+
.replace(/\s+/g, ' ')
|
|
67
|
+
.trim()
|
|
68
|
+
.toLocaleLowerCase('es');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
72
|
+
const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
|
|
73
|
+
return [
|
|
74
|
+
'data:image/svg+xml;base64',
|
|
75
|
+
'background-image: url',
|
|
76
|
+
'.layout-playground {',
|
|
77
|
+
'const samplerate =',
|
|
78
|
+
].some((pattern) => text.includes(pattern)) ||
|
|
79
|
+
(text.includes('presets') && text.includes('hz')) ||
|
|
80
|
+
(text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
|
|
81
|
+
(text.includes('led') && ledUnitMatches.length >= 3);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectString(value: string, output: string[]): void {
|
|
85
|
+
const text = normalize(value);
|
|
86
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
90
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
91
|
+
collectText(childValue, output, childKey);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
96
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
97
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
98
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
99
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
103
|
+
if (typeof loader !== 'function') return [];
|
|
104
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
105
|
+
const output: string[] = [];
|
|
106
|
+
if (module && typeof module === 'object') {
|
|
107
|
+
const record = module as UnknownRecord;
|
|
108
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
109
|
+
collectText(record[key], output, key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return output;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
116
|
+
const corpus = text.join(' ');
|
|
117
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
118
|
+
pattern.test(corpus) ? [label] : [],
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function similarity(left: string, right: string): number {
|
|
123
|
+
const leftTokens = left.split(/\s+/);
|
|
124
|
+
const rightCounts = new Map<string, number>();
|
|
125
|
+
right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
|
|
126
|
+
const matches = leftTokens.reduce((total, token) => {
|
|
127
|
+
const count = rightCounts.get(token) ?? 0;
|
|
128
|
+
if (count > 0) rightCounts.set(token, count - 1);
|
|
129
|
+
return total + (count > 0 ? 1 : 0);
|
|
130
|
+
}, 0);
|
|
131
|
+
return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
135
|
+
return spanish
|
|
136
|
+
.filter((fragment) => fragment.length >= 80)
|
|
137
|
+
.filter((fragment) => translated.some((candidate) =>
|
|
138
|
+
candidate.length >= 80 &&
|
|
139
|
+
Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
|
|
140
|
+
similarity(fragment, candidate) >= COPY_THRESHOLD,
|
|
141
|
+
))
|
|
142
|
+
.sort((a, b) => b.length - a.length)
|
|
143
|
+
.slice(0, 3);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
147
|
+
for (const entry of ALL_ENTRIES) {
|
|
148
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
149
|
+
const spanish = await loadText(entry.i18n.es);
|
|
150
|
+
const failures: string[] = [];
|
|
151
|
+
|
|
152
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
153
|
+
if (locale === 'es') continue;
|
|
154
|
+
|
|
155
|
+
const translated = await loadText(loader);
|
|
156
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
157
|
+
const markerHits = findSpanishMarkers(translated);
|
|
158
|
+
|
|
159
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
160
|
+
const details = [
|
|
161
|
+
copiedFragments.length > 0
|
|
162
|
+
? `copied fragments: ${copiedFragments
|
|
163
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
164
|
+
.join(', ')}`
|
|
165
|
+
: '',
|
|
166
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
167
|
+
]
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.join('; ');
|
|
170
|
+
failures.push(`${locale}: ${details}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string, ext: string[]): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) return results;
|
|
8
|
+
const list = fs.readdirSync(dir);
|
|
9
|
+
for (const file of list) {
|
|
10
|
+
const fullPath = path.join(dir, file);
|
|
11
|
+
const stat = fs.statSync(fullPath);
|
|
12
|
+
if (stat && stat.isDirectory()) {
|
|
13
|
+
results.push(...getFiles(fullPath, ext));
|
|
14
|
+
} else if (ext.some((e) => file.endsWith(e))) {
|
|
15
|
+
results.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SRC_DIR = path.join(process.cwd(), 'src');
|
|
22
|
+
|
|
23
|
+
describe('Project Titles - Separator Validation', () => {
|
|
24
|
+
const files = [
|
|
25
|
+
...getFiles(path.join(SRC_DIR, 'tool'), ['.ts']),
|
|
26
|
+
...getFiles(path.join(SRC_DIR, 'category'), ['.ts']),
|
|
27
|
+
].filter(f => f.includes('i18n'));
|
|
28
|
+
|
|
29
|
+
it.each(files)('Verify that titles in %s do not contain | or -', (filePath) => {
|
|
30
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
32
|
+
|
|
33
|
+
const titlePatterns = [
|
|
34
|
+
/const\s+title\s*=\s*['"]([^'"]+)['"]/g,
|
|
35
|
+
/title\s*:\s*['"]([^'"]+)['"]/g,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const findings: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const pattern of titlePatterns) {
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
43
|
+
const title = match[1] ?? '';
|
|
44
|
+
if (title.includes('|') || title.includes('-')) {
|
|
45
|
+
findings.push(title);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (findings.length > 0) {
|
|
51
|
+
const list = findings.map((f) => ` - "${f}"`).join('\n');
|
|
52
|
+
throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import { motorCategory } from '../data';
|
|
4
|
+
|
|
5
|
+
describe('Tool Validation Suite', () => {
|
|
6
|
+
describe('Library Registration', () => {
|
|
7
|
+
it('should have tools registered in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('motorCategory should be defined', () => {
|
|
12
|
+
expect(motorCategory).toBeDefined();
|
|
13
|
+
expect(motorCategory.i18n).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|