@jjlmoya/utils-forensic-science 1.17.0 → 1.18.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.
- package/package.json +17 -5
- package/scripts/postinstall.mjs +2 -4
- package/src/components/ProductionBreadcrumb.astro +134 -0
- package/src/components/ProductionStructuredData.astro +46 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/i18n/header-ui.ts +15 -0
- package/src/i18n/language-ui.ts +9 -0
- package/src/i18n/languages.ts +24 -0
- package/src/identity/brands.ts +5 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +218 -0
- package/src/layouts/ProductionUtilityPage.astro +283 -0
- package/src/mfe/assets.ts +39 -0
- package/src/mfe/category-ui.ts +34 -0
- package/src/mfe/manifest.ts +45 -0
- package/src/mfe/routes.ts +50 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
- package/src/pages/index.astro +2 -2
- package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/mfe_cache_contract.test.ts +11 -0
- package/src/tests/mfe_manifest.test.ts +28 -0
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/script_density.test.ts +93 -93
- package/src/types.ts +5 -7
- package/src/worker.ts +25 -0
- package/src/pages/[locale]/[slug].astro +0 -164
- package/src/pages/[locale].astro +0 -264
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { ALL_TOOLS, forensicCategory } from "../../../../../../index";
|
|
3
|
+
import type { CategoryLocaleContent, ToolLocaleContent } from "../../../../../../types";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../../../../../i18n/languages";
|
|
5
|
+
import { getCategoryNamespace, getUtilityNamespace, getUtilityPath } from "../../../../../../mfe/routes";
|
|
6
|
+
import { createUtilityManifestResponse, type UtilityManifestInput } from "../../../../../../mfe/manifest";
|
|
7
|
+
|
|
8
|
+
const loadCategories = async () => Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
|
|
9
|
+
language,
|
|
10
|
+
await forensicCategory.i18n[language]!(),
|
|
11
|
+
]))) as Record<Language, CategoryLocaleContent>;
|
|
12
|
+
|
|
13
|
+
const loadContents = async (entry: (typeof ALL_TOOLS)[number]["entry"]) => Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => {
|
|
14
|
+
const loader = entry.i18n[language] ?? entry.i18n.en;
|
|
15
|
+
if (!loader) throw new Error(`Missing ${language} locale for ${entry.id}`);
|
|
16
|
+
return [language, await loader()];
|
|
17
|
+
}))) as Record<Language, ToolLocaleContent>;
|
|
18
|
+
|
|
19
|
+
const createManifestPath = (locale: Exclude<Language, "es">, category: CategoryLocaleContent, content: ToolLocaleContent, englishContent: ToolLocaleContent) => ({
|
|
20
|
+
params: {
|
|
21
|
+
locale,
|
|
22
|
+
utilities: getUtilityNamespace(locale),
|
|
23
|
+
categories: getCategoryNamespace(locale),
|
|
24
|
+
category: category.slug,
|
|
25
|
+
slug: content.slug,
|
|
26
|
+
},
|
|
27
|
+
props: {
|
|
28
|
+
title: content.title,
|
|
29
|
+
description: content.description,
|
|
30
|
+
startUrl: getUtilityPath(locale, category.slug, content.slug),
|
|
31
|
+
englishSlug: englishContent.slug,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export async function getStaticPaths() {
|
|
36
|
+
const categories = await loadCategories();
|
|
37
|
+
const paths = [];
|
|
38
|
+
for (const { entry } of ALL_TOOLS) {
|
|
39
|
+
const contents = await loadContents(entry);
|
|
40
|
+
for (const locale of LANGUAGE_CODES.filter((candidate) => candidate !== "es")) {
|
|
41
|
+
const content = contents[locale];
|
|
42
|
+
const category = categories[locale];
|
|
43
|
+
paths.push(createManifestPath(locale, category, content, contents.en));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return paths;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const GET: APIRoute = ({ props }) => createUtilityManifestResponse(props as unknown as UtilityManifestInput);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionUtilityPage from "../../../../../layouts/ProductionUtilityPage.astro";
|
|
3
|
+
import { ALL_TOOLS, forensicCategory } from "../../../../../index";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../../../../i18n/languages";
|
|
5
|
+
import type { CategoryLocaleContent, ToolLocaleContent } from "../../../../../types";
|
|
6
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
7
|
+
import { getCategoryNamespace, getCategoryPath, getUtilityNamespace, getUtilityPath } from "../../../../../mfe/routes";
|
|
8
|
+
import { getUtilityOgImage } from "../../../../../mfe/assets";
|
|
9
|
+
|
|
10
|
+
type Loader<T> = () => Promise<{ default: T }>;
|
|
11
|
+
type LoadedTool = ToolLocaleContent;
|
|
12
|
+
|
|
13
|
+
export async function getStaticPaths() {
|
|
14
|
+
const paths = [];
|
|
15
|
+
for (const { entry, Component: componentLoader, SEOComponent: seoLoader, BibliographyComponent: bibliographyLoader } of ALL_TOOLS) {
|
|
16
|
+
const contents = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => {
|
|
17
|
+
const loader = entry.i18n[language] ?? entry.i18n.en;
|
|
18
|
+
if (!loader) throw new Error(`Missing ${language} locale for ${entry.id}`);
|
|
19
|
+
return [language, await loader()];
|
|
20
|
+
}))) as Record<Language, LoadedTool>;
|
|
21
|
+
const categories = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
|
|
22
|
+
language,
|
|
23
|
+
await forensicCategory.i18n[language]!(),
|
|
24
|
+
]))) as Record<Language, CategoryLocaleContent>;
|
|
25
|
+
const { default: Component } = await (componentLoader as Loader<AstroComponentFactory>)();
|
|
26
|
+
const { default: SEOComponent } = await (seoLoader as Loader<AstroComponentFactory>)();
|
|
27
|
+
const { default: BibliographyComponent } = await (bibliographyLoader as Loader<AstroComponentFactory>)();
|
|
28
|
+
for (const locale of LANGUAGE_CODES.filter((candidate) => candidate !== "es")) {
|
|
29
|
+
const content = contents[locale];
|
|
30
|
+
const category = categories[locale];
|
|
31
|
+
paths.push({
|
|
32
|
+
params: {
|
|
33
|
+
locale,
|
|
34
|
+
utilities: getUtilityNamespace(locale),
|
|
35
|
+
categories: getCategoryNamespace(locale),
|
|
36
|
+
category: category.slug,
|
|
37
|
+
slug: content.slug,
|
|
38
|
+
},
|
|
39
|
+
props: {
|
|
40
|
+
locale,
|
|
41
|
+
content,
|
|
42
|
+
englishSlug: contents.en.slug,
|
|
43
|
+
category,
|
|
44
|
+
categorySlug: category.slug,
|
|
45
|
+
Component,
|
|
46
|
+
SEOComponent,
|
|
47
|
+
BibliographyComponent,
|
|
48
|
+
alternates: LANGUAGE_CODES.map((language) => ({
|
|
49
|
+
language,
|
|
50
|
+
url: getUtilityPath(language, categories[language].slug, contents[language].slug),
|
|
51
|
+
})),
|
|
52
|
+
image: getUtilityOgImage(contents.en.slug),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return paths;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { locale, content, englishSlug, category, categorySlug, Component, SEOComponent, BibliographyComponent, relatedTools, alternates, image } = Astro.props as {
|
|
61
|
+
locale: Exclude<Language, "es">;
|
|
62
|
+
content: LoadedTool;
|
|
63
|
+
englishSlug: string;
|
|
64
|
+
category: CategoryLocaleContent;
|
|
65
|
+
categorySlug: string;
|
|
66
|
+
Component: AstroComponentFactory;
|
|
67
|
+
SEOComponent: AstroComponentFactory;
|
|
68
|
+
BibliographyComponent: AstroComponentFactory;
|
|
69
|
+
relatedTools?: { icon: string; title: string; description: string; href: string }[];
|
|
70
|
+
alternates: { language: Language; url: string }[];
|
|
71
|
+
image?: string;
|
|
72
|
+
};
|
|
73
|
+
const related = relatedTools ?? (await Promise.all(ALL_TOOLS.map(async ({ entry: relatedEntry }) => {
|
|
74
|
+
const loader = relatedEntry.i18n[locale] ?? relatedEntry.i18n.en;
|
|
75
|
+
if (!loader) return null;
|
|
76
|
+
const relatedContent = await loader();
|
|
77
|
+
if (relatedContent.slug === content.slug) return null;
|
|
78
|
+
return {
|
|
79
|
+
icon: relatedEntry.icons.fg,
|
|
80
|
+
title: relatedContent.title,
|
|
81
|
+
description: relatedContent.description,
|
|
82
|
+
href: getUtilityPath(locale, categorySlug, relatedContent.slug),
|
|
83
|
+
};
|
|
84
|
+
}))).filter((tool): tool is { icon: string; title: string; description: string; href: string } => tool !== null);
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
<ProductionUtilityPage
|
|
88
|
+
{locale}
|
|
89
|
+
{content}
|
|
90
|
+
{englishSlug}
|
|
91
|
+
categoryTitle={category.title}
|
|
92
|
+
{categorySlug}
|
|
93
|
+
categoryHref={getCategoryPath(locale, categorySlug)}
|
|
94
|
+
relatedTools={related}
|
|
95
|
+
{Component}
|
|
96
|
+
{SEOComponent}
|
|
97
|
+
{BibliographyComponent}
|
|
98
|
+
{alternates}
|
|
99
|
+
{image}
|
|
100
|
+
/>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionCategoryPage from "../../../../layouts/ProductionCategoryPage.astro";
|
|
3
|
+
import { ALL_TOOLS, forensicCategory } from "../../../../index";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../../../i18n/languages";
|
|
5
|
+
import { getCategoryNamespace, getCategoryPath, getUtilityNamespace, getUtilityPath } from "../../../../mfe/routes";
|
|
6
|
+
import { CATEGORY_OG_IMAGE } from "../../../../mfe/assets";
|
|
7
|
+
|
|
8
|
+
export async function getStaticPaths() {
|
|
9
|
+
const categories = Object.fromEntries(await Promise.all(
|
|
10
|
+
LANGUAGE_CODES.map(async (language) => [language, await forensicCategory.i18n[language]!()]),
|
|
11
|
+
));
|
|
12
|
+
return LANGUAGE_CODES.filter((language) => language !== "es").map((locale) => {
|
|
13
|
+
const category = categories[locale]!;
|
|
14
|
+
const alternates = LANGUAGE_CODES.map((language) => ({
|
|
15
|
+
language,
|
|
16
|
+
url: getCategoryPath(language, categories[language]!.slug),
|
|
17
|
+
}));
|
|
18
|
+
return {
|
|
19
|
+
params: { locale, utilities: getUtilityNamespace(locale), categories: getCategoryNamespace(locale), category: category.slug },
|
|
20
|
+
props: { locale, category, alternates },
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { locale, category, alternates } = Astro.props as {
|
|
26
|
+
locale: Exclude<Language, "es">;
|
|
27
|
+
category: Awaited<ReturnType<NonNullable<(typeof forensicCategory.i18n)[Language]>>>;
|
|
28
|
+
alternates: { language: Language; url: string }[];
|
|
29
|
+
};
|
|
30
|
+
const tools = await Promise.all(ALL_TOOLS.map(async ({ entry }) => {
|
|
31
|
+
const loader = entry.i18n[locale] ?? entry.i18n.en;
|
|
32
|
+
if (!loader) throw new Error(`Missing ${locale} locale for ${entry.id}`);
|
|
33
|
+
const content = await loader();
|
|
34
|
+
return {
|
|
35
|
+
id: entry.id,
|
|
36
|
+
icon: entry.icons.fg,
|
|
37
|
+
title: content.title,
|
|
38
|
+
description: content.description,
|
|
39
|
+
href: getUtilityPath(locale, category.slug, content.slug),
|
|
40
|
+
};
|
|
41
|
+
}));
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<ProductionCategoryPage locale={locale} {category} {tools} {alternates} image={CATEGORY_OG_IMAGE} />
|
package/src/pages/index.astro
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { ALL_TOOLS, forensicCategory } from "../../../index";
|
|
3
|
+
import type { ToolLocaleContent } from "../../../types";
|
|
4
|
+
import { getUtilityPath } from "../../../mfe/routes";
|
|
5
|
+
import { createUtilityManifestResponse, type UtilityManifestInput } from "../../../mfe/manifest";
|
|
6
|
+
|
|
7
|
+
export async function getStaticPaths() {
|
|
8
|
+
const category = await forensicCategory.i18n.es!();
|
|
9
|
+
const paths = [];
|
|
10
|
+
for (const { entry } of ALL_TOOLS) {
|
|
11
|
+
const loader = entry.i18n.es ?? entry.i18n.en;
|
|
12
|
+
if (!loader) continue;
|
|
13
|
+
const content = await loader() as ToolLocaleContent;
|
|
14
|
+
const englishContent = await (entry.i18n.en ?? loader)() as ToolLocaleContent;
|
|
15
|
+
paths.push({
|
|
16
|
+
params: { slug: content.slug },
|
|
17
|
+
props: {
|
|
18
|
+
title: content.title,
|
|
19
|
+
description: content.description,
|
|
20
|
+
startUrl: getUtilityPath("es", category.slug, content.slug),
|
|
21
|
+
englishSlug: englishContent.slug,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return paths;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const GET: APIRoute = ({ props }) => createUtilityManifestResponse(props as unknown as UtilityManifestInput);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionUtilityPage from "../../layouts/ProductionUtilityPage.astro";
|
|
3
|
+
import { ALL_TOOLS, forensicCategory } from "../../index";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../i18n/languages";
|
|
5
|
+
import type { CategoryLocaleContent, ToolLocaleContent } from "../../types";
|
|
6
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
7
|
+
import { getCategoryPath, getUtilityPath } from "../../mfe/routes";
|
|
8
|
+
import { getUtilityOgImage } from "../../mfe/assets";
|
|
9
|
+
|
|
10
|
+
type Loader<T> = () => Promise<{ default: T }>;
|
|
11
|
+
type LoadedTool = ToolLocaleContent;
|
|
12
|
+
|
|
13
|
+
export async function getStaticPaths() {
|
|
14
|
+
const category = await forensicCategory.i18n.es!();
|
|
15
|
+
const categories = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
|
|
16
|
+
language,
|
|
17
|
+
await forensicCategory.i18n[language]!(),
|
|
18
|
+
]))) as Record<Language, CategoryLocaleContent>;
|
|
19
|
+
const paths = [];
|
|
20
|
+
for (const { entry, Component: componentLoader, SEOComponent: seoLoader, BibliographyComponent: bibliographyLoader } of ALL_TOOLS) {
|
|
21
|
+
const contents = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => {
|
|
22
|
+
const loader = entry.i18n[language] ?? entry.i18n.en;
|
|
23
|
+
if (!loader) throw new Error(`Missing ${language} locale for ${entry.id}`);
|
|
24
|
+
return [language, await loader()];
|
|
25
|
+
}))) as Record<Language, LoadedTool>;
|
|
26
|
+
const { default: Component } = await (componentLoader as Loader<AstroComponentFactory>)();
|
|
27
|
+
const { default: SEOComponent } = await (seoLoader as Loader<AstroComponentFactory>)();
|
|
28
|
+
const { default: BibliographyComponent } = await (bibliographyLoader as Loader<AstroComponentFactory>)();
|
|
29
|
+
paths.push({
|
|
30
|
+
params: { slug: contents.es.slug },
|
|
31
|
+
props: {
|
|
32
|
+
locale: "es",
|
|
33
|
+
content: contents.es,
|
|
34
|
+
englishSlug: contents.en.slug,
|
|
35
|
+
category,
|
|
36
|
+
categorySlug: category.slug,
|
|
37
|
+
Component,
|
|
38
|
+
SEOComponent,
|
|
39
|
+
BibliographyComponent,
|
|
40
|
+
alternates: LANGUAGE_CODES.map((language) => ({
|
|
41
|
+
language,
|
|
42
|
+
url: getUtilityPath(language, categories[language].slug, contents[language].slug),
|
|
43
|
+
})),
|
|
44
|
+
image: getUtilityOgImage(contents.en.slug),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return paths;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { locale, content, englishSlug, category, categorySlug, Component, SEOComponent, BibliographyComponent, alternates, image } = Astro.props as {
|
|
52
|
+
locale: "es";
|
|
53
|
+
content: LoadedTool;
|
|
54
|
+
englishSlug: string;
|
|
55
|
+
category: CategoryLocaleContent;
|
|
56
|
+
categorySlug: string;
|
|
57
|
+
Component: AstroComponentFactory;
|
|
58
|
+
SEOComponent: AstroComponentFactory;
|
|
59
|
+
BibliographyComponent: AstroComponentFactory;
|
|
60
|
+
alternates: { language: Language; url: string }[];
|
|
61
|
+
image?: string;
|
|
62
|
+
};
|
|
63
|
+
const relatedTools = (await Promise.all(ALL_TOOLS.map(async ({ entry: relatedEntry }) => {
|
|
64
|
+
const loader = relatedEntry.i18n.es ?? relatedEntry.i18n.en;
|
|
65
|
+
if (!loader) return null;
|
|
66
|
+
const relatedContent = await loader();
|
|
67
|
+
if (relatedContent.slug === content.slug) return null;
|
|
68
|
+
return {
|
|
69
|
+
icon: relatedEntry.icons.fg,
|
|
70
|
+
title: relatedContent.title,
|
|
71
|
+
description: relatedContent.description,
|
|
72
|
+
href: getUtilityPath("es", categorySlug, relatedContent.slug),
|
|
73
|
+
};
|
|
74
|
+
}))).filter((tool): tool is { icon: string; title: string; description: string; href: string } => tool !== null);
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
<ProductionUtilityPage
|
|
78
|
+
{locale}
|
|
79
|
+
{content}
|
|
80
|
+
{englishSlug}
|
|
81
|
+
categoryTitle={category.title}
|
|
82
|
+
{categorySlug}
|
|
83
|
+
categoryHref={getCategoryPath(locale, categorySlug)}
|
|
84
|
+
{relatedTools}
|
|
85
|
+
{Component}
|
|
86
|
+
{SEOComponent}
|
|
87
|
+
{BibliographyComponent}
|
|
88
|
+
{alternates}
|
|
89
|
+
{image}
|
|
90
|
+
/>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionCategoryPage from "../../../layouts/ProductionCategoryPage.astro";
|
|
3
|
+
import { ALL_TOOLS, forensicCategory } from "../../../index";
|
|
4
|
+
import { LANGUAGE_CODES, type Language } from "../../../i18n/languages";
|
|
5
|
+
import { getCategoryPath, getUtilityPath } from "../../../mfe/routes";
|
|
6
|
+
import { CATEGORY_OG_IMAGE } from "../../../mfe/assets";
|
|
7
|
+
|
|
8
|
+
export async function getStaticPaths() {
|
|
9
|
+
const categories = Object.fromEntries(await Promise.all(
|
|
10
|
+
LANGUAGE_CODES.map(async (language) => [language, await forensicCategory.i18n[language]!()]),
|
|
11
|
+
));
|
|
12
|
+
const category = categories.es!;
|
|
13
|
+
const alternates = LANGUAGE_CODES.map((language) => ({
|
|
14
|
+
language,
|
|
15
|
+
url: getCategoryPath(language, categories[language]!.slug),
|
|
16
|
+
}));
|
|
17
|
+
return [{ params: { category: category.slug }, props: { category, alternates } }];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { category, alternates } = Astro.props as {
|
|
21
|
+
category: Awaited<ReturnType<NonNullable<(typeof forensicCategory.i18n)[Language]>>>;
|
|
22
|
+
alternates: { language: Language; url: string }[];
|
|
23
|
+
};
|
|
24
|
+
const tools = await Promise.all(ALL_TOOLS.map(async ({ entry }) => {
|
|
25
|
+
const loader = entry.i18n.es ?? entry.i18n.en;
|
|
26
|
+
if (!loader) throw new Error(`Missing Spanish locale for ${entry.id}`);
|
|
27
|
+
const content = await loader();
|
|
28
|
+
return {
|
|
29
|
+
id: entry.id,
|
|
30
|
+
icon: entry.icons.fg,
|
|
31
|
+
title: content.title,
|
|
32
|
+
description: content.description,
|
|
33
|
+
href: getUtilityPath("es", category.slug, content.slug),
|
|
34
|
+
};
|
|
35
|
+
}));
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<ProductionCategoryPage locale="es" {category} {tools} {alternates} image={CATEGORY_OG_IMAGE} />
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getCacheControl, LONG_LIVED_ASSET_CACHE } from "../worker";
|
|
3
|
+
|
|
4
|
+
describe("MFE cache contract", () => {
|
|
5
|
+
it("caches versioned assets and manifests for one year", () => {
|
|
6
|
+
expect(getCacheControl("/_utilities/forensic-science/images/tool.webp")).toBe(LONG_LIVED_ASSET_CACHE);
|
|
7
|
+
expect(getCacheControl("/_utilities/forensic-science/styles/tool.css")).toBe(LONG_LIVED_ASSET_CACHE);
|
|
8
|
+
expect(getCacheControl("/en/utilities/categories/forensic-science/tool/manifest.json")).toBe(LONG_LIVED_ASSET_CACHE);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createUtilityManifestResponse } from "../mfe/manifest";
|
|
3
|
+
|
|
4
|
+
describe("MFE utility manifest", () => {
|
|
5
|
+
it("returns a versioned installable manifest for the current utility", async () => {
|
|
6
|
+
const response = createUtilityManifestResponse({
|
|
7
|
+
title: "Card draw odds calculator",
|
|
8
|
+
description: "Calculate card draw probabilities.",
|
|
9
|
+
startUrl: "/en/utilities/categories/forensic-science/card-draw-odds-calculator/",
|
|
10
|
+
englishSlug: "card-draw-odds-calculator",
|
|
11
|
+
});
|
|
12
|
+
const manifest = await response.json() as {
|
|
13
|
+
name: string;
|
|
14
|
+
start_url: string;
|
|
15
|
+
scope: string;
|
|
16
|
+
icons: { src: string; sizes: string; type: string; purpose: string }[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
expect(response.headers.get("Content-Type")).toContain("application/manifest+json");
|
|
20
|
+
expect(response.headers.get("Cache-Control")).toContain("immutable");
|
|
21
|
+
expect(manifest.name).toBe("Card draw odds calculator");
|
|
22
|
+
expect(manifest.start_url).toBe(manifest.scope);
|
|
23
|
+
expect(manifest.icons[0]?.src).toContain("/_utilities/forensic-science/images/card-draw-odds-calculator.webp?version=");
|
|
24
|
+
expect(manifest.icons[0]?.sizes).toBe("512x512");
|
|
25
|
+
expect(manifest.icons[0]?.type).toBe("image/webp");
|
|
26
|
+
expect(manifest.icons[0]?.purpose).toBe("any");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { ALL_ENTRIES } from '../entries';
|
|
6
|
+
import { ALL_TOOLS } from '../tools';
|
|
7
|
+
import type { ToolDefinition } from '../types';
|
|
8
|
+
|
|
9
|
+
function isToolDefinition(value: unknown): value is ToolDefinition {
|
|
10
|
+
if (!value || typeof value !== 'object') return false;
|
|
11
|
+
|
|
12
|
+
const candidate = value as Record<string, unknown>;
|
|
13
|
+
return Boolean(
|
|
14
|
+
candidate.entry &&
|
|
15
|
+
typeof candidate.entry === 'object' &&
|
|
16
|
+
typeof candidate.Component === 'function' &&
|
|
17
|
+
typeof candidate.SEOComponent === 'function' &&
|
|
18
|
+
typeof candidate.BibliographyComponent === 'function',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolRoot = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const toolDirectories = readdirSync(toolRoot, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(toolRoot, entry.name, 'index.ts')))
|
|
25
|
+
.map((entry) => entry.name)
|
|
26
|
+
.sort();
|
|
27
|
+
|
|
28
|
+
describe('Library registry contract', () => {
|
|
29
|
+
it('keeps ALL_ENTRIES and ALL_TOOLS synchronized by id', () => {
|
|
30
|
+
const entryIds = ALL_ENTRIES.map((entry) => entry.id);
|
|
31
|
+
const toolIds = ALL_TOOLS.map((tool) => tool.entry.id);
|
|
32
|
+
|
|
33
|
+
expect(new Set(entryIds).size).toBe(entryIds.length);
|
|
34
|
+
expect(new Set(toolIds).size).toBe(toolIds.length);
|
|
35
|
+
expect([...toolIds].sort()).toEqual([...entryIds].sort());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('registers every tool runtime index in both public registries', async () => {
|
|
39
|
+
const failures: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const directory of toolDirectories) {
|
|
42
|
+
const runtimeModule = await import(
|
|
43
|
+
pathToFileURL(join(toolRoot, directory, 'index.ts')).href,
|
|
44
|
+
);
|
|
45
|
+
const definitions = [...new Set(Object.values(runtimeModule).filter(isToolDefinition))];
|
|
46
|
+
|
|
47
|
+
if (definitions.length !== 1) {
|
|
48
|
+
failures.push(`${directory}: expected exactly one ToolDefinition export, found ${definitions.length}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [definition] = definitions;
|
|
53
|
+
if (!definition) {
|
|
54
|
+
failures.push(`${directory}: runtime definition is undefined`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!ALL_TOOLS.some((tool) => tool.entry.id === definition.entry.id)) {
|
|
58
|
+
failures.push(`${directory}: runtime definition is missing from ALL_TOOLS`);
|
|
59
|
+
}
|
|
60
|
+
if (!ALL_ENTRIES.some((entry) => entry.id === definition.entry.id)) {
|
|
61
|
+
failures.push(`${directory}: runtime entry is missing from ALL_ENTRIES`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(failures).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
|
|
4
|
-
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
-
|
|
6
|
-
const SCRIPT_RULES = {
|
|
7
|
-
ja: {
|
|
8
|
-
language: 'Japanese',
|
|
9
|
-
scriptName: 'kana/kanji',
|
|
10
|
-
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
-
minScriptRatio: 0.45,
|
|
12
|
-
},
|
|
13
|
-
ko: {
|
|
14
|
-
language: 'Korean',
|
|
15
|
-
scriptName: 'hangul',
|
|
16
|
-
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
-
minScriptRatio: 0.55,
|
|
18
|
-
},
|
|
19
|
-
ru: {
|
|
20
|
-
language: 'Russian',
|
|
21
|
-
scriptName: 'cyrillic',
|
|
22
|
-
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
-
minScriptRatio: 0.65,
|
|
24
|
-
},
|
|
25
|
-
zh: {
|
|
26
|
-
language: 'Chinese',
|
|
27
|
-
scriptName: 'han',
|
|
28
|
-
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
-
minScriptRatio: 0.45,
|
|
30
|
-
},
|
|
31
|
-
} as const;
|
|
32
|
-
|
|
33
|
-
const LETTERS = /\p{L}/gu;
|
|
34
|
-
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
-
|
|
36
|
-
function collectStrings(value: unknown): string[] {
|
|
37
|
-
if (typeof value === 'string') return [value];
|
|
38
|
-
if (!value || typeof value !== 'object') return [];
|
|
39
|
-
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
-
return Object.values(value).flatMap(collectStrings);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeText(value: unknown): string {
|
|
44
|
-
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
-
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function letterCount(text: string): number {
|
|
52
|
-
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
-
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
-
const letters = letterCount(text);
|
|
61
|
-
if (letters === 0) return 0;
|
|
62
|
-
return scriptCount(text, locale) / letters;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe('Native script density validation', () => {
|
|
66
|
-
ALL_TOOLS.forEach((tool) => {
|
|
67
|
-
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
-
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
-
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
-
const typedLocale = locale as ScriptLocale;
|
|
71
|
-
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
-
if (!loader) return;
|
|
73
|
-
|
|
74
|
-
const content = await loader();
|
|
75
|
-
const rule = SCRIPT_RULES[typedLocale];
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
76
|
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
77
|
-
const letters = letterCount(text);
|
|
78
|
-
const matches = scriptCount(text, typedLocale);
|
|
79
|
-
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
-
|
|
81
|
-
expect(
|
|
82
|
-
ratio,
|
|
83
|
-
[
|
|
84
|
-
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
-
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
-
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
-
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
-
].join(' '),
|
|
89
|
-
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|