@jjlmoya/utils-forensic-science 1.13.0 → 1.15.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 (145) hide show
  1. package/package.json +2 -1
  2. package/src/category/i18n/en.ts +3 -3
  3. package/src/category/i18n/es.ts +2 -2
  4. package/src/category/i18n/fr.ts +1 -1
  5. package/src/category/i18n/nl.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/index.ts +61 -59
  8. package/src/category/seo.astro +1 -1
  9. package/src/components/PreviewNavSidebar.astro +1 -1
  10. package/src/components/PreviewToolbar.astro +1 -1
  11. package/src/data.ts +1 -1
  12. package/src/entries.ts +70 -68
  13. package/src/env.d.ts +1 -1
  14. package/src/icons/.gitkeep +1 -0
  15. package/src/index.ts +32 -31
  16. package/src/pages/[locale]/[slug].astro +161 -159
  17. package/src/pages/[locale].astro +8 -5
  18. package/src/pages/index.astro +1 -1
  19. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  20. package/src/tests/category_seo_quality.test.ts +76 -0
  21. package/src/tests/diacritics_density.test.ts +1 -1
  22. package/src/tests/faq_count.test.ts +19 -19
  23. package/src/tests/i18n_coverage.test.ts +36 -36
  24. package/src/tests/inverted_punctuation.test.ts +1 -1
  25. package/src/tests/locale_completeness.test.ts +2 -2
  26. package/src/tests/mocks/astro_mock.js +1 -1
  27. package/src/tests/no_h1_in_components.test.ts +1 -1
  28. package/src/tests/script_density.test.ts +94 -94
  29. package/src/tests/seo_length.test.ts +46 -46
  30. package/src/tests/seo_parity.test.ts +2 -7
  31. package/src/tests/seo_translation_completeness.test.ts +68 -0
  32. package/src/tests/seo_wellformed_export.test.ts +65 -0
  33. package/src/tests/tool_validation.test.ts +2 -2
  34. package/src/tests/translation_copy.test.ts +23 -24
  35. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
  36. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
  37. package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
  38. package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
  39. package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
  40. package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
  41. package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
  42. package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
  43. package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
  44. package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
  45. package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
  46. package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
  47. package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
  48. package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
  49. package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
  50. package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
  51. package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
  52. package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
  53. package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
  54. package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
  55. package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
  56. package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
  57. package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
  58. package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
  59. package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
  60. package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
  61. package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
  62. package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
  63. package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
  64. package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
  65. package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
  66. package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
  67. package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
  68. package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
  69. package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
  70. package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
  71. package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
  72. package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
  73. package/src/tool/forensic-age-estimator/component.astro +1 -1
  74. package/src/tool/forensic-age-estimator/entry.ts +17 -17
  75. package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
  76. package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
  77. package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
  78. package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
  79. package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
  80. package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
  81. package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
  82. package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
  83. package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
  84. package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
  85. package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
  86. package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
  87. package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
  88. package/src/tool/forensic-age-estimator/index.ts +1 -1
  89. package/src/tool/forensic-age-estimator/seo.astro +1 -1
  90. package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
  91. package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
  92. package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
  93. package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
  94. package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
  95. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
  96. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
  97. package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
  98. package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
  99. package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
  100. package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
  101. package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
  102. package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
  103. package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
  104. package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
  105. package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
  106. package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
  107. package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
  108. package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
  109. package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
  110. package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
  111. package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
  112. package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
  113. package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
  114. package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
  115. package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
  116. package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
  117. package/src/tool/forensic-sex-determinator/component.astro +3 -2
  118. package/src/tool/forensic-stature-estimator/component.astro +1 -1
  119. package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
  120. package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
  121. package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
  122. package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
  123. package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
  124. package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
  125. package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
  126. package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
  127. package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
  128. package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
  129. package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
  130. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
  131. package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
  132. package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
  133. package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
  134. package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
  135. package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
  136. package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
  137. package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
  138. package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
  139. package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
  140. package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
  141. package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
  142. package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
  143. package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
  144. package/src/tools.ts +37 -35
  145. package/src/types.ts +4 -4
@@ -1,162 +1,164 @@
1
- ---
2
- import PreviewLayout from "../../layouts/PreviewLayout.astro";
3
- import PreviewNavSidebar from "../../components/PreviewNavSidebar.astro";
4
- import { ALL_TOOLS } from "../../index";
5
- import {
6
- UtilityHeader,
7
- FAQSection,
8
- Bibliography,
9
- SEORenderer,
10
- } from "@jjlmoya/utils-shared";
1
+ ---
2
+ import PreviewLayout from "../../layouts/PreviewLayout.astro";
3
+ import PreviewNavSidebar from "../../components/PreviewNavSidebar.astro";
4
+ import { ALL_TOOLS } from "../../index";
5
+ import {
6
+ UtilityHeader,
7
+ FAQSection,
8
+ Bibliography,
9
+ SEORenderer,
10
+ } from "@jjlmoya/utils-shared";
11
11
  import type { KnownLocale, ToolLocaleContent } from "../../types";
12
12
  import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
13
-
14
- export async function getStaticPaths() {
15
- const paths = [];
16
-
17
- for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
19
- const localeEntries = Object.entries(entry.i18n) as [
20
- KnownLocale,
21
- () => Promise<ToolLocaleContent>,
22
- ][];
23
- const localeContents = await Promise.all(
24
- localeEntries.map(async ([locale, loader]) => ({
25
- locale,
26
- content: await loader(),
27
- })),
28
- );
29
-
30
- const localeUrls = Object.fromEntries(
31
- localeContents.map(({ locale, content }) => [
32
- locale,
33
- `/${locale}/${content.slug}`,
34
- ]),
35
- ) as Partial<Record<KnownLocale, string>>;
36
-
37
- const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
38
- const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
39
-
40
- for (const { locale, content } of localeContents) {
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[];
56
- paths.push({
57
- params: { locale, slug: content.slug },
58
- props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
59
- });
60
- }
61
- }
62
-
63
- return paths;
64
- }
65
-
66
- interface NavItem {
67
- id: string;
68
- title: string;
69
- href: string;
70
- isActive?: boolean;
71
- }
72
-
73
- interface Props {
74
- Component: unknown;
75
- locale: KnownLocale;
76
- content: ToolLocaleContent;
77
- localeUrls: Partial<Record<KnownLocale, string>>;
78
- allToolsNav: NavItem[];
79
- englishSlug: string;
80
- }
81
-
13
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
14
+
15
+ export async function getStaticPaths() {
16
+ const paths = [];
17
+
18
+ for (const { entry, Component: lazyComp } of ALL_TOOLS) {
19
+ const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
20
+ const localeEntries = Object.entries(entry.i18n) as [
21
+ KnownLocale,
22
+ () => Promise<ToolLocaleContent>,
23
+ ][];
24
+ const localeContents = await Promise.all(
25
+ localeEntries.map(async ([locale, loader]) => ({
26
+ locale,
27
+ content: await loader(),
28
+ })),
29
+ );
30
+
31
+ const localeUrls = Object.fromEntries(
32
+ localeContents.map(({ locale, content }) => [
33
+ locale,
34
+ `/${locale}/${content.slug}`,
35
+ ]),
36
+ ) as Partial<Record<KnownLocale, string>>;
37
+
38
+ const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
39
+ const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
40
+
41
+ for (const { locale, content } of localeContents) {
42
+ const allToolsNav = (
43
+ await Promise.all(
44
+ ALL_TOOLS.map(async ({ entry: navEntry }) => {
45
+ const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
46
+ if (!loader) return null;
47
+ const navContent = await loader();
48
+ return {
49
+ id: navEntry.id,
50
+ title: navContent.title,
51
+ href: `/${locale}/${navContent.slug}`,
52
+ isActive: navEntry.id === entry.id,
53
+ };
54
+ }),
55
+ )
56
+ ).filter(Boolean) as NavItem[];
57
+ paths.push({
58
+ params: { locale, slug: content.slug },
59
+ props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
60
+ });
61
+ }
62
+ }
63
+
64
+ return paths;
65
+ }
66
+
67
+ interface NavItem {
68
+ id: string;
69
+ title: string;
70
+ href: string;
71
+ isActive?: boolean;
72
+ }
73
+
74
+ interface Props {
75
+ Component: unknown;
76
+ locale: KnownLocale;
77
+ content: ToolLocaleContent;
78
+ localeUrls: Partial<Record<KnownLocale, string>>;
79
+ allToolsNav: NavItem[];
80
+ englishSlug: string;
81
+ }
82
+
82
83
  const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as 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 cssLoader = cssKey ? cssFiles[cssKey] : null;
87
- const toolCss = cssLoader ? await cssLoader() as string : "";
88
-
89
- const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
90
-
91
- const words = content.title.split(" ");
92
- const titleHighlight = words[0] || "";
93
- const titleBase = words.slice(1).join(" ") || "";
94
- ---
95
-
96
- <PreviewLayout
97
- title={content.title}
98
- currentLocale={locale}
99
- localeUrls={localeUrls}
100
- hasSidebar={true}
101
- >
102
- <PreviewNavSidebar
103
- slot="sidebar"
104
- categoryTitle="Tools"
105
- tools={allToolsNav}
106
- />
107
- <Fragment slot="head">
108
- {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
109
- {
110
- ( content.schemas ?? []).map((schema: unknown) => (
111
- <script
112
- is:inline
113
- type="application/ld+json"
114
- set:html={JSON.stringify(schema)}
115
- />
116
- ))
117
- }
118
- </Fragment>
119
-
120
- <div class="tool-page">
121
- <UtilityHeader
122
- titleHighlight={titleHighlight}
123
- titleBase={titleBase}
124
- description={content.description}
125
- />
126
-
127
- <section class="section-tool">
128
- <Component ui={content.ui} />
129
- </section>
130
-
131
- <section class="section-seo">
132
- <SEORenderer content={seoContent} />
133
- </section>
134
-
135
- <section class="section-faq">
136
- <FAQSection items={content.faq} />
137
- </section>
138
-
139
- <section class="section-bibliography">
140
- <Bibliography links={content.bibliography} />
141
- </section>
142
- </div>
143
- </PreviewLayout>
144
-
145
- <style>
146
- .tool-page {
147
- display: flex;
148
- flex-direction: column;
149
- gap: 2rem;
150
- }
151
- .section-tool {
152
- max-width: 1200px;
153
- margin: 0 auto;
154
- width: 100%;
155
- }
156
- .section-seo,
157
- .section-faq,
158
- .section-bibliography {
159
- padding-top: 2rem;
160
- border-top: 1px solid var(--border-color);
161
- }
162
- </style>
84
+ const ToolComponent = Component as AstroComponentFactory;
85
+
86
+ const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
87
+ const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
88
+ const cssLoader = cssKey ? cssFiles[cssKey] : null;
89
+ const toolCss = cssLoader ? await cssLoader() as string : "";
90
+
91
+ const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
92
+
93
+ const words = content.title.split(" ");
94
+ const titleHighlight = words[0] || "";
95
+ const titleBase = words.slice(1).join(" ") || "";
96
+ ---
97
+
98
+ <PreviewLayout
99
+ title={content.title}
100
+ currentLocale={locale}
101
+ localeUrls={localeUrls}
102
+ hasSidebar={true}
103
+ >
104
+ <PreviewNavSidebar
105
+ slot="sidebar"
106
+ categoryTitle="Tools"
107
+ tools={allToolsNav}
108
+ />
109
+ <Fragment slot="head">
110
+ {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
111
+ {
112
+ ( content.schemas ?? []).map((schema: unknown) => (
113
+ <script
114
+ is:inline
115
+ type="application/ld+json"
116
+ set:html={JSON.stringify(schema)}
117
+ />
118
+ ))
119
+ }
120
+ </Fragment>
121
+
122
+ <div class="tool-page">
123
+ <UtilityHeader
124
+ titleHighlight={titleHighlight}
125
+ titleBase={titleBase}
126
+ description={content.description}
127
+ />
128
+
129
+ <section class="section-tool">
130
+ <ToolComponent ui={content.ui} />
131
+ </section>
132
+
133
+ <section class="section-seo">
134
+ <SEORenderer content={seoContent} />
135
+ </section>
136
+
137
+ <section class="section-faq">
138
+ <FAQSection items={content.faq} />
139
+ </section>
140
+
141
+ <section class="section-bibliography">
142
+ <Bibliography links={content.bibliography} />
143
+ </section>
144
+ </div>
145
+ </PreviewLayout>
146
+
147
+ <style>
148
+ .tool-page {
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 2rem;
152
+ }
153
+ .section-tool {
154
+ max-width: 1200px;
155
+ margin: 0 auto;
156
+ width: 100%;
157
+ }
158
+ .section-seo,
159
+ .section-faq,
160
+ .section-bibliography {
161
+ padding-top: 2rem;
162
+ border-top: 1px solid var(--border-color);
163
+ }
164
+ </style>
@@ -28,7 +28,7 @@ const toolsWithContent = tools.length > 0
28
28
  tools.map(async ({ entry, Component }) => {
29
29
  const languages = Object.keys(entry.i18n) as KnownLocale[];
30
30
  const localeEntries = await Promise.all(
31
- languages.map(async (l) => {
31
+ languages.map(async (l) => {
32
32
  const loader = entry.i18n[l];
33
33
  if (!loader) return null;
34
34
  const content = await loader();
@@ -36,7 +36,10 @@ const toolsWithContent = tools.length > 0
36
36
  })
37
37
  );
38
38
 
39
- const localeContents = Object.fromEntries(localeEntries.filter(Boolean)) as Record<string, ToolLocaleContent<Record<string, string>>>;
39
+ const availableLocaleEntries = localeEntries.filter(
40
+ (localeEntry): localeEntry is readonly [KnownLocale, ToolLocaleContent<Record<string, string>>] => localeEntry !== null,
41
+ );
42
+ const localeContents = Object.fromEntries(availableLocaleEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
40
43
  const currentLocaleContent = localeContents[currentLocale] || localeContents.en;
41
44
  const availableLocales: Record<string, string> = {};
42
45
 
@@ -256,6 +259,6 @@ const toolsWithContent = tools.length > 0
256
259
  border-color: var(--accent);
257
260
  background: rgba(244, 63, 94, 0.1);
258
261
  }
259
- </style>
260
-
261
-
262
+ </style>
263
+
264
+
@@ -1,3 +1,3 @@
1
1
  ---
2
2
  ---
3
- <meta http-equiv="refresh" content="0;url=/en" />
3
+ <meta http-equiv="refresh" content="0;url=/en" />
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(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(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -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
+ });
@@ -98,7 +98,7 @@ describe('Diacritics density validation', () => {
98
98
  if (!loader) return;
99
99
 
100
100
  const content = await loader();
101
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
101
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
102
102
  const rule = DIACRITIC_RULES[typedLocale];
103
103
  const letters = letterCount(text);
104
104
  const matches = diacriticCount(text, typedLocale);
@@ -1,19 +1,19 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type * as DATA from '../data';
3
-
4
- const TOOLS: typeof DATA.scienceCategory[] = [];
5
-
6
- describe('FAQ Content Validation', () => {
7
- TOOLS.forEach((entry) => {
8
- describe(`Tool: ${entry.icon}`, () => {
9
- it('placeholder', () => {
10
- expect(true).toBe(true);
11
- });
12
- });
13
- });
14
-
15
- it('no tools registered yet', () => {
16
- expect(TOOLS.length).toBe(0);
17
- });
18
- });
19
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import type * as DATA from '../data';
3
+
4
+ const TOOLS: typeof DATA.scienceCategory[] = [];
5
+
6
+ describe('FAQ Content Validation', () => {
7
+ TOOLS.forEach((entry) => {
8
+ describe(`Tool: ${entry.icon}`, () => {
9
+ it('placeholder', () => {
10
+ expect(true).toBe(true);
11
+ });
12
+ });
13
+ });
14
+
15
+ it('no tools registered yet', () => {
16
+ expect(TOOLS.length).toBe(0);
17
+ });
18
+ });
19
+
@@ -1,36 +1,36 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
-
4
- const EXPECTED_LOCALES = [
5
- 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
- ];
7
-
8
- describe('I18n Coverage Validation', () => {
9
- it('all tools should be registered', () => {
10
- expect(ALL_TOOLS.length).toBeGreaterThan(0);
11
- });
12
-
13
- ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
- describe(`Tool: ${entry.id}`, () => {
15
- it('should have all 15 required locales', () => {
16
- const registeredLocales = Object.keys(entry.i18n);
17
- EXPECTED_LOCALES.forEach((locale) => {
18
- expect(
19
- registeredLocales,
20
- `Tool "${entry.id}" is missing locale "${locale}"`,
21
- ).toContain(locale);
22
- });
23
- });
24
-
25
- it('all locale loaders should be functions', () => {
26
- EXPECTED_LOCALES.forEach((locale) => {
27
- const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
- expect(
29
- typeof loader,
30
- `Tool "${entry.id}" locale "${locale}" loader is not a function`,
31
- ).toBe('function');
32
- });
33
- });
34
- });
35
- });
36
- });
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
+ ];
7
+
8
+ describe('I18n Coverage Validation', () => {
9
+ it('all tools should be registered', () => {
10
+ expect(ALL_TOOLS.length).toBeGreaterThan(0);
11
+ });
12
+
13
+ ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
+ describe(`Tool: ${entry.id}`, () => {
15
+ it('should have all 15 required locales', () => {
16
+ const registeredLocales = Object.keys(entry.i18n);
17
+ EXPECTED_LOCALES.forEach((locale) => {
18
+ expect(
19
+ registeredLocales,
20
+ `Tool "${entry.id}" is missing locale "${locale}"`,
21
+ ).toContain(locale);
22
+ });
23
+ });
24
+
25
+ it('all locale loaders should be functions', () => {
26
+ EXPECTED_LOCALES.forEach((locale) => {
27
+ const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
+ expect(
29
+ typeof loader,
30
+ `Tool "${entry.id}" locale "${locale}" loader is not a function`,
31
+ ).toBe('function');
32
+ });
33
+ });
34
+ });
35
+ });
36
+ });
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
60
60
 
61
61
  const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
62
  const content = await loader();
63
- const strings = translatableStrings(content as Record<string, unknown>);
63
+ const strings = translatableStrings(content as unknown as Record<string, unknown>);
64
64
  const missingQuestions = strings.flatMap((text) =>
65
65
  findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
66
  );
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 17 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(17);
21
+ it('all 18 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(18);
23
23
  });
24
24
  });
@@ -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
+