@jjlmoya/utils-astronomy 1.18.0 → 1.19.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 (46) hide show
  1. package/package.json +4 -2
  2. package/src/category/i18n/de.ts +2 -2
  3. package/src/category/i18n/pl.ts +2 -2
  4. package/src/category/i18n/ru.ts +4 -4
  5. package/src/category/i18n/sv.ts +1 -1
  6. package/src/category/i18n/zh.ts +1 -1
  7. package/src/data.ts +0 -5
  8. package/src/layouts/PreviewLayout.astro +7 -2
  9. package/src/tests/diacritics_density.test.ts +118 -0
  10. package/src/tests/inverted_punctuation.test.ts +84 -0
  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/tool/bortleVisualizer/i18n/pl.ts +13 -13
  17. package/src/tool/bortleVisualizer/i18n/ru.ts +7 -7
  18. package/src/tool/deepSpaceScope/component.astro +7 -4
  19. package/src/tool/deepSpaceScope/i18n/de.ts +1 -1
  20. package/src/tool/deepSpaceScope/i18n/ko.ts +1 -1
  21. package/src/tool/deepSpaceScope/i18n/pl.ts +7 -7
  22. package/src/tool/deepSpaceScope/i18n/ru.ts +5 -5
  23. package/src/tool/deepSpaceScope/i18n/zh.ts +2 -2
  24. package/src/tool/smartEyepieceCalculator/component.astro +6 -6
  25. package/src/tool/smartEyepieceCalculator/i18n/fr.ts +11 -11
  26. package/src/tool/smartEyepieceCalculator/i18n/ru.ts +4 -4
  27. package/src/tool/smartEyepieceCalculator/i18n/zh.ts +2 -2
  28. package/src/tool/starExposureCalculator/component.astro +4 -4
  29. package/src/tool/starExposureCalculator/i18n/pl.ts +1 -1
  30. package/src/tool/starExposureCalculator/i18n/ru.ts +6 -6
  31. package/src/tool/telescopeResolution/component.astro +3 -3
  32. package/src/tool/telescopeResolution/i18n/de.ts +3 -3
  33. package/src/tool/telescopeResolution/i18n/en.ts +2 -2
  34. package/src/tool/telescopeResolution/i18n/es.ts +1 -1
  35. package/src/tool/telescopeResolution/i18n/fr.ts +2 -2
  36. package/src/tool/telescopeResolution/i18n/id.ts +1 -1
  37. package/src/tool/telescopeResolution/i18n/it.ts +1 -1
  38. package/src/tool/telescopeResolution/i18n/ja.ts +1 -1
  39. package/src/tool/telescopeResolution/i18n/ko.ts +1 -1
  40. package/src/tool/telescopeResolution/i18n/nl.ts +1 -1
  41. package/src/tool/telescopeResolution/i18n/pl.ts +2 -2
  42. package/src/tool/telescopeResolution/i18n/pt.ts +1 -1
  43. package/src/tool/telescopeResolution/i18n/ru.ts +10 -10
  44. package/src/tool/telescopeResolution/i18n/sv.ts +1 -1
  45. package/src/tool/telescopeResolution/i18n/tr.ts +1 -1
  46. package/src/tool/telescopeResolution/i18n/zh.ts +2 -2
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-astronomy",
3
- "version": "1.18.0",
3
+ "version": "1.19.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",
@@ -12,12 +12,12 @@ export const content: CategoryLocaleContent = {
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
15
- html: 'Von der Bewertung der Himmelsqualität mit der Bortle-Skala bis hin zur präzisen Berechnung der optischen Auflösung Ihres Teleskops jedes Tool nutzt von der internationalen Astronomie-Gemeinschaft validierte Formeln, damit jede Nacht unter den Sternen produktiv und unvergesslich wird.',
15
+ html: 'Von der Bewertung der Himmelsqualität mit der Bortle-Skala bis hin zur präzisen Berechnung der optischen Auflösung Ihres Teleskops - jedes Tool nutzt von der internationalen Astronomie-Gemeinschaft validierte Formeln, damit jede Nacht unter den Sternen produktiv und unvergesslich wird.',
16
16
  },
17
17
  { type: 'title', text: 'Lichtverschmutzung: Der größte Feind des Astronomen', level: 2 },
18
18
  {
19
19
  type: 'paragraph',
20
- html: 'Wir leben in einer zunehmend beleuchteten Welt. Die <strong>Lichtverschmutzung</strong> wächst jährlich um 10 % und löscht den Nachthimmel für mehr als 80 % der Weltbevölkerung aus. Die Bortle-Skala mit ihren 9 Stufen ist der internationale Standard zur Quantifizierung dieses Verlusts. Unser Dunkelhimmel-Simulator ermöglicht es Ihnen, interaktiv zu visualisieren, wie künstliches Licht das Firmament verändert vom absoluten Schwarz einer entlegenen Wüste bis zum bedrückenden Orange einer Metropole.',
20
+ html: 'Wir leben in einer zunehmend beleuchteten Welt. Die <strong>Lichtverschmutzung</strong> wächst jährlich um 10 % und löscht den Nachthimmel für mehr als 80 % der Weltbevölkerung aus. Die Bortle-Skala mit ihren 9 Stufen ist der internationale Standard zur Quantifizierung dieses Verlusts. Unser Dunkelhimmel-Simulator ermöglicht es Ihnen, interaktiv zu visualisieren, wie künstliches Licht das Firmament verändert - vom absoluten Schwarz einer entlegenen Wüste bis zum bedrückenden Orange einer Metropole.',
21
21
  },
22
22
  { type: 'title', text: 'Ihr Teleskop: Die wahren Möglichkeiten verstehen', level: 2 },
23
23
  {
@@ -12,12 +12,12 @@ export const content: CategoryLocaleContent = {
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
15
- html: 'Od oceny jakości nieba w skali Bortle\'a po precyzyjne obliczanie rozdzielczości optycznej teleskopu każde narzędzie wykorzystuje wzory zatwierdzone przez międzynarodową społeczność astronomiczną, aby każda noc spędzona pod gwiazdami była produktywna i niezapomniana.',
15
+ html: 'Od oceny jakości nieba w skali Bortle\'a po precyzyjne obliczanie rozdzielczości optycznej teleskopu - każde narzędzie wykorzystuje wzory zatwierdzone przez międzynarodową społeczność astronomiczną, aby każda noc spędzona pod gwiazdami była produktywna i niezapomniana.',
16
16
  },
17
17
  { type: 'title', text: 'Zanieczyszczenie Światłem: Największy Wróg Astronoma', level: 2 },
18
18
  {
19
19
  type: 'paragraph',
20
- html: 'Żyjemy w coraz bardziej oświetlonym świecie. <strong>Zanieczyszczenie światłem</strong> rośnie w tempie 10% rocznie i wymazuje nocne niebo dla ponad 80% populacji świata. Skala Bortle\'a, z jej 9 poziomami, jest międzynarodowym standardem ilościowego określania tej straty. Nasz symulator ciemnego nieba pozwala na interaktywną wizualizację tego, jak sztuczne światło zmienia firmament od absolutnej czerni odległej pustyni po uciążliwy pomarańcz metropolii.',
20
+ html: 'Żyjemy w coraz bardziej oświetlonym świecie. <strong>Zanieczyszczenie światłem</strong> rośnie w tempie 10% rocznie i wymazuje nocne niebo dla ponad 80% populacji świata. Skala Bortle\'a, z jej 9 poziomami, jest międzynarodowym standardem ilościowego określania tej straty. Nasz symulator ciemnego nieba pozwala na interaktywną wizualizację tego, jak sztuczne światło zmienia firmament - od absolutnej czerni odległej pustyni po uciążliwy pomarańcz metropolii.',
21
21
  },
22
22
  { type: 'title', text: 'Twój Teleskop: Zrozumienie Jego Rzeczywistych Możliwości', level: 2 },
23
23
  {
@@ -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: 'От оценки качества неба по шкале Бортля до точного расчета оптического разрешения вашего телескопа каждый инструмент применяет формулы, подтвержденные международным астрономическим сообществом, чтобы каждая ночь, проведенная под звездами, была продуктивной и запоминающейся.',
15
+ html: 'От оценки качества неба по шкале Бортля до точного расчета оптического разрешения вашего телескопа - каждый инструмент применяет формулы, подтвержденные международным астрономическим сообществом, чтобы каждая ночь, проведенная под звездами, была продуктивной и запоминающейся.',
16
16
  },
17
17
  { type: 'title', text: 'Световое загрязнение: враг номер один для астронома', level: 2 },
18
18
  {
@@ -22,7 +22,7 @@ export const content: CategoryLocaleContent = {
22
22
  { type: 'title', text: 'Ваш телескоп: понимание его реальных возможностей', level: 2 },
23
23
  {
24
24
  type: 'paragraph',
25
- html: 'Рынок переполнен телескопами, которые обещают «увидеть Сатурн с его кольцами» при любой апертуре. Реальность такова, что <strong>апертура объектива</strong> является самым критическим параметром. Она определяет, сколько света он собирает (предельная величина) и сколько деталей он может разрешить (разрешающая способность). Калькуляторы дальности и разрешения в этом разделе дают вам конкретные цифры: какие каталоги объектов доступны вашему оборудованию и какую детализацию можно ожидать на поверхностях планет.',
25
+ html: 'Рынок переполнен телескопами, которые обещают \"увидеть Сатурн с его кольцами\" при любой апертуре. Реальность такова, что <strong>апертура объектива</strong> является самым критическим параметром. Она определяет, сколько света он собирает (предельная величина) и сколько деталей он может разрешить (разрешающая способность). Калькуляторы дальности и разрешения в этом разделе дают вам конкретные цифры: какие каталоги объектов доступны вашему оборудованию и какую детализацию можно ожидать на поверхностях планет.',
26
26
  },
27
27
  {
28
28
  type: 'list',
@@ -41,7 +41,7 @@ export const content: CategoryLocaleContent = {
41
41
  { type: 'title', text: 'Астрофотография глубокого космоса: запечатлейте космос', level: 2 },
42
42
  {
43
43
  type: 'paragraph',
44
- html: 'Астрофотография демократизировала доступ к Вселенной. С помощью стандартной зеркальной камеры и широкоугольного объектива вы можете запечатлеть Млечный Путь во всем его великолепии. С телескопом и экваториальной монтировкой вы можете фотографировать туманности и галактики, которые превосходят то, что лучшие обсерватории могли видеть всего несколько десятилетий назад. Ключ кроется в <strong>времени экспозиции</strong>: слишком короткое и вы потеряете тусклые детали; слишком длинное и вы получите звездные треки из-за вращения Земли.',
44
+ html: 'Астрофотография демократизировала доступ к Вселенной. С помощью стандартной зеркальной камеры и широкоугольного объектива вы можете запечатлеть Млечный Путь во всем его великолепии. С телескопом и экваториальной монтировкой вы можете фотографировать туманности и галактики, которые превосходят то, что лучшие обсерватории могли видеть всего несколько десятилетий назад. Ключ кроется в <strong>времени экспозиции</strong>: слишком короткое - и вы потеряете тусклые детали; слишком длинное - и вы получите звездные треки из-за вращения Земли.',
45
45
  },
46
46
  {
47
47
  type: 'stats',
@@ -12,7 +12,7 @@ export const content: CategoryLocaleContent = {
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
15
- html: 'Från att utvärdera himmelskvalitet med Bortle-skalan till att exakt beräkna den optiska upplösningen på ditt teleskop varje verktyg tillämpar formler som validerats av det internationella astronomiska samfundet så att varje natt du spenderar under stjärnorna blir produktiv och minnesvärd.',
15
+ html: 'Från att utvärdera himmelskvalitet med Bortle-skalan till att exakt beräkna den optiska upplösningen på ditt teleskop - varje verktyg tillämpar formler som validerats av det internationella astronomiska samfundet så att varje natt du spenderar under stjärnorna blir produktiv och minnesvärd.',
16
16
  },
17
17
  { type: 'title', text: 'Ljusföroreningar: Astronomens Fiende Nummer Ett', level: 2 },
18
18
  {
@@ -22,7 +22,7 @@ export const content: CategoryLocaleContent = {
22
22
  { type: 'title', text: '你的望远镜:理解其真实能力', level: 2 },
23
23
  {
24
24
  type: 'paragraph',
25
- html: '市场上到处都是承诺在任何口径下都能“清晰看到土星光环”的望远镜。现实情况是,<strong>物镜口径</strong> 是最关键的参数。它决定了能收集多少光线(极限星等)以及能辨别多少细节(分辨力)。本节中的量程和分辨率计算器提供了具体的数值:哪些天体目录在你的观测范围内,以及你对行星表面的细节可以有怎样的期待。',
25
+ html: '市场上到处都是承诺在任何口径下都能\"清晰看到土星光环\"的望远镜。现实情况是,<strong>物镜口径</strong> 是最关键的参数。它决定了能收集多少光线(极限星等)以及能辨别多少细节(分辨力)。本节中的量程和分辨率计算器提供了具体的数值:哪些天体目录在你的观测范围内,以及你对行星表面的细节可以有怎样的期待。',
26
26
  },
27
27
  {
28
28
  type: 'list',
package/src/data.ts CHANGED
@@ -1,8 +1,3 @@
1
- export { bortleVisualizer } from './tool/bortleVisualizer';
2
- export { deepSpaceScope } from './tool/deepSpaceScope';
3
- export { starExposureCalculator } from './tool/starExposureCalculator';
4
- export { telescopeResolution } from './tool/telescopeResolution';
5
- export { EYEPIECE_CALCULATOR_TOOL as eyepieceCalculator } from './tool/smartEyepieceCalculator';
6
1
  export { toolsCategory as astronomyCategory } from './category';
7
2
 
8
3
  export type { BortleVisualizerUI, BortleVisualizerLocaleContent } from './tool/bortleVisualizer';
@@ -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 {
@@ -114,4 +120,3 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
114
120
  }
115
121
  }
116
122
  </style>
117
-
@@ -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
+ });
@@ -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
+ });
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });