@jjlmoya/utils-tabletop 1.34.0 → 1.37.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 +18 -4
- package/scripts/postbuild.mjs +17 -0
- package/scripts/postinstall.mjs +2 -4
- package/src/category/index.ts +3 -1
- package/src/components/ProductionBreadcrumb.astro +132 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/entries.ts +2 -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/index.ts +1 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +209 -0
- package/src/layouts/ProductionUtilityPage.astro +237 -0
- package/src/mfe/assets.ts +15 -0
- package/src/mfe/category-ui.ts +26 -0
- package/src/mfe/routes.ts +50 -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 +1 -1
- package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/category_ui.test.ts +16 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/mfe_assets_contract.test.ts +44 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +46 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +100 -0
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/routing_contract.test.ts +41 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tests/translation_copy.test.ts +0 -1
- package/src/tool/board-game-timer/component.astro +0 -1
- package/src/tool/board-game-timer/i18n/en.ts +1 -2
- package/src/tool/board-game-timer/i18n/es.ts +2 -2
- package/src/tool/card-draw-odds-calculator/bibliography.astro +16 -0
- package/src/tool/card-draw-odds-calculator/bibliography.ts +7 -0
- package/src/tool/card-draw-odds-calculator/card-draw-odds-calculator.css +382 -0
- package/src/tool/card-draw-odds-calculator/component.astro +88 -0
- package/src/tool/card-draw-odds-calculator/controller.ts +118 -0
- package/src/tool/card-draw-odds-calculator/dom-views.ts +90 -0
- package/src/tool/card-draw-odds-calculator/entry.ts +26 -0
- package/src/tool/card-draw-odds-calculator/evaluator.ts +30 -0
- package/src/tool/card-draw-odds-calculator/i18n/de.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/en.ts +76 -0
- package/src/tool/card-draw-odds-calculator/i18n/es.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/fr.ts +48 -0
- package/src/tool/card-draw-odds-calculator/i18n/id.ts +27 -0
- package/src/tool/card-draw-odds-calculator/i18n/it.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ja.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ko.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/nl.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/pl.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/pt.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/ru.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/sv.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/tr.ts +15 -0
- package/src/tool/card-draw-odds-calculator/i18n/zh.ts +15 -0
- package/src/tool/card-draw-odds-calculator/index.ts +11 -0
- package/src/tool/card-draw-odds-calculator/logic.test.ts +50 -0
- package/src/tool/card-draw-odds-calculator/logic.ts +100 -0
- package/src/tool/card-draw-odds-calculator/seo.astro +16 -0
- package/src/tool/card-draw-odds-calculator/storage.ts +22 -0
- package/src/tool/card-draw-odds-calculator/ui.ts +37 -0
- package/src/tool/dice-roller-simulator/i18n/es.ts +3 -3
- package/src/tool/dungeon-map-generator/component.astro +16 -12
- package/src/tool/dungeon-map-generator/dom-views.ts +5 -4
- package/src/tool/dungeon-map-generator/i18n/es.ts +2 -2
- package/src/tool/encounter-difficulty-calculator/dom-views.ts +8 -1
- package/src/tool/fantasy-runes-translator/component.astro +0 -1
- package/src/tool/fantasy-runes-translator/logic.test.ts +32 -0
- package/src/tool/first-player-selector/component.astro +0 -1
- package/src/tool/first-player-selector/i18n/en.ts +1 -1
- package/src/tool/first-player-selector/i18n/es.ts +3 -3
- package/src/tool/hidden-role-dealer/component.astro +0 -1
- package/src/tool/lunar-tide-tracker/component.astro +0 -1
- package/src/tool/mus-scoreboard/component.astro +3 -4
- package/src/tool/mus-scoreboard/controller.ts +2 -1
- package/src/tool/mus-scoreboard/dom-views.ts +6 -1
- package/src/tool/rpg-initiative-tracker/component.astro +0 -1
- package/src/tool/rpg-settlement-exploration-map-generator/component.astro +14 -4
- package/src/tool/rpg-settlement-exploration-map-generator/editor.ts +38 -6
- package/src/tool/scatter-direction-selector/component.astro +0 -1
- package/src/tool/score-tracker/component.astro +0 -1
- package/src/tool/token-stamp-studio/component.astro +0 -1
- package/src/tools.ts +2 -0
- package/src/types.ts +6 -8
- package/src/worker.ts +26 -0
- package/src/pages/[locale]/[slug].astro +0 -162
- package/src/pages/[locale].astro +0 -251
|
@@ -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,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionUtilityPage from "../../../../../layouts/ProductionUtilityPage.astro";
|
|
3
|
+
import { ALL_TOOLS, tabletopCategory } 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 tabletopCategory.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?: { 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_ENTRIES, tabletopCategory } 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 tabletopCategory.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 tabletopCategory.i18n)[Language]>>>;
|
|
28
|
+
alternates: { language: Language; url: string }[];
|
|
29
|
+
};
|
|
30
|
+
const tools = await Promise.all(ALL_ENTRIES.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,75 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { tabletopCategory } 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("&", "&")
|
|
19
|
+
.replaceAll("\"", """)
|
|
20
|
+
.replaceAll("'", "'")
|
|
21
|
+
.replaceAll("<", "<")
|
|
22
|
+
.replaceAll(">", ">");
|
|
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: "tabletop" },
|
|
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(tabletopCategory, 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(tabletopCategory, 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,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ProductionUtilityPage from "../../layouts/ProductionUtilityPage.astro";
|
|
3
|
+
import { ALL_TOOLS, tabletopCategory } 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 tabletopCategory.i18n.es!();
|
|
15
|
+
const categories = Object.fromEntries(await Promise.all(LANGUAGE_CODES.map(async (language) => [
|
|
16
|
+
language,
|
|
17
|
+
await tabletopCategory.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_ENTRIES, tabletopCategory } 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 tabletopCategory.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 tabletopCategory.i18n)[Language]>>>;
|
|
22
|
+
alternates: { language: Language; url: string }[];
|
|
23
|
+
};
|
|
24
|
+
const tools = await Promise.all(ALL_ENTRIES.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,16 @@
|
|
|
1
|
+
import { UTILITY_LOCALES } from '@jjlmoya/utils-shared/routing';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { CATEGORY_UI, getCategoryUi } from '../mfe/category-ui';
|
|
4
|
+
|
|
5
|
+
describe('category UI translations', () => {
|
|
6
|
+
it('provides a translated use-tool CTA for every supported locale', () => {
|
|
7
|
+
for (const locale of UTILITY_LOCALES) {
|
|
8
|
+
expect(getCategoryUi(locale).useTool.trim()).not.toBe('');
|
|
9
|
+
expect(getCategoryUi(locale).openTool.trim()).not.toBe('');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
expect(Object.keys(CATEGORY_UI).sort()).toEqual([...UTILITY_LOCALES].sort());
|
|
13
|
+
expect(UTILITY_LOCALES.filter((locale) => locale !== 'en').every((locale) => getCategoryUi(locale).useTool !== 'Use tool')).toBe(true);
|
|
14
|
+
expect(UTILITY_LOCALES.filter((locale) => locale !== 'en').every((locale) => getCategoryUi(locale).openTool !== 'Open tool')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import {
|
|
6
|
+
CATEGORY_OG_IMAGE,
|
|
7
|
+
getUtilityCssPath,
|
|
8
|
+
getUtilityOgImage,
|
|
9
|
+
TABLETOP_ASSET_VERSION,
|
|
10
|
+
} from '../mfe/assets';
|
|
11
|
+
|
|
12
|
+
const assetRoot = join(process.cwd(), 'public', '_utilities', 'tabletop', 'images');
|
|
13
|
+
|
|
14
|
+
describe('MFE asset contract', () => {
|
|
15
|
+
it('keeps one English OG asset per category and tool', async () => {
|
|
16
|
+
const expectedSlugs = new Set(['tabletop']);
|
|
17
|
+
|
|
18
|
+
for (const { entry } of ALL_TOOLS) {
|
|
19
|
+
const loader = entry.i18n.en;
|
|
20
|
+
if (!loader) throw new Error(`Missing English locale for ${entry.id}`);
|
|
21
|
+
expectedSlugs.add((await loader()).slug);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const actualSlugs = new Set(
|
|
25
|
+
readdirSync(assetRoot)
|
|
26
|
+
.filter((filename) => filename.endsWith('.webp'))
|
|
27
|
+
.map((filename) => filename.slice(0, -'.webp'.length)),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(actualSlugs).toEqual(expectedSlugs);
|
|
31
|
+
for (const slug of expectedSlugs) {
|
|
32
|
+
expect(existsSync(join(assetRoot, `${slug}.webp`))).toBe(true);
|
|
33
|
+
expect(getUtilityOgImage(slug)).toBe(
|
|
34
|
+
`/_utilities/tabletop/images/${slug}.webp?version=${TABLETOP_ASSET_VERSION}`,
|
|
35
|
+
);
|
|
36
|
+
expect(getUtilityCssPath(slug)).toBe(
|
|
37
|
+
`/_utilities/tabletop/styles/${slug}.css?version=${TABLETOP_ASSET_VERSION}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
expect(CATEGORY_OG_IMAGE).toBe(
|
|
41
|
+
`/_utilities/tabletop/images/tabletop.webp?version=${TABLETOP_ASSET_VERSION}`,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const repositoryRoot = process.cwd();
|
|
5
|
+
export const toolRoot = join(repositoryRoot, 'src', 'tool');
|
|
6
|
+
|
|
7
|
+
export function listToolDirectories(): string[] {
|
|
8
|
+
return readdirSync(toolRoot)
|
|
9
|
+
.map((name) => join(toolRoot, name))
|
|
10
|
+
.filter((path) => statSync(path).isDirectory());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
17
|
+
const path = join(directory, entry.name);
|
|
18
|
+
if (entry.isDirectory()) files.push(...findFiles(path, extensions));
|
|
19
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function read(path: string): string {
|
|
26
|
+
return readFileSync(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function displayPath(path: string): string {
|
|
30
|
+
return relative(repositoryRoot, path).replace(/\\/g, '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
interface LinkFailure {
|
|
6
|
+
tool: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseUrl(tool: string, rawUrl: string, failures: LinkFailure[]): URL | undefined {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(rawUrl);
|
|
13
|
+
} catch {
|
|
14
|
+
failures.push({ tool, message: `invalid URL: ${rawUrl}` });
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function checkUrl(tool: string, url: URL, seen: Set<string>, failures: LinkFailure[]): void {
|
|
20
|
+
if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${url.href}` });
|
|
21
|
+
if (url.pathname === '/' && !url.search && !url.hash) failures.push({ tool, message: `generic homepage, cite the exact document: ${url.href}` });
|
|
22
|
+
if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${url.href}` });
|
|
23
|
+
seen.add(url.href);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function checkTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
|
|
27
|
+
const failures: LinkFailure[] = [];
|
|
28
|
+
const loader = tool.entry.i18n.en;
|
|
29
|
+
if (!loader) return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
|
|
30
|
+
|
|
31
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
for (const entry of content.bibliography ?? []) {
|
|
34
|
+
const url = parseUrl(tool.entry.id, entry.url, failures);
|
|
35
|
+
if (url) checkUrl(tool.entry.id, url, seen, failures);
|
|
36
|
+
}
|
|
37
|
+
return failures;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('QA: bibliography links are specific and usable', () => {
|
|
41
|
+
it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
|
|
42
|
+
const failures = (await Promise.all(ALL_TOOLS.map(checkTool))).flat();
|
|
43
|
+
const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
|
|
44
|
+
expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { displayPath, listToolDirectories, read } from './qa-test-helpers';
|
|
5
|
+
|
|
6
|
+
const evidenceClaims = [
|
|
7
|
+
/\bofficial algorithm\b/i,
|
|
8
|
+
/\balgoritmo oficial\b/i,
|
|
9
|
+
/\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
|
|
10
|
+
/\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
|
|
11
|
+
/\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
|
|
12
|
+
/\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
|
|
13
|
+
/\b(?:guarantees|ensures)\b/i,
|
|
14
|
+
/\bgarantiza\b/i,
|
|
15
|
+
/\bofficial (?:data|rate|calculation)\b/i,
|
|
16
|
+
/\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const requiredEvidenceFields = [
|
|
20
|
+
'reviewedAt',
|
|
21
|
+
'methodology',
|
|
22
|
+
'sources',
|
|
23
|
+
'referenceCases',
|
|
24
|
+
'limitations',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('QA: strong factual claims are traceable', () => {
|
|
28
|
+
it('requires machine-readable validation evidence beside tools making strong claims', () => {
|
|
29
|
+
const failures: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const directory of listToolDirectories()) {
|
|
32
|
+
const localePaths = ['en', 'es']
|
|
33
|
+
.map((locale) => join(directory, 'i18n', `${locale}.ts`))
|
|
34
|
+
.filter(existsSync);
|
|
35
|
+
const claims = localePaths.flatMap((path) => {
|
|
36
|
+
const source = read(path);
|
|
37
|
+
return evidenceClaims
|
|
38
|
+
.filter((pattern) => pattern.test(source))
|
|
39
|
+
.map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
|
|
40
|
+
});
|
|
41
|
+
if (claims.length === 0) continue;
|
|
42
|
+
|
|
43
|
+
const evidencePath = join(directory, 'validation.ts');
|
|
44
|
+
if (!existsSync(evidencePath)) {
|
|
45
|
+
failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const evidence = read(evidencePath);
|
|
50
|
+
const missingFields = requiredEvidenceFields.filter(
|
|
51
|
+
(field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
|
|
52
|
+
);
|
|
53
|
+
if (missingFields.length > 0) {
|
|
54
|
+
failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
failures,
|
|
60
|
+
[
|
|
61
|
+
'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
|
|
62
|
+
'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
|
|
63
|
+
'or weaken/remove the unsupported claim.',
|
|
64
|
+
...failures,
|
|
65
|
+
].join('\n'),
|
|
66
|
+
).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|