@jjlmoya/utils-forensic-science 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/mfe/widget-height.ts +14 -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
|
@@ -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
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import type { SEOSection } from '@jjlmoya/utils-shared';
|
|
2
|
+
import type { UtilityLocale } from '@jjlmoya/utils-shared/routing';
|
|
2
3
|
import type { WithContext, Thing } from 'schema-dts';
|
|
3
4
|
|
|
4
5
|
export type { SEOSection };
|
|
5
6
|
|
|
6
|
-
export type KnownLocale =
|
|
7
|
-
| 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
|
|
8
|
-
| 'fr' | 'id' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
|
|
9
|
-
| 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
|
|
7
|
+
export type KnownLocale = UtilityLocale;
|
|
10
8
|
|
|
11
9
|
export interface FAQItem {
|
|
12
10
|
question: string;
|
|
@@ -23,7 +21,7 @@ export interface HowToStep {
|
|
|
23
21
|
text: string;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
export interface ToolLocaleContent<TUI extends
|
|
24
|
+
export interface ToolLocaleContent<TUI extends object = Record<string, string>> {
|
|
27
25
|
slug: string;
|
|
28
26
|
title: string;
|
|
29
27
|
description: string;
|
|
@@ -46,7 +44,7 @@ export type LocaleLoader<T> = () => Promise<T>;
|
|
|
46
44
|
|
|
47
45
|
export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
|
|
48
46
|
|
|
49
|
-
export interface ScienceToolEntry<TUI extends
|
|
47
|
+
export interface ScienceToolEntry<TUI extends object = Record<string, string>> {
|
|
50
48
|
id: string;
|
|
51
49
|
icons: {
|
|
52
50
|
bg: string;
|
|
@@ -67,4 +65,4 @@ export interface ToolDefinition {
|
|
|
67
65
|
SEOComponent: (_props: Record<string, unknown>) => unknown;
|
|
68
66
|
BibliographyComponent: (_props: Record<string, unknown>) => unknown;
|
|
69
67
|
}
|
|
70
|
-
|
|
68
|
+
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface UtilityMfeEnvironment {
|
|
2
|
+
ASSETS: { fetch(request: Request): Promise<Response> };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const LONG_LIVED_ASSET_CACHE = "public, max-age=31536000, immutable";
|
|
6
|
+
|
|
7
|
+
export const getCacheControl = (pathname: string): string | undefined => {
|
|
8
|
+
if (pathname.endsWith("/manifest.json")) return LONG_LIVED_ASSET_CACHE;
|
|
9
|
+
if (pathname.startsWith("/_utilities/")) {
|
|
10
|
+
return LONG_LIVED_ASSET_CACHE;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
|
|
17
|
+
const response = await environment.ASSETS.fetch(request);
|
|
18
|
+
const cacheControl = getCacheControl(new URL(request.url).pathname);
|
|
19
|
+
if (!cacheControl) return response;
|
|
20
|
+
|
|
21
|
+
const headers = new Headers(response.headers);
|
|
22
|
+
headers.set("Cache-Control", cacheControl);
|
|
23
|
+
return new Response(response.body, { status: response.status, headers });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import PreviewLayout from "../../layouts/PreviewLayout.astro";
|
|
3
|
-
import PreviewNavSidebar from "../../components/PreviewNavSidebar.astro";
|
|
4
|
-
import { ALL_TOOLS } from "../../index";
|
|
5
|
-
import {
|
|
6
|
-
UtilityHeader,
|
|
7
|
-
FAQSection,
|
|
8
|
-
Bibliography,
|
|
9
|
-
SEORenderer,
|
|
10
|
-
} from "@jjlmoya/utils-shared";
|
|
11
|
-
import type { KnownLocale, ToolLocaleContent } from "../../types";
|
|
12
|
-
import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
|
|
13
|
-
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
14
|
-
|
|
15
|
-
export async function getStaticPaths() {
|
|
16
|
-
const paths = [];
|
|
17
|
-
|
|
18
|
-
for (const { entry, Component: lazyComp } of ALL_TOOLS) {
|
|
19
|
-
const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
|
|
20
|
-
const localeEntries = Object.entries(entry.i18n) as [
|
|
21
|
-
KnownLocale,
|
|
22
|
-
() => Promise<ToolLocaleContent>,
|
|
23
|
-
][];
|
|
24
|
-
const localeContents = await Promise.all(
|
|
25
|
-
localeEntries.map(async ([locale, loader]) => ({
|
|
26
|
-
locale,
|
|
27
|
-
content: await loader(),
|
|
28
|
-
})),
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
const localeUrls = Object.fromEntries(
|
|
32
|
-
localeContents.map(({ locale, content }) => [
|
|
33
|
-
locale,
|
|
34
|
-
`/${locale}/${content.slug}`,
|
|
35
|
-
]),
|
|
36
|
-
) as Partial<Record<KnownLocale, string>>;
|
|
37
|
-
|
|
38
|
-
const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
|
|
39
|
-
const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
|
|
40
|
-
|
|
41
|
-
for (const { locale, content } of localeContents) {
|
|
42
|
-
const allToolsNav = (
|
|
43
|
-
await Promise.all(
|
|
44
|
-
ALL_TOOLS.map(async ({ entry: navEntry }) => {
|
|
45
|
-
const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
|
|
46
|
-
if (!loader) return null;
|
|
47
|
-
const navContent = await loader();
|
|
48
|
-
return {
|
|
49
|
-
id: navEntry.id,
|
|
50
|
-
title: navContent.title,
|
|
51
|
-
href: `/${locale}/${navContent.slug}`,
|
|
52
|
-
isActive: navEntry.id === entry.id,
|
|
53
|
-
};
|
|
54
|
-
}),
|
|
55
|
-
)
|
|
56
|
-
).filter(Boolean) as NavItem[];
|
|
57
|
-
paths.push({
|
|
58
|
-
params: { locale, slug: content.slug },
|
|
59
|
-
props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return paths;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface NavItem {
|
|
68
|
-
id: string;
|
|
69
|
-
title: string;
|
|
70
|
-
href: string;
|
|
71
|
-
isActive?: boolean;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
interface Props {
|
|
75
|
-
Component: unknown;
|
|
76
|
-
locale: KnownLocale;
|
|
77
|
-
content: ToolLocaleContent;
|
|
78
|
-
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
79
|
-
allToolsNav: NavItem[];
|
|
80
|
-
englishSlug: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as Props;
|
|
84
|
-
const ToolComponent = Component as AstroComponentFactory;
|
|
85
|
-
|
|
86
|
-
const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
|
|
87
|
-
const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
|
|
88
|
-
const cssLoader = cssKey ? cssFiles[cssKey] : null;
|
|
89
|
-
const toolCss = cssLoader ? await cssLoader() as string : "";
|
|
90
|
-
|
|
91
|
-
const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
|
|
92
|
-
|
|
93
|
-
const words = content.title.split(" ");
|
|
94
|
-
const titleHighlight = words[0] || "";
|
|
95
|
-
const titleBase = words.slice(1).join(" ") || "";
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
<PreviewLayout
|
|
99
|
-
title={content.title}
|
|
100
|
-
currentLocale={locale}
|
|
101
|
-
localeUrls={localeUrls}
|
|
102
|
-
hasSidebar={true}
|
|
103
|
-
>
|
|
104
|
-
<PreviewNavSidebar
|
|
105
|
-
slot="sidebar"
|
|
106
|
-
categoryTitle="Tools"
|
|
107
|
-
tools={allToolsNav}
|
|
108
|
-
/>
|
|
109
|
-
<Fragment slot="head">
|
|
110
|
-
{toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
|
|
111
|
-
{
|
|
112
|
-
( content.schemas ?? []).map((schema: unknown) => (
|
|
113
|
-
<script
|
|
114
|
-
is:inline
|
|
115
|
-
type="application/ld+json"
|
|
116
|
-
set:html={JSON.stringify(schema)}
|
|
117
|
-
/>
|
|
118
|
-
))
|
|
119
|
-
}
|
|
120
|
-
</Fragment>
|
|
121
|
-
|
|
122
|
-
<div class="tool-page">
|
|
123
|
-
<UtilityHeader
|
|
124
|
-
titleHighlight={titleHighlight}
|
|
125
|
-
titleBase={titleBase}
|
|
126
|
-
description={content.description}
|
|
127
|
-
/>
|
|
128
|
-
|
|
129
|
-
<section class="section-tool">
|
|
130
|
-
<ToolComponent ui={content.ui} />
|
|
131
|
-
</section>
|
|
132
|
-
|
|
133
|
-
<section class="section-seo">
|
|
134
|
-
<SEORenderer content={seoContent} />
|
|
135
|
-
</section>
|
|
136
|
-
|
|
137
|
-
<section class="section-faq">
|
|
138
|
-
<FAQSection items={content.faq} />
|
|
139
|
-
</section>
|
|
140
|
-
|
|
141
|
-
<section class="section-bibliography">
|
|
142
|
-
<Bibliography links={content.bibliography} />
|
|
143
|
-
</section>
|
|
144
|
-
</div>
|
|
145
|
-
</PreviewLayout>
|
|
146
|
-
|
|
147
|
-
<style>
|
|
148
|
-
.tool-page {
|
|
149
|
-
display: flex;
|
|
150
|
-
flex-direction: column;
|
|
151
|
-
gap: 2rem;
|
|
152
|
-
}
|
|
153
|
-
.section-tool {
|
|
154
|
-
max-width: 1200px;
|
|
155
|
-
margin: 0 auto;
|
|
156
|
-
width: 100%;
|
|
157
|
-
}
|
|
158
|
-
.section-seo,
|
|
159
|
-
.section-faq,
|
|
160
|
-
.section-bibliography {
|
|
161
|
-
padding-top: 2rem;
|
|
162
|
-
border-top: 1px solid var(--border-color);
|
|
163
|
-
}
|
|
164
|
-
</style>
|
package/src/pages/[locale].astro
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import PreviewLayout from '../layouts/PreviewLayout.astro';
|
|
3
|
-
import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
|
|
4
|
-
import { templateCategory, ALL_TOOLS } from '../index';
|
|
5
|
-
import { Icon } from 'astro-icon/components';
|
|
6
|
-
import type { KnownLocale, ToolLocaleContent } from '../types';
|
|
7
|
-
|
|
8
|
-
export async function getStaticPaths() {
|
|
9
|
-
const locales = Object.keys(templateCategory.i18n) as KnownLocale[];
|
|
10
|
-
return locales.map((locale) => ({ params: { locale } }));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const categoryLocales = Object.keys(templateCategory.i18n) as KnownLocale[];
|
|
14
|
-
|
|
15
|
-
const { locale: requestedLocale } = Astro.params as { locale: KnownLocale };
|
|
16
|
-
const currentLocale = categoryLocales.includes(requestedLocale) ? requestedLocale : 'en';
|
|
17
|
-
const categoryLoader = templateCategory.i18n[currentLocale] ?? templateCategory.i18n.en;
|
|
18
|
-
|
|
19
|
-
if (!categoryLoader) {
|
|
20
|
-
throw new Error('No category locale loader is registered.');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const categoryContent = await categoryLoader();
|
|
24
|
-
const tools = ALL_TOOLS || [];
|
|
25
|
-
|
|
26
|
-
const toolsWithContent = tools.length > 0
|
|
27
|
-
? await Promise.all(
|
|
28
|
-
tools.map(async ({ entry, Component }) => {
|
|
29
|
-
const languages = Object.keys(entry.i18n) as KnownLocale[];
|
|
30
|
-
const localeEntries = await Promise.all(
|
|
31
|
-
languages.map(async (l) => {
|
|
32
|
-
const loader = entry.i18n[l];
|
|
33
|
-
if (!loader) return null;
|
|
34
|
-
const content = await loader();
|
|
35
|
-
return [l, content] as const;
|
|
36
|
-
})
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const availableLocaleEntries = localeEntries.filter(
|
|
40
|
-
(localeEntry): localeEntry is readonly [KnownLocale, ToolLocaleContent<Record<string, string>>] => localeEntry !== null,
|
|
41
|
-
);
|
|
42
|
-
const localeContents = Object.fromEntries(availableLocaleEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
|
|
43
|
-
const currentLocaleContent = localeContents[currentLocale] || localeContents.en;
|
|
44
|
-
const availableLocales: Record<string, string> = {};
|
|
45
|
-
|
|
46
|
-
for (const l of languages) {
|
|
47
|
-
const lCont = localeContents[l];
|
|
48
|
-
if (lCont) {
|
|
49
|
-
availableLocales[l] = `/${l}/${lCont.slug}`;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return { entry, Component, locale: currentLocaleContent, availableLocales };
|
|
54
|
-
})
|
|
55
|
-
)
|
|
56
|
-
: [];
|
|
57
|
-
---
|
|
58
|
-
|
|
59
|
-
<PreviewLayout
|
|
60
|
-
title={categoryContent.title}
|
|
61
|
-
currentLocale={currentLocale}
|
|
62
|
-
hasSidebar={true}
|
|
63
|
-
>
|
|
64
|
-
<PreviewNavSidebar
|
|
65
|
-
slot="sidebar"
|
|
66
|
-
categoryTitle={categoryContent.title}
|
|
67
|
-
tools={toolsWithContent.map(({ entry, locale, availableLocales }) => {
|
|
68
|
-
const href = availableLocales[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#');
|
|
69
|
-
return {
|
|
70
|
-
id: entry.id,
|
|
71
|
-
title: locale?.title || entry.id,
|
|
72
|
-
href: href,
|
|
73
|
-
};
|
|
74
|
-
})}
|
|
75
|
-
/>
|
|
76
|
-
<div class="dashboard">
|
|
77
|
-
<header class="preview-header">
|
|
78
|
-
<span class="badge">preview - @jjlmoya/utils-forensic-science</span>
|
|
79
|
-
<h1>{categoryContent.title}</h1>
|
|
80
|
-
<p>{categoryContent.description}</p>
|
|
81
|
-
</header>
|
|
82
|
-
|
|
83
|
-
<div class="tool-list">
|
|
84
|
-
{toolsWithContent?.map(({ entry, locale, availableLocales }) => (
|
|
85
|
-
<article class="tool-card">
|
|
86
|
-
<a href={availableLocales?.[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#')} class="tool-card-link">
|
|
87
|
-
<div class="tool-icons">
|
|
88
|
-
<div class="icon-wrapper bg">
|
|
89
|
-
<Icon name={entry.icons.bg} />
|
|
90
|
-
</div>
|
|
91
|
-
<div class="icon-wrapper fg">
|
|
92
|
-
<Icon name={entry.icons.fg} />
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="tool-card-content">
|
|
96
|
-
<h2 class="tool-title">{locale?.title}</h2>
|
|
97
|
-
<p class="tool-description">{locale?.description}</p>
|
|
98
|
-
</div>
|
|
99
|
-
<div class="tool-card-meta">
|
|
100
|
-
<span class="tool-id">{entry.id}</span>
|
|
101
|
-
</div>
|
|
102
|
-
</a>
|
|
103
|
-
|
|
104
|
-
{availableLocales && Object.keys(availableLocales).length > 1 && (
|
|
105
|
-
<div class="tool-locales">
|
|
106
|
-
{Object.entries(availableLocales).map(([l, url]) => (
|
|
107
|
-
<a href={url} class="locale-badge" title={`View in ${l.toUpperCase()}`} class:list={{ active: l === currentLocale }}>
|
|
108
|
-
{l.toUpperCase()}
|
|
109
|
-
</a>
|
|
110
|
-
))}
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
|
-
</article>
|
|
114
|
-
))}
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
</PreviewLayout>
|
|
118
|
-
|
|
119
|
-
<style>
|
|
120
|
-
.dashboard {
|
|
121
|
-
display: flex;
|
|
122
|
-
flex-direction: column;
|
|
123
|
-
gap: 5rem;
|
|
124
|
-
}
|
|
125
|
-
.preview-header {
|
|
126
|
-
text-align: center;
|
|
127
|
-
padding-bottom: 3rem;
|
|
128
|
-
border-bottom: 1px solid var(--border-color);
|
|
129
|
-
}
|
|
130
|
-
.badge {
|
|
131
|
-
display: inline-block;
|
|
132
|
-
padding: 0.25rem 0.75rem;
|
|
133
|
-
background: var(--accent);
|
|
134
|
-
border-radius: 99px;
|
|
135
|
-
font-size: 0.7rem;
|
|
136
|
-
font-weight: 800;
|
|
137
|
-
margin-bottom: 1.5rem;
|
|
138
|
-
color: var(--text-base);
|
|
139
|
-
letter-spacing: 0.05em;
|
|
140
|
-
}
|
|
141
|
-
h1 {
|
|
142
|
-
font-size: clamp(2rem, 6vw, 3.5rem);
|
|
143
|
-
font-weight: 900;
|
|
144
|
-
margin: 0 0 1rem;
|
|
145
|
-
background: linear-gradient(to bottom, var(--text-base), var(--text-muted));
|
|
146
|
-
-webkit-background-clip: text;
|
|
147
|
-
-webkit-text-fill-color: transparent;
|
|
148
|
-
background-clip: text;
|
|
149
|
-
}
|
|
150
|
-
.preview-header p {
|
|
151
|
-
color: var(--text-muted);
|
|
152
|
-
font-size: 1.1rem;
|
|
153
|
-
margin: 0;
|
|
154
|
-
}
|
|
155
|
-
.tool-list {
|
|
156
|
-
display: grid;
|
|
157
|
-
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
158
|
-
gap: 2rem;
|
|
159
|
-
}
|
|
160
|
-
.tool-card {
|
|
161
|
-
display: flex;
|
|
162
|
-
flex-direction: column;
|
|
163
|
-
gap: 1rem;
|
|
164
|
-
}
|
|
165
|
-
.tool-card-link {
|
|
166
|
-
flex: 1;
|
|
167
|
-
display: flex;
|
|
168
|
-
flex-direction: column;
|
|
169
|
-
padding: 1.5rem;
|
|
170
|
-
background: var(--bg-surface);
|
|
171
|
-
border: 1px solid var(--border-color);
|
|
172
|
-
border-radius: 0.75rem;
|
|
173
|
-
text-decoration: none;
|
|
174
|
-
transition: all 0.2s ease;
|
|
175
|
-
}
|
|
176
|
-
.tool-card-link:hover {
|
|
177
|
-
border-color: var(--accent);
|
|
178
|
-
background: rgba(244, 63, 94, 0.05);
|
|
179
|
-
transform: translateY(-2px);
|
|
180
|
-
}
|
|
181
|
-
.tool-icons {
|
|
182
|
-
display: flex;
|
|
183
|
-
align-items: center;
|
|
184
|
-
gap: 1rem;
|
|
185
|
-
margin-bottom: 1.25rem;
|
|
186
|
-
}
|
|
187
|
-
.icon-wrapper {
|
|
188
|
-
display: flex;
|
|
189
|
-
align-items: center;
|
|
190
|
-
justify-content: center;
|
|
191
|
-
width: 3rem;
|
|
192
|
-
height: 3rem;
|
|
193
|
-
border-radius: 0.5rem;
|
|
194
|
-
font-size: 1.5rem;
|
|
195
|
-
}
|
|
196
|
-
.icon-wrapper.bg {
|
|
197
|
-
background: var(--accent);
|
|
198
|
-
color: var(--text-base);
|
|
199
|
-
}
|
|
200
|
-
.icon-wrapper.fg {
|
|
201
|
-
background: var(--bg-page);
|
|
202
|
-
border: 1px solid var(--border-color);
|
|
203
|
-
color: var(--accent);
|
|
204
|
-
}
|
|
205
|
-
.tool-card-content {
|
|
206
|
-
flex: 1;
|
|
207
|
-
display: flex;
|
|
208
|
-
flex-direction: column;
|
|
209
|
-
}
|
|
210
|
-
.tool-title {
|
|
211
|
-
font-size: 1.25rem;
|
|
212
|
-
font-weight: 700;
|
|
213
|
-
margin: 0 0 0.5rem;
|
|
214
|
-
color: var(--text-base);
|
|
215
|
-
}
|
|
216
|
-
.tool-description {
|
|
217
|
-
font-size: 0.9375rem;
|
|
218
|
-
color: var(--text-muted);
|
|
219
|
-
line-height: 1.5;
|
|
220
|
-
margin: 0;
|
|
221
|
-
}
|
|
222
|
-
.tool-card-meta {
|
|
223
|
-
padding-top: 1rem;
|
|
224
|
-
border-top: 1px solid var(--border-color);
|
|
225
|
-
margin-top: 1.5rem;
|
|
226
|
-
}
|
|
227
|
-
.tool-id {
|
|
228
|
-
display: inline-block;
|
|
229
|
-
font-size: 0.7rem;
|
|
230
|
-
background: var(--bg-page);
|
|
231
|
-
border: 1px solid var(--border-color);
|
|
232
|
-
padding: 0.35rem 0.75rem;
|
|
233
|
-
border-radius: 0.4rem;
|
|
234
|
-
color: var(--accent);
|
|
235
|
-
font-weight: 600;
|
|
236
|
-
}
|
|
237
|
-
.tool-locales {
|
|
238
|
-
display: flex;
|
|
239
|
-
gap: 0.5rem;
|
|
240
|
-
flex-wrap: wrap;
|
|
241
|
-
}
|
|
242
|
-
.locale-badge {
|
|
243
|
-
display: inline-block;
|
|
244
|
-
padding: 0.4rem 0.85rem;
|
|
245
|
-
background: var(--bg-page);
|
|
246
|
-
border: 1px solid var(--border-color);
|
|
247
|
-
border-radius: 0.4rem;
|
|
248
|
-
color: var(--text-muted);
|
|
249
|
-
text-decoration: none;
|
|
250
|
-
font-size: 0.75rem;
|
|
251
|
-
font-weight: 600;
|
|
252
|
-
text-transform: uppercase;
|
|
253
|
-
letter-spacing: 0.05em;
|
|
254
|
-
transition: all 0.15s ease;
|
|
255
|
-
}
|
|
256
|
-
.locale-badge:hover,
|
|
257
|
-
.locale-badge.active {
|
|
258
|
-
color: var(--accent);
|
|
259
|
-
border-color: var(--accent);
|
|
260
|
-
background: rgba(244, 63, 94, 0.1);
|
|
261
|
-
}
|
|
262
|
-
</style>
|
|
263
|
-
|
|
264
|
-
|