@jjlmoya/utils-pets 1.12.0 → 1.14.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 (57) hide show
  1. package/package.json +4 -2
  2. package/src/category/i18n/fr.ts +4 -4
  3. package/src/category/i18n/ru.ts +4 -4
  4. package/src/category/i18n/zh.ts +1 -1
  5. package/src/data.ts +1 -1
  6. package/src/layouts/PreviewLayout.astro +7 -1
  7. package/src/pages/[locale]/[slug].astro +29 -13
  8. package/src/tests/diacritics_density.test.ts +118 -0
  9. package/src/tests/inverted_punctuation.test.ts +84 -0
  10. package/src/tests/locale_completeness.test.ts +4 -14
  11. package/src/tests/no_en_dash.test.ts +70 -0
  12. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  13. package/src/tests/script_density.test.ts +94 -0
  14. package/src/tests/shared-test-helpers.ts +56 -0
  15. package/src/tests/tool_exports.test.ts +34 -0
  16. package/src/tests/tool_validation.test.ts +2 -2
  17. package/src/tool/petAge/bibliography.astro +2 -10
  18. package/src/tool/petAge/bibliography.ts +6 -0
  19. package/src/tool/petAge/component.astro +1 -0
  20. package/src/tool/petAge/entry.ts +2 -0
  21. package/src/tool/petAge/i18n/de.ts +2 -7
  22. package/src/tool/petAge/i18n/en.ts +2 -7
  23. package/src/tool/petAge/i18n/es.ts +2 -7
  24. package/src/tool/petAge/i18n/fr.ts +4 -9
  25. package/src/tool/petAge/i18n/id.ts +2 -7
  26. package/src/tool/petAge/i18n/it.ts +2 -7
  27. package/src/tool/petAge/i18n/ja.ts +2 -7
  28. package/src/tool/petAge/i18n/ko.ts +2 -7
  29. package/src/tool/petAge/i18n/nl.ts +2 -7
  30. package/src/tool/petAge/i18n/pl.ts +2 -7
  31. package/src/tool/petAge/i18n/pt.ts +2 -7
  32. package/src/tool/petAge/i18n/ru.ts +9 -14
  33. package/src/tool/petAge/i18n/sv.ts +2 -7
  34. package/src/tool/petAge/i18n/tr.ts +2 -7
  35. package/src/tool/petAge/i18n/zh.ts +8 -13
  36. package/src/tool/petAge/seo.astro +2 -48
  37. package/src/tool/petRation/bibliography.astro +2 -10
  38. package/src/tool/petRation/bibliography.ts +6 -0
  39. package/src/tool/petRation/component.astro +2 -2
  40. package/src/tool/petRation/entry.ts +2 -0
  41. package/src/tool/petRation/i18n/de.ts +2 -7
  42. package/src/tool/petRation/i18n/en.ts +2 -7
  43. package/src/tool/petRation/i18n/es.ts +2 -7
  44. package/src/tool/petRation/i18n/fr.ts +8 -13
  45. package/src/tool/petRation/i18n/id.ts +2 -7
  46. package/src/tool/petRation/i18n/it.ts +2 -7
  47. package/src/tool/petRation/i18n/ja.ts +2 -7
  48. package/src/tool/petRation/i18n/ko.ts +3 -8
  49. package/src/tool/petRation/i18n/nl.ts +2 -7
  50. package/src/tool/petRation/i18n/pl.ts +2 -7
  51. package/src/tool/petRation/i18n/pt.ts +2 -7
  52. package/src/tool/petRation/i18n/ru.ts +8 -13
  53. package/src/tool/petRation/i18n/sv.ts +2 -7
  54. package/src/tool/petRation/i18n/tr.ts +5 -10
  55. package/src/tool/petRation/i18n/zh.ts +5 -10
  56. package/src/tool/petRation/seo.astro +2 -48
  57. package/src/types.ts +0 -2
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -5,7 +5,7 @@ export const content: CategoryLocaleContent = {
5
5
  title: 'Outils pour Animaux de Compagnie',
6
6
  description: 'Calculatrices et utilitaires gratuits pour les propriétaires d\'animaux. Découvrez l\'âge réel de votre chien ou chat et calculez leur ration quotidienne avec précision vétérinaire.',
7
7
  seo: [
8
- { type: 'title', text: 'Nutrition et Bien-être Animal : La Science au Service de votre Animal', level: 2 },
8
+ { type: 'title', text: 'Nutrition et Bien-être Animal: La Science au Service de votre Animal', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
11
  html: 'Prendre soin d\'un chien, d\'un chat ou d\'un lapin va bien au-delà de leur donner de la nourriture et de l\'affection. Cela implique de comprendre leur biologie, leur rythme de vieillissement et leurs besoins énergétiques spécifiques. Dans cette section, nous proposons des <strong>calculatrices vétérinaires gratuites</strong> qui appliquent des formules scientifiques validées pour vous aider à prendre de meilleures décisions concernant la santé de votre compagnon.',
@@ -14,16 +14,16 @@ export const content: CategoryLocaleContent = {
14
14
  type: 'paragraph',
15
15
  html: 'De la curiosité de savoir combien d\'années humaines a réellement votre chien, jusqu\'au calcul précis de la ration quotidienne basé sur le RER (Resting Energy Requirement), nos outils transforment des concepts vétérinaires complexes en réponses claires et exploitables pour le propriétaire responsable.',
16
16
  },
17
- { type: 'title', text: 'L\'Âge Réel de votre Animal : Au-delà du Mythe du "Fois 7"', level: 2 },
17
+ { type: 'title', text: 'L\'Âge Réel de votre Animal: Au-delà du Mythe du "Fois 7"', level: 2 },
18
18
  {
19
19
  type: 'paragraph',
20
20
  html: 'La croyance populaire qu\'une année de chien équivaut à sept années humaines est démystifiée depuis des décennies par la médecine vétérinaire moderne. La réalité est que les chiens mûrissent de façon exponentielle au cours de leurs deux premières années de vie, atteignant l\'équivalent de 15 ans humains à 12 mois et de 24 ans à 24 mois. Ensuite, le rythme ralentit et dépend énormément de la <strong>taille de l\'animal</strong>.',
21
21
  },
22
22
  {
23
23
  type: 'paragraph',
24
- html: 'Les chats, quant à eux, suivent une courbe de vieillissement plus linéaire : après les deux premières années de développement accéléré, chaque année féline équivaut approximativement à quatre années humaines. Connaître le stade de vie de votre animal n\'est pas seulement une curiosité, c\'est une information médicale pertinente qui détermine le moment des bilans gériatriques et l\'ajustement du régime alimentaire.',
24
+ html: 'Les chats, quant à eux, suivent une courbe de vieillissement plus linéaire: après les deux premières années de développement accéléré, chaque année féline équivaut approximativement à quatre années humaines. Connaître le stade de vie de votre animal n\'est pas seulement une curiosité, c\'est une information médicale pertinente qui détermine le moment des bilans gériatriques et l\'ajustement du régime alimentaire.',
25
25
  },
26
- { type: 'title', text: 'Nutrition de Précision : Calculez la Ration Quotidienne Exacte', level: 2 },
26
+ { type: 'title', text: 'Nutrition de Précision: Calculez la Ration Quotidienne Exacte', level: 2 },
27
27
  {
28
28
  type: 'paragraph',
29
29
  html: 'L\'obésité touche plus de 50 % des animaux de compagnie dans les pays développés et est la cause directe de problèmes articulaires, cardiovasculaires et d\'une espérance de vie réduite. Notre calculatrice de ration quotidienne applique la formule vétérinaire standard RER (70 × poids^0.75) multipliée par le facteur DER correspondant à l\'espèce, au stade de vie et au niveau d\'activité de votre animal.',
@@ -8,11 +8,11 @@ export const content: CategoryLocaleContent = {
8
8
  { type: 'title', text: 'Питание и Благополучие Животных: Наука на Службе Вашего Питомца', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
- html: 'Забота о собаке, кошке или кролике это гораздо больше, чем просто еда и ласка. Это требует понимания их биологии, ритма старения и специфических потребностей в энергии. В этом разделе мы предлагаем <strong>бесплатные ветеринарные калькуляторы</strong>, которые применяют проверенные научные формулы, чтобы помочь вам принимать более обоснованные решения в вопросах здоровья вашего спутника.',
11
+ html: 'Забота о собаке, кошке или кролике - это гораздо больше, чем просто еда и ласка. Это требует понимания их биологии, ритма старения и специфических потребностей в энергии. В этом разделе мы предлагаем <strong>бесплатные ветеринарные калькуляторы</strong>, которые применяют проверенные научные формулы, чтобы помочь вам принимать более обоснованные решения в вопросах здоровья вашего спутника.',
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
15
- html: 'От любопытства узнать, сколько человеческих лет на самом деле вашей собаке, до точного расчета дневного рациона на основе RER (Resting Energy Requirement) наши инструменты превращают сложные ветеринарные концепции в четкие и практичные ответы для ответственного владельца домашнего животного.',
15
+ html: 'От любопытства узнать, сколько человеческих лет на самом деле вашей собаке, до точного расчета дневного рациона на основе RER (Resting Energy Requirement) - наши инструменты превращают сложные ветеринарные концепции в четкие и практичные ответы для ответственного владельца домашнего животного.',
16
16
  },
17
17
  { type: 'title', text: 'Реальный Возраст Вашего Питомца: За Пределами Мифа об "Умножении на 7"', level: 2 },
18
18
  {
@@ -21,7 +21,7 @@ export const content: CategoryLocaleContent = {
21
21
  },
22
22
  {
23
23
  type: 'paragraph',
24
- html: 'Кошки, с другой стороны, следуют более линейной кривой старения: после первых двух лет ускоренного развития каждый кошачий год примерно эквивалентен четырем человеческим годам. Знание этапа жизни вашего питомца это не просто любопытство, это важная медицинская информация: она определяет, когда планировать гериатрические осмотры, когда корректировать диету и какие признаки здоровья следует контролировать.',
24
+ html: 'Кошки, с другой стороны, следуют более линейной кривой старения: после первых двух лет ускоренного развития каждый кошачий год примерно эквивалентен четырем человеческим годам. Знание этапа жизни вашего питомца - это не просто любопытство, это важная медицинская информация: она определяет, когда планировать гериатрические осмотры, когда корректировать диету и какие признаки здоровья следует контролировать.',
25
25
  },
26
26
  { type: 'title', text: 'Точное Питание: Рассчитайте Точный Дневной Рацион', level: 2 },
27
27
  {
@@ -42,7 +42,7 @@ export const content: CategoryLocaleContent = {
42
42
  },
43
43
  {
44
44
  type: 'paragraph',
45
- html: 'Инвестиции в знания о биологии вашего питомца самая эффективная форма профилактической медицины. Здоровый вес и своевременная ветеринарная помощь могут добавить годы качественной жизни вашему верному спутнику. Научный подход к уходу гарантирует, что ваш питомец не просто выживет, но и преуспеет на каждом этапе своей жизни.',
45
+ html: 'Инвестиции в знания о биологии вашего питомца - самая эффективная форма профилактической медицины. Здоровый вес и своевременная ветеринарная помощь могут добавить годы качественной жизни вашему верному спутнику. Научный подход к уходу гарантирует, что ваш питомец не просто выживет, но и преуспеет на каждом этапе своей жизни.',
46
46
  },
47
47
  {
48
48
  type: 'paragraph',
@@ -14,7 +14,7 @@ export const content: CategoryLocaleContent = {
14
14
  type: 'paragraph',
15
15
  html: '从 好奇 想 知道 您 的 狗 实际上 有 多少 岁 的 人类 年龄 , 到 基于 RER ( 静息 能量 需求 ) 的 每日 喂食量 的 精确 计算 , 我们 的 工具 将 复杂 的 兽医学 概念 转化 为 对 负责任 的 宠物 主人 而言 清晰 、 可 操作 的 答案 。',
16
16
  },
17
- { type: 'title', text: '您 宠物 的 真实 年龄 : 超越 乘以 7 的 误区', level: 2 },
17
+ { type: 'title', text: '您 宠物 的 真实 年龄 : 超越 \" 乘以 7 \" 的 误区', level: 2 },
18
18
  {
19
19
  type: 'paragraph',
20
20
  html: '一个 狗 年 等于 七个 人类 年 的 流行 观念 几十年来 一直 被 现代 兽医学 所 否定 。 现实 情况 是 , 狗 在 生命 的 前 两年 呈 指数 级 成熟 , 在 12 个月 时 达到 相当于 人类 15 岁 的 水平 , 在 24 个月 时 达到 24 岁 。 随后 , 速度 会 变慢 , 并 极大 地 取决于 <strong>动物 的 体型</strong> : 体重 超过 45 公斤 的 巨型 犬 的 衰老 速度 比 小型 犬 快 多 达 60% 。',
package/src/data.ts CHANGED
@@ -1,6 +1,6 @@
1
+ export { petsCategory } from './category';
1
2
  export { petAge } from './tool/petAge';
2
3
  export { petRation } from './tool/petRation';
3
- export { petsCategory } from './category';
4
4
 
5
5
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
6
6
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
@@ -10,7 +10,12 @@ interface Props {
10
10
  hasSidebar?: boolean;
11
11
  }
12
12
 
13
- const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
13
+ const {
14
+ title,
15
+ currentLocale = "es",
16
+ localeUrls = {},
17
+ hasSidebar = false,
18
+ } = Astro.props;
14
19
  ---
15
20
 
16
21
  <!doctype html>
@@ -78,6 +83,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
83
  transition:
79
84
  background-color 0.3s ease,
80
85
  color 0.3s ease;
86
+ font-family: Inter, sans-serif;
81
87
  }
82
88
 
83
89
  main {
@@ -34,18 +34,28 @@ export async function getStaticPaths() {
34
34
  ]),
35
35
  ) as Partial<Record<KnownLocale, string>>;
36
36
 
37
+ const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
38
+ const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
39
+
37
40
  for (const { locale, content } of localeContents) {
38
- const allToolsNav = await Promise.all(
39
- ALL_TOOLS.map(async ({ entry: navEntry }) => ({
40
- id: navEntry.id,
41
- title: (await navEntry.i18n[locale]!()).title,
42
- href: `/${locale}/${(await navEntry.i18n[locale]!()).slug}`,
43
- isActive: navEntry.id === entry.id,
44
- })),
45
- );
41
+ const allToolsNav = (
42
+ await Promise.all(
43
+ ALL_TOOLS.map(async ({ entry: navEntry }) => {
44
+ const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
45
+ if (!loader) return null;
46
+ const navContent = await loader();
47
+ return {
48
+ id: navEntry.id,
49
+ title: navContent.title,
50
+ href: `/${locale}/${navContent.slug}`,
51
+ isActive: navEntry.id === entry.id,
52
+ };
53
+ }),
54
+ )
55
+ ).filter(Boolean) as NavItem[];
46
56
  paths.push({
47
57
  params: { locale, slug: content.slug },
48
- props: { Component, locale, content, localeUrls, allToolsNav },
58
+ props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
49
59
  });
50
60
  }
51
61
  }
@@ -66,11 +76,16 @@ interface Props {
66
76
  content: ToolLocaleContent;
67
77
  localeUrls: Partial<Record<KnownLocale, string>>;
68
78
  allToolsNav: NavItem[];
79
+ englishSlug: string;
69
80
  }
70
81
 
71
- const { Component, locale, content, localeUrls, allToolsNav } = Astro.props;
82
+ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
83
+
84
+ const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
85
+ const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
+ const toolCss = cssKey ? await cssFiles[cssKey]() as string : "";
72
87
 
73
- const seoContent: UtilitySEOContent = { locale, sections: content.seo };
88
+ const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
74
89
 
75
90
  const words = content.title.split(" ");
76
91
  const titleHighlight = words[0] || "";
@@ -89,8 +104,9 @@ const titleBase = words.slice(1).join(" ") || "";
89
104
  tools={allToolsNav}
90
105
  />
91
106
  <Fragment slot="head">
107
+ {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
92
108
  {
93
- content.schemas.map((schema) => (
109
+ ( content.schemas ?? []).map((schema) => (
94
110
  <script
95
111
  is:inline
96
112
  type="application/ld+json"
@@ -120,7 +136,7 @@ const titleBase = words.slice(1).join(" ") || "";
120
136
  </section>
121
137
 
122
138
  <section class="section-bibliography">
123
- <Bibliography links={content.bibliography} title={content.bibliographyTitle} />
139
+ <Bibliography links={content.bibliography} />
124
140
  </section>
125
141
  </div>
126
142
  </PreviewLayout>
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
5
+
6
+ const DIACRITIC_RULES = {
7
+ de: {
8
+ language: 'German',
9
+ expectedCharacters: 'ä ö ü ß',
10
+ characters: /[äöüÄÖÜß]/g,
11
+ minPerThousandLetters: 0.1,
12
+ },
13
+ es: {
14
+ language: 'Spanish',
15
+ expectedCharacters: 'á é í ó ú ü ñ',
16
+ characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
17
+ minPerThousandLetters: 0.1,
18
+ },
19
+ fr: {
20
+ language: 'French',
21
+ expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
22
+ characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
23
+ minPerThousandLetters: 0.1,
24
+ },
25
+ it: {
26
+ language: 'Italian',
27
+ expectedCharacters: 'à è é ì í î ò ó ù ú',
28
+ characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
29
+ minPerThousandLetters: 0.1,
30
+ },
31
+ pl: {
32
+ language: 'Polish',
33
+ expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
34
+ characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
35
+ minPerThousandLetters: 0.1,
36
+ },
37
+ pt: {
38
+ language: 'Portuguese',
39
+ expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
40
+ characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
41
+ minPerThousandLetters: 0.1,
42
+ },
43
+ sv: {
44
+ language: 'Swedish',
45
+ expectedCharacters: 'å ä ö',
46
+ characters: /[åäöÅÄÖ]/g,
47
+ minPerThousandLetters: 0.1,
48
+ },
49
+ tr: {
50
+ language: 'Turkish',
51
+ expectedCharacters: 'ç ğ ı İ ö ş ü',
52
+ characters: /[çğıöşüÇĞİÖŞÜ]/g,
53
+ minPerThousandLetters: 0.1,
54
+ },
55
+ } as const;
56
+
57
+ const LETTERS = /\p{L}/gu;
58
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
59
+
60
+ function collectStrings(value: unknown): string[] {
61
+ if (typeof value === 'string') return [value];
62
+ if (!value || typeof value !== 'object') return [];
63
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
64
+ return Object.values(value).flatMap(collectStrings);
65
+ }
66
+
67
+ function normalizeText(value: unknown): string {
68
+ return collectStrings(value).join(' ').normalize('NFC');
69
+ }
70
+
71
+ function translatableContent(content: Record<string, unknown>) {
72
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
73
+ }
74
+
75
+ function letterCount(text: string): number {
76
+ return text.match(LETTERS)?.length ?? 0;
77
+ }
78
+
79
+ function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
80
+ return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
81
+ }
82
+
83
+ function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
84
+ const letters = letterCount(text);
85
+ if (letters === 0) return 0;
86
+ return diacriticCount(text, locale) / letters * 1000;
87
+ }
88
+
89
+ describe('Diacritics density validation', () => {
90
+ ALL_TOOLS.forEach((tool) => {
91
+ describe(`Tool: ${tool.entry.id}`, () => {
92
+ Object.keys(DIACRITIC_RULES).forEach((locale) => {
93
+ it(`${locale} keeps the expected accent and special-letter set`, async () => {
94
+ const typedLocale = locale as LocaleWithDiacritics;
95
+ const loader = tool.entry.i18n[typedLocale];
96
+ if (!loader) return;
97
+
98
+ const content = await loader();
99
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
100
+ const rule = DIACRITIC_RULES[typedLocale];
101
+ const letters = letterCount(text);
102
+ const matches = diacriticCount(text, typedLocale);
103
+ const density = diacriticsPerThousandLetters(text, typedLocale);
104
+
105
+ expect(
106
+ density,
107
+ [
108
+ `Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
109
+ `The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
110
+ `This locale should include some of these characters: ${rule.expectedCharacters}.`,
111
+ 'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
112
+ ].join(' '),
113
+ ).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
114
+ });
115
+ });
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,84 @@
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
+ const boundary = Math.max(
32
+ beforeMark.lastIndexOf('.'),
33
+ beforeMark.lastIndexOf(':'),
34
+ beforeMark.lastIndexOf(';'),
35
+ beforeMark.lastIndexOf('\n'),
36
+ );
37
+
38
+ return beforeMark.slice(boundary + 1).trimStart();
39
+ }
40
+
41
+ function findMissingInvertedMarks(
42
+ text: string,
43
+ startMark: string,
44
+ endMark: string,
45
+ ): string[] {
46
+ return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
47
+ .map((match) => sentenceStart(text, match.index ?? 0))
48
+ .filter((segment) => segment.length > 0 && !segment.includes(startMark))
49
+ .map((segment) => `${segment}${endMark}`);
50
+ }
51
+
52
+ describe('Inverted punctuation validation', () => {
53
+ ALL_TOOLS.forEach((tool) => {
54
+ describe(`Tool: ${tool.entry.id}`, () => {
55
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
56
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
57
+ const typedLocale = locale as InvertedPunctuationLocale;
58
+ const loader = tool.entry.i18n[typedLocale];
59
+ if (!loader) return;
60
+
61
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
+ const content = await loader();
63
+ const strings = translatableStrings(content as Record<string, unknown>);
64
+ const missingQuestions = strings.flatMap((text) =>
65
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
+ );
67
+ const missingExclamations = strings.flatMap((text) =>
68
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
69
+ );
70
+ const failures = [...missingQuestions, ...missingExclamations];
71
+
72
+ expect(
73
+ failures,
74
+ [
75
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
76
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
77
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
78
+ ].join(' '),
79
+ ).toHaveLength(0);
80
+ });
81
+ });
82
+ });
83
+ });
84
+ });
@@ -7,28 +7,18 @@ describe('Locale Completeness Validation', () => {
7
7
  describe(`Tool: ${tool.entry.id}`, () => {
8
8
  Object.keys(tool.entry.i18n).forEach((locale) => {
9
9
  describe(`Locale: ${locale}`, () => {
10
- it('faqTitle should be defined when faq items exist', async () => {
10
+ it('faq should be defined', async () => {
11
11
  const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
12
  const content = (await loader?.()) as ToolLocaleContent;
13
13
 
14
- if (content.faq.length > 0) {
15
- expect(
16
- content.faqTitle,
17
- `Tool "${tool.entry.id}" locale "${locale}" has ${content.faq.length} FAQ items but is missing faqTitle`,
18
- ).toBeTruthy();
19
- }
14
+ expect(Array.isArray(content.faq)).toBe(true);
20
15
  });
21
16
 
22
- it('bibliographyTitle should be defined when bibliography items exist', async () => {
17
+ it('bibliography should be defined', async () => {
23
18
  const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
24
19
  const content = (await loader?.()) as ToolLocaleContent;
25
20
 
26
- if (content.bibliography.length > 0) {
27
- expect(
28
- content.bibliographyTitle,
29
- `Tool "${tool.entry.id}" locale "${locale}" has ${content.bibliography.length} bibliography items but is missing bibliographyTitle`,
30
- ).toBeTruthy();
31
- }
21
+ expect(Array.isArray(content.bibliography)).toBe(true);
32
22
  });
33
23
  });
34
24
  });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) {
8
+ return results;
9
+ }
10
+ const list = fs.readdirSync(dir);
11
+ for (const file of list) {
12
+ const fullPath = path.join(dir, file);
13
+ const stat = fs.statSync(fullPath);
14
+ if (stat && stat.isDirectory()) {
15
+ if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
16
+ results.push(...getFiles(fullPath));
17
+ }
18
+ } else {
19
+ results.push(fullPath);
20
+ }
21
+ }
22
+ return results;
23
+ }
24
+
25
+ function isContentFile(filePath: string): boolean {
26
+ return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
27
+ }
28
+
29
+ const srcDir = path.join(process.cwd(), 'src');
30
+ const scriptsDir = path.join(process.cwd(), 'scripts');
31
+ const filesToTest = [
32
+ ...getFiles(srcDir).filter(isContentFile),
33
+ ...getFiles(scriptsDir).filter(isContentFile),
34
+ ];
35
+
36
+ const aiTypographyGarbage = [
37
+ '\u2013',
38
+ '\u2014',
39
+ '\u2026',
40
+ '\u201C',
41
+ '\u201D',
42
+ '\u2018',
43
+ '\u2019',
44
+ '\u00AB',
45
+ '\u00BB',
46
+ '\u200B',
47
+ '\u201E',
48
+ ];
49
+
50
+ describe('Typography Garbage Character Validation', () => {
51
+ filesToTest.forEach((filePath) => {
52
+ const relativePath = path.relative(process.cwd(), filePath);
53
+ it(`should not contain typography garbage characters in ${relativePath}`, () => {
54
+ const content = fs.readFileSync(filePath, 'utf-8');
55
+ const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
56
+ expect(hasAiPatterns).toBe(false);
57
+ });
58
+
59
+ it(`should not contain space before colon in ${relativePath}`, () => {
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ const spaceBeforeColon = / : /.test(content);
62
+ expect(spaceBeforeColon).toBe(false);
63
+ });
64
+
65
+ it(`should not contain double hyphen in ${relativePath}`, () => {
66
+ const content = fs.readFileSync(filePath, 'utf-8');
67
+ expect(content).not.toContain('--');
68
+ });
69
+ });
70
+ });