@jjlmoya/utils-games-development 1.65.0 → 1.67.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.
Files changed (77) hide show
  1. package/package.json +67 -67
  2. package/scripts/postinstall.mjs +27 -27
  3. package/src/category/GamesCategorySEO.astro +19 -19
  4. package/src/category/i18n/de.ts +15 -15
  5. package/src/category/i18n/en.ts +15 -15
  6. package/src/category/i18n/es.ts +15 -15
  7. package/src/category/i18n/fr.ts +15 -15
  8. package/src/category/i18n/id.ts +10 -10
  9. package/src/category/i18n/it.ts +10 -10
  10. package/src/category/i18n/ja.ts +10 -10
  11. package/src/category/i18n/ko.ts +10 -10
  12. package/src/category/i18n/nl.ts +10 -10
  13. package/src/category/i18n/pl.ts +10 -10
  14. package/src/category/i18n/pt.ts +10 -10
  15. package/src/category/i18n/ru.ts +10 -10
  16. package/src/category/i18n/sv.ts +10 -10
  17. package/src/category/i18n/tr.ts +10 -10
  18. package/src/category/i18n/zh.ts +10 -10
  19. package/src/category/seo.astro +15 -15
  20. package/src/components/PreviewNavSidebar.astro +116 -116
  21. package/src/components/PreviewToolbar.astro +143 -143
  22. package/src/data.ts +11 -11
  23. package/src/env.d.ts +5 -5
  24. package/src/index.ts +20 -20
  25. package/src/layouts/PreviewLayout.astro +122 -122
  26. package/src/pages/[locale]/[slug].astro +165 -165
  27. package/src/pages/[locale].astro +250 -250
  28. package/src/pages/index.astro +4 -4
  29. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  30. package/src/tests/category_seo_quality.test.ts +74 -0
  31. package/src/tests/diacritics_density.test.ts +118 -118
  32. package/src/tests/faq_count.test.ts +19 -19
  33. package/src/tests/i18n_coverage.test.ts +36 -36
  34. package/src/tests/inverted_punctuation.test.ts +84 -84
  35. package/src/tests/mocks/astro_mock.js +2 -2
  36. package/src/tests/no_en_dash.test.ts +70 -70
  37. package/src/tests/no_h1_in_components.test.ts +48 -48
  38. package/src/tests/pagespeed_best_practices.test.ts +198 -198
  39. package/src/tests/qa-test-helpers.ts +32 -32
  40. package/src/tests/qa_bibliography_links.test.ts +41 -41
  41. package/src/tests/qa_claim_evidence.test.ts +69 -69
  42. package/src/tests/qa_logic_reference_coverage.test.ts +46 -46
  43. package/src/tests/qa_runtime_i18n.test.ts +100 -100
  44. package/src/tests/schemas_fulfillment.test.ts +23 -23
  45. package/src/tests/script_density.test.ts +94 -94
  46. package/src/tests/seo_length.test.ts +23 -23
  47. package/src/tests/seo_wellformed_export.test.ts +65 -0
  48. package/src/tests/slug_language_code_format.test.ts +23 -23
  49. package/src/tests/slug_uniqueness.test.ts +80 -80
  50. package/src/tests/title_quality.test.ts +56 -56
  51. package/src/tool/audioLoopPointFinder/audio-loop-point-finder.css +322 -322
  52. package/src/tool/audioLoopPointFinder/client.ts +262 -262
  53. package/src/tool/damageFormulaLab/bibliography.astro +3 -21
  54. package/src/tool/damageFormulaLab/seo.astro +7 -5
  55. package/src/tool/gameDeltaTimeFixedTimestepLab/bibliography.astro +3 -19
  56. package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -614
  57. package/src/tool/hitboxHurtboxAnimator/seo.astro +7 -5
  58. package/src/tool/saveFileEditor/component.astro +351 -351
  59. package/src/tool/saveFileEditor/game-save-file-editor.css +250 -250
  60. package/src/tool/spriteSheetPacker/app-client.ts +248 -248
  61. package/src/tool/spriteSheetPacker/bibliography.ts +12 -12
  62. package/src/tool/spriteSheetPacker/component.astro +229 -229
  63. package/src/tool/spriteSheetPacker/dropzone-client.ts +55 -55
  64. package/src/tool/spriteSheetPacker/entry.ts +28 -28
  65. package/src/tool/spriteSheetPacker/exporter.ts +116 -116
  66. package/src/tool/spriteSheetPacker/extractor-client.ts +127 -127
  67. package/src/tool/spriteSheetPacker/flipbook-client.ts +60 -60
  68. package/src/tool/spriteSheetPacker/logic.test.ts +155 -155
  69. package/src/tool/spriteSheetPacker/logic.ts +32 -32
  70. package/src/tool/spriteSheetPacker/packer-core.ts +121 -121
  71. package/src/tool/spriteSheetPacker/packer-ui.ts +86 -86
  72. package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +578 -578
  73. package/src/tool/spriteSheetPacker/types.ts +89 -89
  74. package/src/tool/spriteSheetPacker/ui.ts +42 -42
  75. package/src/tool/steamCapsuleGenerator/index.ts +9 -9
  76. package/src/tool/steamCapsuleGenerator/validation.ts +14 -14
  77. package/src/types.ts +72 -72
@@ -1,94 +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
- });
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
+ });
@@ -1,23 +1,23 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
4
- const ENTRIES = [
5
- { id: 'gamesCategory', i18n: DATA.gamesCategory.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[] }>>)[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
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'gamesCategory', i18n: DATA.gamesCategory.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[] }>>)[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,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findSeoAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findSeoAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'seo.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const seoFiles = findSeoAstroFiles(toolDir);
24
+
25
+ describe('SEO Component Wellformed Export', () => {
26
+ it('found tool seo.astro components', () => {
27
+ expect(seoFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ seoFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSeoRenderer =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('SEORenderer') || content.includes('SEOArticle'));
39
+
40
+ const acquiresDynamicContent =
41
+ content.includes('.i18n') ||
42
+ content.includes('loader') ||
43
+ content.includes('await loader') ||
44
+ content.includes('.seo');
45
+
46
+ const isBrokenPattern =
47
+ /sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
48
+
49
+ expect(
50
+ usesSeoRenderer,
51
+ `File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
52
+ ).toBe(true);
53
+
54
+ expect(
55
+ isBrokenPattern,
56
+ `File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
57
+ ).toBe(false);
58
+
59
+ expect(
60
+ acquiresDynamicContent,
61
+ `File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
62
+ ).toBe(true);
63
+ });
64
+ });
65
+ });
@@ -1,23 +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
- });
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
+ });
@@ -1,81 +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
- });
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
81
  });
@@ -1,56 +1,56 @@
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) continue;
45
- if (title.includes('|') || title.includes('-')) {
46
- findings.push(title);
47
- }
48
- }
49
- }
50
-
51
- if (findings.length > 0) {
52
- const list = findings.map((f) => ` - "${f}"`).join('\n');
53
- throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
54
- }
55
- });
56
- });
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) continue;
45
+ if (title.includes('|') || title.includes('-')) {
46
+ findings.push(title);
47
+ }
48
+ }
49
+ }
50
+
51
+ if (findings.length > 0) {
52
+ const list = findings.map((f) => ` - "${f}"`).join('\n');
53
+ throw new Error(`Forbidden separators (| or -) found in titles in ${relativePath}:\n${list}`);
54
+ }
55
+ });
56
+ });