@jjlmoya/utils-science 1.53.0 → 1.55.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 (37) hide show
  1. package/package.json +18 -5
  2. package/scripts/postbuild.mjs +17 -0
  3. package/scripts/postinstall.mjs +2 -4
  4. package/src/components/ProductionBreadcrumb.astro +134 -0
  5. package/src/components/ProductionStructuredData.astro +46 -0
  6. package/src/components/ProductionWidget.astro +182 -0
  7. package/src/i18n/header-ui.ts +15 -0
  8. package/src/i18n/language-ui.ts +9 -0
  9. package/src/i18n/languages.ts +24 -0
  10. package/src/identity/brands.ts +5 -0
  11. package/src/layouts/ProductionCategoryPage.astro +184 -0
  12. package/src/layouts/ProductionPage.astro +218 -0
  13. package/src/layouts/ProductionUtilityPage.astro +283 -0
  14. package/src/mfe/assets.ts +39 -0
  15. package/src/mfe/category-ui.ts +34 -0
  16. package/src/mfe/manifest.ts +45 -0
  17. package/src/mfe/routes.ts +50 -0
  18. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  21. package/src/pages/index.astro +1 -2
  22. package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
  23. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  24. package/src/pages/utilidades/[slug].astro +90 -0
  25. package/src/pages/utilidades/categorias/[category].astro +38 -0
  26. package/src/tests/diacritics_density.test.ts +1 -1
  27. package/src/tests/inverted_punctuation.test.ts +1 -1
  28. package/src/tests/mfe_cache_contract.test.ts +14 -0
  29. package/src/tests/mfe_manifest.test.ts +28 -0
  30. package/src/tests/registry_contract.test.ts +67 -0
  31. package/src/tests/script_density.test.ts +1 -1
  32. package/src/tests/seo_translation_completeness.test.ts +3 -4
  33. package/src/tests/translation_copy.test.ts +2 -2
  34. package/src/types.ts +5 -7
  35. package/src/worker.ts +27 -0
  36. package/src/pages/[locale]/[slug].astro +0 -162
  37. package/src/pages/[locale].astro +0 -251
@@ -0,0 +1,39 @@
1
+ import packageMetadata from "../../package.json";
2
+ import type { Brand } from "../identity/brands";
3
+
4
+ const UTILITY_ASSET_ROOT = "/_utilities/science";
5
+
6
+ export const TABLETOP_ASSET_VERSION = encodeURIComponent(
7
+ import.meta.env.PUBLIC_APP_VERSION ?? packageMetadata.version,
8
+ );
9
+
10
+ const withAssetVersion = (path: string): string => `${path}?version=${TABLETOP_ASSET_VERSION}`;
11
+
12
+ export const getUtilityAssetPath = (assetName: string): string =>
13
+ withAssetVersion(`${UTILITY_ASSET_ROOT}/${assetName}`);
14
+
15
+ const BRAND_ASSETS = {
16
+ gamebob: {
17
+ favicon: "favicon.ico",
18
+ faviconSized: "favicon-48.webp",
19
+ appleTouchIcon: "apple-touch-icon-brand.webp",
20
+ },
21
+ jjlmoya: {
22
+ favicon: "favicon-jjlmoya.ico",
23
+ faviconSized: "favicon-jjlmoya-32.webp",
24
+ appleTouchIcon: "apple-touch-icon-jjlmoya.webp",
25
+ },
26
+ } as const satisfies Record<Brand, Record<"favicon" | "faviconSized" | "appleTouchIcon", string>>;
27
+
28
+ export const getBrandAssetPath = (
29
+ brand: Brand,
30
+ asset: keyof (typeof BRAND_ASSETS)[Brand],
31
+ ): string => getUtilityAssetPath(BRAND_ASSETS[brand][asset]);
32
+
33
+ export const getUtilityOgImage = (englishSlug: string): string =>
34
+ withAssetVersion(`/_utilities/science/images/${englishSlug}.webp`);
35
+
36
+ export const getUtilityCssPath = (englishSlug: string): string =>
37
+ withAssetVersion(`/_utilities/science/styles/${englishSlug}.css`);
38
+
39
+ export const CATEGORY_OG_IMAGE = getUtilityOgImage("science");
@@ -0,0 +1,34 @@
1
+ import type { KnownLocale } from '../types';
2
+
3
+ export interface CategoryUi {
4
+ useTool: string;
5
+ openTool: string;
6
+ relatedEyebrow: string;
7
+ moreToolsIn: string;
8
+ zoom: string;
9
+ zoomControls: string;
10
+ zoomOut: string;
11
+ resetZoom: string;
12
+ zoomIn: string;
13
+ breadcrumb: string;
14
+ }
15
+
16
+ export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
17
+ es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta', relatedEyebrow: 'Sigue explorando', moreToolsIn: 'Más herramientas en', zoom: 'Zoom', zoomControls: 'Controles de zoom', zoomOut: 'Reducir zoom', resetZoom: 'Restablecer zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Migas de pan' },
18
+ en: { useTool: 'Use tool', openTool: 'Open tool', relatedEyebrow: 'Keep exploring', moreToolsIn: 'More tools in', zoom: 'Zoom', zoomControls: 'Zoom controls', zoomOut: 'Zoom out', resetZoom: 'Reset zoom', zoomIn: 'Zoom in', breadcrumb: 'Breadcrumb' },
19
+ fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil", relatedEyebrow: 'Continuez à explorer', moreToolsIn: "Plus d'outils dans", zoom: 'Zoom', zoomControls: 'Commandes de zoom', zoomOut: 'Réduire le zoom', resetZoom: 'Réinitialiser le zoom', zoomIn: 'Augmenter le zoom', breadcrumb: 'Fil d’Ariane' },
20
+ de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen', relatedEyebrow: 'Mehr entdecken', moreToolsIn: 'Weitere Werkzeuge in', zoom: 'Zoom', zoomControls: 'Zoom-Steuerung', zoomOut: 'Verkleinern', resetZoom: 'Zoom zurücksetzen', zoomIn: 'Vergrößern', breadcrumb: 'Brotkrümelnavigation' },
21
+ it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento', relatedEyebrow: 'Continua a esplorare', moreToolsIn: 'Altri strumenti in', zoom: 'Zoom', zoomControls: 'Controlli zoom', zoomOut: 'Riduci zoom', resetZoom: 'Reimposta zoom', zoomIn: 'Aumenta zoom', breadcrumb: 'Breadcrumb' },
22
+ pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta', relatedEyebrow: 'Continue a explorar', moreToolsIn: 'Mais ferramentas em', zoom: 'Zoom', zoomControls: 'Controlos de zoom', zoomOut: 'Reduzir zoom', resetZoom: 'Repor zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Trilho de navegação' },
23
+ nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen', relatedEyebrow: 'Blijf ontdekken', moreToolsIn: 'Meer tools in', zoom: 'Zoom', zoomControls: 'Zoomknoppen', zoomOut: 'Uitzoomen', resetZoom: 'Zoom herstellen', zoomIn: 'Inzoomen', breadcrumb: 'Broodkruimelnavigatie' },
24
+ sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget', relatedEyebrow: 'Utforska mer', moreToolsIn: 'Fler verktyg i', zoom: 'Zoom', zoomControls: 'Zoomkontroller', zoomOut: 'Zooma ut', resetZoom: 'Återställ zoom', zoomIn: 'Zooma in', breadcrumb: 'Brödsmulenavigering' },
25
+ pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie', relatedEyebrow: 'Odkrywaj dalej', moreToolsIn: 'Więcej narzędzi w', zoom: 'Powiększenie', zoomControls: 'Sterowanie powiększeniem', zoomOut: 'Pomniejsz', resetZoom: 'Resetuj powiększenie', zoomIn: 'Powiększ', breadcrumb: 'Okruszki nawigacji' },
26
+ id: { useTool: 'Gunakan alat', openTool: 'Buka alat', relatedEyebrow: 'Jelajahi lebih lanjut', moreToolsIn: 'Alat lainnya di', zoom: 'Zoom', zoomControls: 'Kontrol zoom', zoomOut: 'Perkecil', resetZoom: 'Atur ulang zoom', zoomIn: 'Perbesar', breadcrumb: 'Breadcrumb' },
27
+ tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç', relatedEyebrow: 'Keşfetmeye devam edin', moreToolsIn: 'Diğer araçlar:', zoom: 'Yakınlaştırma', zoomControls: 'Yakınlaştırma denetimleri', zoomOut: 'Uzaklaştır', resetZoom: 'Yakınlaştırmayı sıfırla', zoomIn: 'Yakınlaştır', breadcrumb: 'İçerik haritası' },
28
+ ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент', relatedEyebrow: 'Продолжить изучение', moreToolsIn: 'Другие инструменты в', zoom: 'Масштаб', zoomControls: 'Управление масштабом', zoomOut: 'Уменьшить', resetZoom: 'Сбросить масштаб', zoomIn: 'Увеличить', breadcrumb: 'Навигационная цепочка' },
29
+ ja: { useTool: 'ツールを使う', openTool: 'ツールを開く', relatedEyebrow: 'さらに探索', moreToolsIn: 'その他のツール:', zoom: 'ズーム', zoomControls: 'ズーム操作', zoomOut: '縮小', resetZoom: 'ズームをリセット', zoomIn: '拡大', breadcrumb: 'パンくずリスト' },
30
+ ko: { useTool: '도구 사용', openTool: '도구 열기', relatedEyebrow: '더 탐색하기', moreToolsIn: '카테고리의 다른 도구:', zoom: '확대/축소', zoomControls: '확대/축소 컨트롤', zoomOut: '축소', resetZoom: '확대/축소 초기화', zoomIn: '확대', breadcrumb: '탐색 경로' },
31
+ zh: { useTool: '使用工具', openTool: '打开工具', relatedEyebrow: '继续探索', moreToolsIn: '更多工具:', zoom: '缩放', zoomControls: '缩放控件', zoomOut: '缩小', resetZoom: '重置缩放', zoomIn: '放大', breadcrumb: '面包屑导航' },
32
+ };
33
+
34
+ export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
@@ -0,0 +1,45 @@
1
+ import { getUtilityOgImage } from "./assets";
2
+
3
+ export interface UtilityManifestInput {
4
+ title: string;
5
+ description: string;
6
+ startUrl: string;
7
+ englishSlug: string;
8
+ }
9
+
10
+ const buildUtilityManifest = ({
11
+ title,
12
+ description,
13
+ startUrl,
14
+ englishSlug,
15
+ }: UtilityManifestInput) => {
16
+ const shortName = title.length > 12
17
+ ? title.split(/\s+/).map((word) => word[0]).join("")
18
+ : title;
19
+ const manifest = {
20
+ name: title,
21
+ short_name: shortName,
22
+ description,
23
+ start_url: startUrl,
24
+ scope: startUrl,
25
+ icons: [{
26
+ src: getUtilityOgImage(englishSlug),
27
+ sizes: "512x512",
28
+ type: "image/webp",
29
+ purpose: "any",
30
+ }],
31
+ theme_color: "#0f172a",
32
+ background_color: "#0f172a",
33
+ display: "standalone",
34
+ orientation: "portrait",
35
+ };
36
+
37
+ return manifest;
38
+ };
39
+
40
+ export const createUtilityManifestResponse = (input: UtilityManifestInput): Response => new Response(JSON.stringify(buildUtilityManifest(input), null, 2), {
41
+ headers: {
42
+ "Content-Type": "application/manifest+json",
43
+ "Cache-Control": "public, max-age=31536000, immutable",
44
+ },
45
+ });
@@ -0,0 +1,50 @@
1
+ import {
2
+ getCategoryNamespace,
3
+ getCategoryPath as getSharedCategoryPath,
4
+ getUtilitiesPath as getSharedUtilitiesPath,
5
+ getUtilityNamespace,
6
+ getUtilityPath as getSharedUtilityPath,
7
+ type UtilityLocale,
8
+ } from "@jjlmoya/utils-shared/routing";
9
+
10
+ export type { UtilityLocale };
11
+ export { getCategoryNamespace, getUtilityNamespace };
12
+
13
+ export const INTERNAL_LOCALES = [
14
+ "en", "fr", "de", "it", "pt", "nl", "sv", "pl", "id", "tr", "ru", "ja", "ko", "zh",
15
+ ] as const satisfies readonly UtilityLocale[];
16
+
17
+ const getDevelopmentPath = (url: string): string => {
18
+ if (!import.meta.env.DEV) return url;
19
+ try {
20
+ return new URL(url, "http://localhost:4324").pathname;
21
+ } catch {
22
+ return url;
23
+ }
24
+ };
25
+
26
+ export const getUtilitiesPath = (locale: UtilityLocale): string =>
27
+ getDevelopmentPath(getSharedUtilitiesPath(locale));
28
+
29
+ export const getCategoryPath = (locale: UtilityLocale, categorySlug: string): string =>
30
+ getDevelopmentPath(getSharedCategoryPath(locale, categorySlug));
31
+
32
+ export const getUtilityPath = (
33
+ locale: UtilityLocale,
34
+ categorySlug: string,
35
+ toolSlug: string,
36
+ ): string => getDevelopmentPath(getSharedUtilityPath(locale, categorySlug, toolSlug));
37
+
38
+ export const getCategoryRoute = (locale: UtilityLocale, categorySlug: string): string =>
39
+ getCategoryPath(locale, categorySlug);
40
+
41
+ export const getUtilityRoute = (locale: UtilityLocale, categorySlug: string, toolSlug: string): string =>
42
+ getUtilityPath(locale, categorySlug, toolSlug);
43
+
44
+ export const getUtilityIndexRoute = (locale: UtilityLocale): string => getUtilitiesPath(locale);
45
+
46
+ export const getInternalCategoryRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string): string =>
47
+ getCategoryPath(locale, categorySlug);
48
+
49
+ export const getInternalUtilityRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string, toolSlug: string): string =>
50
+ getUtilityPath(locale, categorySlug, toolSlug);
@@ -0,0 +1,49 @@
1
+ import type { APIRoute } from "astro";
2
+ import { ALL_TOOLS, scienceCategory } 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 scienceCategory.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, scienceCategory } 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 scienceCategory.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, scienceCategory } 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 scienceCategory.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 scienceCategory.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} />
@@ -1,4 +1,3 @@
1
1
  ---
2
2
  ---
3
- <meta http-equiv="refresh" content="0;url=/es" />
4
-
3
+ <meta http-equiv="refresh" content="0;url=/utilidades/categorias/ciencia/" />
@@ -0,0 +1,75 @@
1
+ import type { APIRoute } from "astro";
2
+ import { scienceCategory } from "../../../../category";
3
+ import { ALL_ENTRIES } from "../../../../entries";
4
+ import { LANGUAGE_CODES, type Language } from "../../../../i18n/languages";
5
+ import { getCategoryPath, getUtilityPath } from "../../../../mfe/routes";
6
+
7
+ const GAMEBOB_URL = "https://www.gamebob.dev";
8
+ const JJLMOYA_URL = "https://www.jjlmoya.es";
9
+
10
+ type LocalizedEntry = {
11
+ i18n: Partial<Record<Language, () => Promise<{ slug: string }>>>;
12
+ };
13
+
14
+ const absoluteUrl = (locale: Language, path: string): string =>
15
+ path.startsWith("http") ? path : `${locale === "es" ? JJLMOYA_URL : GAMEBOB_URL}${path}`;
16
+
17
+ const escapeXml = (value: string): string => value
18
+ .replaceAll("&", "&amp;")
19
+ .replaceAll("\"", "&quot;")
20
+ .replaceAll("'", "&apos;")
21
+ .replaceAll("<", "&lt;")
22
+ .replaceAll(">", "&gt;");
23
+
24
+ const getContent = async (entry: LocalizedEntry, locale: Language): Promise<{ slug: string }> => {
25
+ const loader = entry.i18n[locale] ?? entry.i18n.en;
26
+ if (!loader) throw new Error(`Missing ${locale} and English fallback`);
27
+ return loader();
28
+ };
29
+
30
+ export const prerender = true;
31
+
32
+ export function getStaticPaths() {
33
+ return LANGUAGE_CODES.map((locale) => ({
34
+ params: { locale, vertical: "science" },
35
+ props: { locale },
36
+ }));
37
+ }
38
+
39
+ const buildUrlEntry = async (locale: Language, path: string, index: number): Promise<string> => {
40
+ const links = await Promise.all(LANGUAGE_CODES.map(async (alternateLocale) => {
41
+ const alternateCategory = await getContent(scienceCategory, alternateLocale);
42
+ const alternateContent = index === 0 ? null : await getContent(ALL_ENTRIES[index - 1]!, alternateLocale);
43
+ const alternatePath = alternateContent
44
+ ? getUtilityPath(alternateLocale, alternateCategory.slug, alternateContent.slug)
45
+ : getCategoryPath(alternateLocale, alternateCategory.slug);
46
+ return ` <xhtml:link rel="alternate" hreflang="${alternateLocale}" href="${escapeXml(absoluteUrl(alternateLocale, alternatePath))}"/>`;
47
+ }));
48
+ return [
49
+ " <url>",
50
+ ` <loc>${escapeXml(absoluteUrl(locale, path))}</loc>`,
51
+ ...links,
52
+ ` <changefreq>${index === 0 ? "weekly" : "monthly"}</changefreq>`,
53
+ ` <priority>${index === 0 ? "0.7" : "0.6"}</priority>`,
54
+ " </url>",
55
+ ].join("\n");
56
+ };
57
+
58
+ export const GET: APIRoute = async ({ props }) => {
59
+ const locale = props.locale as Language;
60
+ const category = await getContent(scienceCategory, locale);
61
+ const contents = await Promise.all(ALL_ENTRIES.map((entry) => getContent(entry, locale)));
62
+ const paths = [
63
+ getCategoryPath(locale, category.slug),
64
+ ...contents.map((content) => getUtilityPath(locale, category.slug, content.slug)),
65
+ ];
66
+ const urls = await Promise.all(paths.map((path, index) => buildUrlEntry(locale, path, index)));
67
+
68
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls.join("\n")}\n</urlset>`;
69
+ return new Response(xml, {
70
+ headers: {
71
+ "Content-Type": "application/xml; charset=utf-8",
72
+ "Cache-Control": "public, max-age=3600, s-maxage=3600, must-revalidate",
73
+ },
74
+ });
75
+ };
@@ -0,0 +1,28 @@
1
+ import type { APIRoute } from "astro";
2
+ import { ALL_TOOLS, scienceCategory } 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 scienceCategory.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, scienceCategory } 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 scienceCategory.i18n.es!();
15
+ const categories = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
16
+ language,
17
+ await scienceCategory.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, scienceCategory } 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 scienceCategory.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 scienceCategory.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} />
@@ -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);
@@ -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
  );
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getCacheControl, LONG_LIVED_ASSET_CACHE, SITEMAP_CACHE } from "../worker";
3
+
4
+ describe("MFE cache contract", () => {
5
+ it("caches versioned assets and manifests for one year", () => {
6
+ expect(getCacheControl("/_utilities/science/images/tool.webp")).toBe(LONG_LIVED_ASSET_CACHE);
7
+ expect(getCacheControl("/_utilities/science/styles/tool.css")).toBe(LONG_LIVED_ASSET_CACHE);
8
+ expect(getCacheControl("/en/utilities/categories/science/tool/manifest.json")).toBe(LONG_LIVED_ASSET_CACHE);
9
+ });
10
+
11
+ it("keeps sitemaps refreshable", () => {
12
+ expect(getCacheControl("/_utilities/en/science/sitemap.xml")).toBe(SITEMAP_CACHE);
13
+ });
14
+ });