@jjlmoya/utils-developer 1.30.0 → 1.32.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 (187) hide show
  1. package/package.json +70 -70
  2. package/src/category/i18n/en.ts +1 -1
  3. package/src/category/i18n/es.ts +1 -1
  4. package/src/category/index.ts +2 -1
  5. package/src/category/seo.astro +1 -1
  6. package/src/components/PreviewNavSidebar.astro +1 -1
  7. package/src/components/PreviewToolbar.astro +1 -1
  8. package/src/data.ts +1 -1
  9. package/src/entries.ts +2 -0
  10. package/src/env.d.ts +1 -1
  11. package/src/index.ts +1 -0
  12. package/src/layouts/PreviewLayout.astro +1 -1
  13. package/src/pages/[locale]/[slug].astro +161 -161
  14. package/src/pages/index.astro +1 -1
  15. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  16. package/src/tests/category_seo_quality.test.ts +76 -0
  17. package/src/tests/entry_contract.test.ts +8 -2
  18. package/src/tests/faq_count.test.ts +1 -1
  19. package/src/tests/i18n_coverage.test.ts +2 -6
  20. package/src/tests/inverted_punctuation.test.ts +113 -113
  21. package/src/tests/locale_completeness.test.ts +1 -1
  22. package/src/tests/mocks/astro_mock.js +1 -1
  23. package/src/tests/no_h1_in_components.test.ts +1 -1
  24. package/src/tests/promote_this_website_i18n.test.ts +33 -0
  25. package/src/tests/seo_translation_completeness.test.ts +69 -0
  26. package/src/tests/seo_wellformed_export.test.ts +65 -0
  27. package/src/tests/slug_language_code_format.test.ts +25 -25
  28. package/src/tests/slug_uniqueness.test.ts +86 -86
  29. package/src/tests/tool_validation.test.ts +16 -16
  30. package/src/tool/aspectRatio/i18n/de.ts +164 -164
  31. package/src/tool/aspectRatio/i18n/ja.ts +1 -1
  32. package/src/tool/aspectRatio/i18n/ko.ts +1 -1
  33. package/src/tool/aspectRatio/i18n/pl.ts +164 -164
  34. package/src/tool/aspectRatio/i18n/ru.ts +164 -164
  35. package/src/tool/aspectRatio/i18n/zh.ts +165 -165
  36. package/src/tool/aspectRatio/seo.astro +15 -15
  37. package/src/tool/calculadoraTiempoDatos/i18n/de.ts +143 -143
  38. package/src/tool/calculadoraTiempoDatos/i18n/ja.ts +1 -1
  39. package/src/tool/calculadoraTiempoDatos/i18n/ko.ts +1 -1
  40. package/src/tool/calculadoraTiempoDatos/i18n/pl.ts +143 -143
  41. package/src/tool/calculadoraTiempoDatos/i18n/ru.ts +144 -144
  42. package/src/tool/calculadoraTiempoDatos/i18n/sv.ts +143 -143
  43. package/src/tool/calculadoraTiempoDatos/i18n/zh.ts +144 -144
  44. package/src/tool/calculadoraTiempoDatos/seo.astro +15 -15
  45. package/src/tool/colorConverter/i18n/de.ts +120 -120
  46. package/src/tool/colorConverter/i18n/ja.ts +1 -1
  47. package/src/tool/colorConverter/i18n/ko.ts +1 -1
  48. package/src/tool/colorConverter/i18n/ru.ts +120 -120
  49. package/src/tool/colorConverter/i18n/zh.ts +1 -1
  50. package/src/tool/colorConverter/seo.astro +15 -15
  51. package/src/tool/conversorExcelCsvHtml/i18n/ja.ts +1 -1
  52. package/src/tool/conversorExcelCsvHtml/i18n/ko.ts +1 -1
  53. package/src/tool/conversorExcelCsvHtml/i18n/pl.ts +124 -124
  54. package/src/tool/conversorExcelCsvHtml/i18n/ru.ts +124 -124
  55. package/src/tool/conversorExcelCsvHtml/i18n/zh.ts +125 -125
  56. package/src/tool/conversorExcelCsvHtml/seo.astro +15 -15
  57. package/src/tool/cronGenerator/i18n/ja.ts +1 -1
  58. package/src/tool/cronGenerator/i18n/ko.ts +1 -1
  59. package/src/tool/cronGenerator/i18n/zh.ts +183 -183
  60. package/src/tool/cronGenerator/seo.astro +15 -15
  61. package/src/tool/cssSpecificityCalculator/i18n/ja.ts +1 -1
  62. package/src/tool/cssSpecificityCalculator/i18n/ko.ts +1 -1
  63. package/src/tool/cssSpecificityCalculator/i18n/zh.ts +1 -1
  64. package/src/tool/cssSpecificityCalculator/seo.astro +15 -15
  65. package/src/tool/cssToInlineConverter/i18n/ja.ts +1 -1
  66. package/src/tool/cssToInlineConverter/i18n/ko.ts +1 -1
  67. package/src/tool/cssToInlineConverter/i18n/zh.ts +1 -1
  68. package/src/tool/cssToInlineConverter/seo.astro +15 -15
  69. package/src/tool/dualOsIconPreview/assets/day-check.webp +0 -0
  70. package/src/tool/dualOsIconPreview/assets/fast-task.webp +0 -0
  71. package/src/tool/dualOsIconPreview/assets/fortune-cookie.webp +0 -0
  72. package/src/tool/dualOsIconPreview/assets/lexi-crash.webp +0 -0
  73. package/src/tool/dualOsIconPreview/assets/pizzametrics.webp +0 -0
  74. package/src/tool/dualOsIconPreview/assets/sauce-lab.webp +0 -0
  75. package/src/tool/dualOsIconPreview/assets/vesp.webp +0 -0
  76. package/src/tool/dualOsIconPreview/bibliography.astro +6 -0
  77. package/src/tool/dualOsIconPreview/bibliography.ts +7 -0
  78. package/src/tool/dualOsIconPreview/component.astro +117 -0
  79. package/src/tool/dualOsIconPreview/controller.ts +157 -0
  80. package/src/tool/dualOsIconPreview/dom-views.ts +82 -0
  81. package/src/tool/dualOsIconPreview/dual-ios-android-app-icon-preview.css +861 -0
  82. package/src/tool/dualOsIconPreview/entry.ts +31 -0
  83. package/src/tool/dualOsIconPreview/evaluator.ts +20 -0
  84. package/src/tool/dualOsIconPreview/i18n/de.ts +56 -0
  85. package/src/tool/dualOsIconPreview/i18n/en.ts +46 -0
  86. package/src/tool/dualOsIconPreview/i18n/es.ts +56 -0
  87. package/src/tool/dualOsIconPreview/i18n/fr.ts +56 -0
  88. package/src/tool/dualOsIconPreview/i18n/id.ts +56 -0
  89. package/src/tool/dualOsIconPreview/i18n/it.ts +56 -0
  90. package/src/tool/dualOsIconPreview/i18n/ja.ts +56 -0
  91. package/src/tool/dualOsIconPreview/i18n/ko.ts +56 -0
  92. package/src/tool/dualOsIconPreview/i18n/nl.ts +56 -0
  93. package/src/tool/dualOsIconPreview/i18n/pl.ts +56 -0
  94. package/src/tool/dualOsIconPreview/i18n/pt.ts +56 -0
  95. package/src/tool/dualOsIconPreview/i18n/ru.ts +56 -0
  96. package/src/tool/dualOsIconPreview/i18n/sv.ts +56 -0
  97. package/src/tool/dualOsIconPreview/i18n/tr.ts +43 -0
  98. package/src/tool/dualOsIconPreview/i18n/zh.ts +43 -0
  99. package/src/tool/dualOsIconPreview/index.ts +11 -0
  100. package/src/tool/dualOsIconPreview/logic.test.ts +63 -0
  101. package/src/tool/dualOsIconPreview/logic.ts +135 -0
  102. package/src/tool/dualOsIconPreview/seo.astro +15 -0
  103. package/src/tool/dualOsIconPreview/storage.ts +18 -0
  104. package/src/tool/dualOsIconPreview/ui.ts +52 -0
  105. package/src/tool/duplicateCssRemover/i18n/ja.ts +1 -1
  106. package/src/tool/duplicateCssRemover/i18n/ko.ts +1 -1
  107. package/src/tool/duplicateCssRemover/i18n/zh.ts +1 -1
  108. package/src/tool/duplicateCssRemover/seo.astro +15 -15
  109. package/src/tool/generadorSecurityTxt/i18n/ja.ts +1 -1
  110. package/src/tool/generadorSecurityTxt/i18n/ko.ts +1 -1
  111. package/src/tool/generadorSecurityTxt/i18n/zh.ts +1 -1
  112. package/src/tool/generadorSecurityTxt/seo.astro +15 -15
  113. package/src/tool/hashGenerator/i18n/ja.ts +1 -1
  114. package/src/tool/hashGenerator/i18n/ko.ts +1 -1
  115. package/src/tool/hashGenerator/seo.astro +15 -15
  116. package/src/tool/inspectorCertificadosSsl/i18n/ja.ts +1 -1
  117. package/src/tool/inspectorCertificadosSsl/i18n/ko.ts +1 -1
  118. package/src/tool/inspectorCertificadosSsl/i18n/zh.ts +1 -1
  119. package/src/tool/inspectorCertificadosSsl/seo.astro +15 -15
  120. package/src/tool/jsonFormatter/seo.astro +15 -15
  121. package/src/tool/jwtDecoder/i18n/es.ts +248 -248
  122. package/src/tool/jwtDecoder/i18n/fr.ts +248 -248
  123. package/src/tool/jwtDecoder/i18n/tr.ts +248 -248
  124. package/src/tool/keycode/seo.astro +15 -15
  125. package/src/tool/llmCostCalculator/i18n/fr.ts +149 -149
  126. package/src/tool/llmCostCalculator/i18n/ja.ts +1 -1
  127. package/src/tool/llmCostCalculator/i18n/ko.ts +1 -1
  128. package/src/tool/llmCostCalculator/seo.astro +15 -15
  129. package/src/tool/mobileMockupGenerator/i18n/ja.ts +1 -1
  130. package/src/tool/mobileMockupGenerator/i18n/ko.ts +1 -1
  131. package/src/tool/mobileMockupGenerator/i18n/zh.ts +1 -1
  132. package/src/tool/mobileMockupGenerator/seo.astro +15 -15
  133. package/src/tool/musicalTypography/i18n/ja.ts +1 -1
  134. package/src/tool/musicalTypography/i18n/ko.ts +1 -1
  135. package/src/tool/musicalTypography/i18n/zh.ts +1 -1
  136. package/src/tool/musicalTypography/seo.astro +15 -15
  137. package/src/tool/placeholderGenerator/i18n/ja.ts +1 -1
  138. package/src/tool/placeholderGenerator/i18n/ko.ts +1 -1
  139. package/src/tool/placeholderGenerator/i18n/zh.ts +1 -1
  140. package/src/tool/placeholderGenerator/seo.astro +15 -15
  141. package/src/tool/promoteThisWebsite/entry.ts +13 -0
  142. package/src/tool/promoteThisWebsite/i18n/de.ts +95 -0
  143. package/src/tool/promoteThisWebsite/i18n/fr.ts +95 -0
  144. package/src/tool/promoteThisWebsite/i18n/id.ts +95 -0
  145. package/src/tool/promoteThisWebsite/i18n/it.ts +95 -0
  146. package/src/tool/promoteThisWebsite/i18n/ja.ts +95 -0
  147. package/src/tool/promoteThisWebsite/i18n/ko.ts +95 -0
  148. package/src/tool/promoteThisWebsite/i18n/nl.ts +95 -0
  149. package/src/tool/promoteThisWebsite/i18n/pl.ts +95 -0
  150. package/src/tool/promoteThisWebsite/i18n/pt.ts +95 -0
  151. package/src/tool/promoteThisWebsite/i18n/ru.ts +95 -0
  152. package/src/tool/promoteThisWebsite/i18n/sv.ts +95 -0
  153. package/src/tool/promoteThisWebsite/i18n/tr.ts +95 -0
  154. package/src/tool/promoteThisWebsite/i18n/zh.ts +95 -0
  155. package/src/tool/promptLibrary/i18n/ja.ts +1 -1
  156. package/src/tool/promptLibrary/i18n/ko.ts +1 -1
  157. package/src/tool/promptLibrary/i18n/zh.ts +1 -1
  158. package/src/tool/promptLibrary/seo.astro +15 -15
  159. package/src/tool/readabilityCalculator/i18n/ja.ts +1 -1
  160. package/src/tool/readabilityCalculator/i18n/ko.ts +1 -1
  161. package/src/tool/readabilityCalculator/i18n/zh.ts +1 -1
  162. package/src/tool/readabilityCalculator/seo.astro +15 -15
  163. package/src/tool/serpPixelSimulator/entry.ts +30 -30
  164. package/src/tool/serpPixelSimulator/i18n/en.ts +211 -211
  165. package/src/tool/serpPixelSimulator/i18n/fr.ts +214 -214
  166. package/src/tool/serpPixelSimulator/i18n/ru.ts +214 -214
  167. package/src/tool/serpPixelSimulator/logic.ts +18 -18
  168. package/src/tool/serpPixelSimulator/serp-pixel-simulator.css +195 -195
  169. package/src/tool/svgSanitizer/i18n/ja.ts +1 -1
  170. package/src/tool/svgSanitizer/i18n/ko.ts +1 -1
  171. package/src/tool/svgSanitizer/seo.astro +15 -15
  172. package/src/tool/svgToCss/i18n/ja.ts +1 -1
  173. package/src/tool/svgToCss/i18n/ko.ts +1 -1
  174. package/src/tool/svgToCss/seo.astro +15 -15
  175. package/src/tool/urlCleaner/i18n/ja.ts +1 -1
  176. package/src/tool/urlCleaner/i18n/ko.ts +1 -1
  177. package/src/tool/urlCleaner/i18n/zh.ts +1 -1
  178. package/src/tool/urlCleaner/seo.astro +15 -15
  179. package/src/tool/urlEncoderDecoder/i18n/es.ts +146 -146
  180. package/src/tool/urlEncoderDecoder/i18n/ko.ts +1 -1
  181. package/src/tool/urlEncoderDecoder/seo.astro +15 -15
  182. package/src/tool/utmGenerator/i18n/ja.ts +1 -1
  183. package/src/tool/utmGenerator/i18n/ko.ts +1 -1
  184. package/src/tool/utmGenerator/i18n/zh.ts +1 -1
  185. package/src/tool/utmGenerator/seo.astro +15 -15
  186. package/src/tools.ts +2 -1
  187. package/src/types.ts +1 -1
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CategoryLocaleContent } from '../types';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ] as const;
7
+ const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
8
+
9
+ const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
10
+ string,
11
+ { content: CategoryLocaleContent }
12
+ >;
13
+
14
+ function textFrom(value: unknown): string {
15
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
16
+ if (Array.isArray(value)) return value.map(textFrom).join(' ');
17
+ if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
18
+ return '';
19
+ }
20
+
21
+ const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
22
+
23
+ function getContents(): Map<string, CategoryLocaleContent> {
24
+ return new Map(
25
+ Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
26
+ );
27
+ }
28
+
29
+ function validateContent(locale: string, content: CategoryLocaleContent): string[] {
30
+ const failures: string[] = [];
31
+ const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
32
+ const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
33
+ const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
34
+ check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
35
+ check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
36
+ check(content.seo.length >= 2, 'SEO needs at least two sections');
37
+
38
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
39
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
40
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
41
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
42
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
43
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
44
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
45
+
46
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
47
+ return failures;
48
+ }
49
+
50
+ describe('Category SEO quality contract', () => {
51
+ it('has one content file for every configured locale', () => {
52
+ const contents = getContents();
53
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
54
+ });
55
+
56
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
57
+ const contents = getContents();
58
+ const english = contents.get('en');
59
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
60
+
61
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
62
+ const content = contents.get(locale);
63
+ return content ? validateContent(locale, content) : [];
64
+ });
65
+
66
+ const englishShape = english?.seo.map((section) => section.type);
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
70
+ failures.push(`${locale}: SEO section types must match English`);
71
+ }
72
+ }
73
+
74
+ expect(failures, 'category SEO quality failures').toEqual([]);
75
+ }, 30000);
76
+ });
@@ -2,9 +2,15 @@ import { describe, expect, it } from 'vitest';
2
2
  import { ALL_ENTRIES } from '../entries';
3
3
 
4
4
  describe('Tool entry consumer contract', () => {
5
- it('every published tool exposes a Spanish locale loader', () => {
5
+ const requiredLocales = [
6
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
7
+ ];
8
+
9
+ it('every published tool exposes all required locale loaders', () => {
6
10
  for (const entry of ALL_ENTRIES) {
7
- expect(typeof entry.i18n.es, `${entry.id} must expose i18n.es`).toBe('function');
11
+ for (const locale of requiredLocales) {
12
+ expect(typeof entry.i18n[locale as keyof typeof entry.i18n], `${entry.id} must expose i18n.${locale}`).toBe('function');
13
+ }
8
14
  }
9
15
  });
10
16
  });
@@ -16,4 +16,4 @@ describe('FAQ Content Validation', () => {
16
16
  expect(TOOLS.length).toBe(0);
17
17
  });
18
18
  });
19
-
19
+
@@ -4,8 +4,6 @@ import { ALL_TOOLS } from '../tools';
4
4
  const EXPECTED_LOCALES = [
5
5
  'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
6
  ];
7
- const ENGLISH_FIRST_TOOLS = new Set(['promote-this-website']);
8
-
9
7
  describe('I18n Coverage Validation', () => {
10
8
  it('all tools should be registered', () => {
11
9
  expect(ALL_TOOLS.length).toBeGreaterThan(0);
@@ -15,8 +13,7 @@ describe('I18n Coverage Validation', () => {
15
13
  describe(`Tool: ${entry.id}`, () => {
16
14
  it('should have all 15 required locales', () => {
17
15
  const registeredLocales = Object.keys(entry.i18n);
18
- const requiredLocales = ENGLISH_FIRST_TOOLS.has(entry.id) ? ['en'] : EXPECTED_LOCALES;
19
- requiredLocales.forEach((locale) => {
16
+ EXPECTED_LOCALES.forEach((locale) => {
20
17
  expect(
21
18
  registeredLocales,
22
19
  `Tool "${entry.id}" is missing locale "${locale}"`,
@@ -25,8 +22,7 @@ describe('I18n Coverage Validation', () => {
25
22
  });
26
23
 
27
24
  it('all locale loaders should be functions', () => {
28
- const requiredLocales = ENGLISH_FIRST_TOOLS.has(entry.id) ? ['en'] : EXPECTED_LOCALES;
29
- requiredLocales.forEach((locale) => {
25
+ EXPECTED_LOCALES.forEach((locale) => {
30
26
  const loader = entry.i18n[locale as keyof typeof entry.i18n];
31
27
  expect(
32
28
  typeof loader,
@@ -1,113 +1,113 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
-
4
- const INVERTED_PUNCTUATION_LOCALES = {
5
- es: {
6
- language: 'Spanish',
7
- questionStart: '¿',
8
- questionEnd: '?',
9
- exclamationStart: '¡',
10
- exclamationEnd: '!',
11
- },
12
- } as const;
13
-
14
- type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
15
-
16
- const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
17
-
18
- function collectStrings(value: unknown): string[] {
19
- if (typeof value === 'string') return [value];
20
- if (!value || typeof value !== 'object') return [];
21
- if (Array.isArray(value)) return value.flatMap(collectStrings);
22
- return Object.values(value).flatMap(collectStrings);
23
- }
24
-
25
- function translatableStrings(content: Record<string, unknown>): string[] {
26
- return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
27
- }
28
-
29
- function sentenceStart(text: string, endIndex: number): string {
30
- const beforeMark = text.slice(0, endIndex).trimEnd();
31
-
32
- const boundaries = [
33
- ...[...beforeMark.matchAll(/\.(?=\s|$)/g)].map((m) => m.index ?? 0),
34
- ...[...beforeMark.matchAll(/:(?=\s|$)/g)].map((m) => m.index ?? 0),
35
- ...[...beforeMark.matchAll(/;(?=\s|$)/g)].map((m) => m.index ?? 0),
36
- ];
37
-
38
- const lastBoundary = boundaries.length > 0
39
- ? Math.max(...boundaries)
40
- : -1;
41
-
42
- const nlBoundary = beforeMark.lastIndexOf('\n');
43
- const boundary = Math.max(lastBoundary, nlBoundary);
44
-
45
- return beforeMark.slice(boundary + 1).trimStart();
46
- }
47
-
48
- function isCodeContext(text: string, index: number, mark: string): boolean {
49
- if (mark === '!') {
50
- const after = text.slice(index + 1, index + 10);
51
- return /^important\b/.test(after);
52
- }
53
- if (mark === '?') {
54
- const before = text.slice(Math.max(0, index - 2), index);
55
- return /[,#\/&+=]/.test(before) || / y $/.test(text.slice(0, index));
56
- }
57
- return false;
58
- }
59
-
60
- function findMissingInvertedMarks(
61
- text: string,
62
- startMark: string,
63
- endMark: string,
64
- _toolId: string,
65
- ): string[] {
66
- const cleaned = text.replace(/<code>.*?<\/code>/g, '');
67
-
68
- return [...cleaned.matchAll(new RegExp(`\\${endMark}`, 'g'))]
69
- .filter((match) => !isCodeContext(cleaned, match.index ?? 0, endMark))
70
- .map((match) => sentenceStart(cleaned, match.index ?? 0))
71
- .filter((segment) => {
72
- if (segment.length === 0) return false;
73
- if (segment.includes(startMark)) return false;
74
- if (endMark === '?' && /:\/\//.test(segment)) return false;
75
- return true;
76
- })
77
- .map((segment) => `${segment}${endMark}`);
78
- }
79
-
80
- describe('Inverted punctuation validation', () => {
81
- ALL_TOOLS.forEach((tool) => {
82
- describe(`Tool: ${tool.entry.id}`, () => {
83
- Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
84
- it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
85
- const typedLocale = locale as InvertedPunctuationLocale;
86
- const loader = tool.entry.i18n[typedLocale];
87
- if (!loader) return;
88
-
89
- const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
90
- const content = await loader();
91
- const strings = translatableStrings(content as Record<string, unknown>);
92
- const toolId = tool.entry.id;
93
- const missingQuestions = strings.flatMap((text) =>
94
- findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd, toolId)
95
- );
96
- const missingExclamations = strings.flatMap((text) =>
97
- findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd, toolId)
98
- );
99
- const failures = [...missingQuestions, ...missingExclamations];
100
-
101
- expect(
102
- failures,
103
- [
104
- `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
105
- `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
106
- `Examples: ${failures.slice(0, 5).join(' | ')}`,
107
- ].join(' '),
108
- ).toHaveLength(0);
109
- });
110
- });
111
- });
112
- });
113
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const INVERTED_PUNCTUATION_LOCALES = {
5
+ es: {
6
+ language: 'Spanish',
7
+ questionStart: '¿',
8
+ questionEnd: '?',
9
+ exclamationStart: '¡',
10
+ exclamationEnd: '!',
11
+ },
12
+ } as const;
13
+
14
+ type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
15
+
16
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
17
+
18
+ function collectStrings(value: unknown): string[] {
19
+ if (typeof value === 'string') return [value];
20
+ if (!value || typeof value !== 'object') return [];
21
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
22
+ return Object.values(value).flatMap(collectStrings);
23
+ }
24
+
25
+ function translatableStrings(content: Record<string, unknown>): string[] {
26
+ return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
27
+ }
28
+
29
+ function sentenceStart(text: string, endIndex: number): string {
30
+ const beforeMark = text.slice(0, endIndex).trimEnd();
31
+
32
+ const boundaries = [
33
+ ...[...beforeMark.matchAll(/\.(?=\s|$)/g)].map((m) => m.index ?? 0),
34
+ ...[...beforeMark.matchAll(/:(?=\s|$)/g)].map((m) => m.index ?? 0),
35
+ ...[...beforeMark.matchAll(/;(?=\s|$)/g)].map((m) => m.index ?? 0),
36
+ ];
37
+
38
+ const lastBoundary = boundaries.length > 0
39
+ ? Math.max(...boundaries)
40
+ : -1;
41
+
42
+ const nlBoundary = beforeMark.lastIndexOf('\n');
43
+ const boundary = Math.max(lastBoundary, nlBoundary);
44
+
45
+ return beforeMark.slice(boundary + 1).trimStart();
46
+ }
47
+
48
+ function isCodeContext(text: string, index: number, mark: string): boolean {
49
+ if (mark === '!') {
50
+ const after = text.slice(index + 1, index + 10);
51
+ return /^important\b/.test(after);
52
+ }
53
+ if (mark === '?') {
54
+ const before = text.slice(Math.max(0, index - 2), index);
55
+ return /[,#\/&+=]/.test(before) || / y $/.test(text.slice(0, index));
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function findMissingInvertedMarks(
61
+ text: string,
62
+ startMark: string,
63
+ endMark: string,
64
+ _toolId: string,
65
+ ): string[] {
66
+ const cleaned = text.replace(/<code>.*?<\/code>/g, '');
67
+
68
+ return [...cleaned.matchAll(new RegExp(`\\${endMark}`, 'g'))]
69
+ .filter((match) => !isCodeContext(cleaned, match.index ?? 0, endMark))
70
+ .map((match) => sentenceStart(cleaned, match.index ?? 0))
71
+ .filter((segment) => {
72
+ if (segment.length === 0) return false;
73
+ if (segment.includes(startMark)) return false;
74
+ if (endMark === '?' && /:\/\//.test(segment)) return false;
75
+ return true;
76
+ })
77
+ .map((segment) => `${segment}${endMark}`);
78
+ }
79
+
80
+ describe('Inverted punctuation validation', () => {
81
+ ALL_TOOLS.forEach((tool) => {
82
+ describe(`Tool: ${tool.entry.id}`, () => {
83
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
84
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
85
+ const typedLocale = locale as InvertedPunctuationLocale;
86
+ const loader = tool.entry.i18n[typedLocale];
87
+ if (!loader) return;
88
+
89
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
90
+ const content = await loader();
91
+ const strings = translatableStrings(content as Record<string, unknown>);
92
+ const toolId = tool.entry.id;
93
+ const missingQuestions = strings.flatMap((text) =>
94
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd, toolId)
95
+ );
96
+ const missingExclamations = strings.flatMap((text) =>
97
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd, toolId)
98
+ );
99
+ const failures = [...missingQuestions, ...missingExclamations];
100
+
101
+ expect(
102
+ failures,
103
+ [
104
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
105
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
106
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
107
+ ].join(' '),
108
+ ).toHaveLength(0);
109
+ });
110
+ });
111
+ });
112
+ });
113
+ });
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all 28 tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(29);
39
+ expect(ALL_TOOLS.length).toBe(30);
40
40
  });
41
41
  });
@@ -1,2 +1,2 @@
1
1
  export default function () { return null; }
2
-
2
+
@@ -45,4 +45,4 @@ describe('No H1 in Components', () => {
45
45
  });
46
46
  });
47
47
  });
48
-
48
+
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { promoteThisWebsite } from '../tool/promoteThisWebsite/entry';
3
+
4
+ const locales = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ];
7
+
8
+ describe('promote-this-website translations', () => {
9
+ it('provides every visible UI key in every locale', async () => {
10
+ const english = await promoteThisWebsite.i18n.en!();
11
+ const expectedKeys = Object.keys(english.ui).sort();
12
+
13
+ for (const locale of locales) {
14
+ const content = await promoteThisWebsite.i18n[locale as keyof typeof promoteThisWebsite.i18n]!();
15
+ expect(Object.keys(content.ui).sort(), `${locale} has an incomplete UI translation`).toEqual(expectedKeys);
16
+ expect(content.faq).toHaveLength(english.faq.length);
17
+ expect(content.howTo).toHaveLength(english.howTo.length);
18
+ expect(content.seo).toHaveLength(english.seo.length);
19
+ }
20
+ });
21
+
22
+ it('does not silently use English for the core translated controls', async () => {
23
+ const english = await promoteThisWebsite.i18n.en!();
24
+ const translatedControls = ['urlLabel', 'applyUrl', 'titleLabel', 'download', 'reset', 'titleStyleLabel', 'brandStyleLabel'];
25
+
26
+ for (const locale of locales.filter((locale) => locale !== 'en')) {
27
+ const content = await promoteThisWebsite.i18n[locale as keyof typeof promoteThisWebsite.i18n]!();
28
+ for (const key of translatedControls) {
29
+ expect(content.ui[key], `${locale}.${key} is still English`).not.toBe(english.ui[key]);
30
+ }
31
+ }
32
+ });
33
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -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,25 +1,25 @@
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
-
7
-
8
- ALL_TOOLS.forEach((tool) => {
9
- describe(`Tool: ${tool.entry.id}`, () => {
10
- it('slug should not end with 2-letter language codes like -ja, -ru, -ko', async () => {
11
- const locales = Object.keys(tool.entry.i18n);
12
-
13
- for (const locale of locales) {
14
- const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
15
- const content = (await loader?.()) as ToolLocaleContent;
16
-
17
- expect(
18
- content.slug,
19
- `Tool "${tool.entry.id}" locale "${locale}" slug ("${content.slug}") cannot end with a 2-letter language code (e.g., -ja, -ru, -ko).`,
20
- ).not.toMatch(/-[a-z]{2}$/);
21
- }
22
- });
23
- });
24
- });
25
- });
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
+
7
+
8
+ ALL_TOOLS.forEach((tool) => {
9
+ describe(`Tool: ${tool.entry.id}`, () => {
10
+ it('slug should not end with 2-letter language codes like -ja, -ru, -ko', async () => {
11
+ const locales = Object.keys(tool.entry.i18n);
12
+
13
+ for (const locale of locales) {
14
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
15
+ const content = (await loader?.()) as ToolLocaleContent;
16
+
17
+ expect(
18
+ content.slug,
19
+ `Tool "${tool.entry.id}" locale "${locale}" slug ("${content.slug}") cannot end with a 2-letter language code (e.g., -ja, -ru, -ko).`,
20
+ ).not.toMatch(/-[a-z]{2}$/);
21
+ }
22
+ });
23
+ });
24
+ });
25
+ });