@jjlmoya/utils-nautical 1.2.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 +75 -0
- package/src/category/i18n/en.ts +79 -0
- package/src/category/i18n/es.ts +79 -0
- package/src/category/i18n/fr.ts +79 -0
- package/src/category/index.ts +13 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +12 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +22 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +146 -0
- package/src/pages/[locale].astro +249 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/faq_count.test.ts +30 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/seo_length.test.ts +29 -0
- package/src/tests/tool_validation.test.ts +51 -0
- package/src/tool/endurance/bibliography.astro +14 -0
- package/src/tool/endurance/component.astro +310 -0
- package/src/tool/endurance/i18n/en.ts +243 -0
- package/src/tool/endurance/i18n/es.ts +249 -0
- package/src/tool/endurance/i18n/fr.ts +217 -0
- package/src/tool/endurance/index.ts +56 -0
- package/src/tool/endurance/logic.ts +36 -0
- package/src/tool/endurance/seo.astro +55 -0
- package/src/tool/endurance/style.css +333 -0
- package/src/tool/nauticalConverter/bibliography.astro +14 -0
- package/src/tool/nauticalConverter/component.astro +481 -0
- package/src/tool/nauticalConverter/i18n/en.ts +239 -0
- package/src/tool/nauticalConverter/i18n/es.ts +239 -0
- package/src/tool/nauticalConverter/i18n/fr.ts +239 -0
- package/src/tool/nauticalConverter/index.ts +57 -0
- package/src/tool/nauticalConverter/logic.ts +52 -0
- package/src/tool/nauticalConverter/seo.astro +52 -0
- package/src/tool/nauticalConverter/style.css +205 -0
- package/src/tool/sailArea/bibliography.astro +14 -0
- package/src/tool/sailArea/component.astro +418 -0
- package/src/tool/sailArea/i18n/en.ts +275 -0
- package/src/tool/sailArea/i18n/es.ts +275 -0
- package/src/tool/sailArea/i18n/fr.ts +275 -0
- package/src/tool/sailArea/index.ts +53 -0
- package/src/tool/sailArea/logic.ts +55 -0
- package/src/tool/sailArea/seo.astro +52 -0
- package/src/tool/sailArea/style.css +351 -0
- package/src/tool/speedConverter/bibliography.astro +14 -0
- package/src/tool/speedConverter/component.astro +125 -0
- package/src/tool/speedConverter/i18n/en.ts +271 -0
- package/src/tool/speedConverter/i18n/es.ts +271 -0
- package/src/tool/speedConverter/i18n/fr.ts +271 -0
- package/src/tool/speedConverter/index.ts +46 -0
- package/src/tool/speedConverter/logic.ts +48 -0
- package/src/tool/speedConverter/seo.astro +43 -0
- package/src/tool/speedConverter/style.css +239 -0
- package/src/tool/tideCalculator/bibliography.astro +14 -0
- package/src/tool/tideCalculator/component.astro +314 -0
- package/src/tool/tideCalculator/i18n/en.ts +203 -0
- package/src/tool/tideCalculator/i18n/es.ts +225 -0
- package/src/tool/tideCalculator/i18n/fr.ts +190 -0
- package/src/tool/tideCalculator/index.ts +50 -0
- package/src/tool/tideCalculator/logic.ts +73 -0
- package/src/tool/tideCalculator/seo.astro +61 -0
- package/src/tool/tideCalculator/style.css +310 -0
- package/src/tool/underKeel/bibliography.astro +14 -0
- package/src/tool/underKeel/component.astro +240 -0
- package/src/tool/underKeel/i18n/en.ts +193 -0
- package/src/tool/underKeel/i18n/es.ts +206 -0
- package/src/tool/underKeel/i18n/fr.ts +193 -0
- package/src/tool/underKeel/index.ts +49 -0
- package/src/tool/underKeel/logic.ts +83 -0
- package/src/tool/underKeel/seo.astro +46 -0
- package/src/tool/underKeel/style.css +312 -0
- package/src/tools.ts +25 -0
- package/src/types.ts +69 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "@jjlmoya/utils-shared/theme.css";
|
|
3
|
+
import PreviewToolbar from "../components/PreviewToolbar.astro";
|
|
4
|
+
import type { KnownLocale } from "../types";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
currentLocale?: KnownLocale;
|
|
9
|
+
localeUrls?: Partial<Record<KnownLocale, string>>;
|
|
10
|
+
hasSidebar?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<!doctype html>
|
|
17
|
+
<html lang={currentLocale}>
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="UTF-8" />
|
|
20
|
+
<meta name="viewport" content="width=device-width" />
|
|
21
|
+
<title>{title} · preview</title>
|
|
22
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
23
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
24
|
+
<link
|
|
25
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
|
|
26
|
+
rel="stylesheet"
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<script is:inline>
|
|
30
|
+
(function () {
|
|
31
|
+
const saved = localStorage.getItem("theme") || "theme-dark";
|
|
32
|
+
document.documentElement.classList.add(saved);
|
|
33
|
+
})();
|
|
34
|
+
</script>
|
|
35
|
+
<slot name="head" />
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<PreviewToolbar currentLocale={currentLocale} localeUrls={localeUrls} />
|
|
39
|
+
<div class:list={["page-wrapper", { "with-sidebar": hasSidebar }]}>
|
|
40
|
+
{
|
|
41
|
+
hasSidebar && (
|
|
42
|
+
<aside class="sidebar-area">
|
|
43
|
+
<slot name="sidebar" />
|
|
44
|
+
</aside>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
<main>
|
|
48
|
+
<slot />
|
|
49
|
+
</main>
|
|
50
|
+
</div>
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
|
|
54
|
+
<style is:global>
|
|
55
|
+
:root {
|
|
56
|
+
--accent: #ff6b35;
|
|
57
|
+
--primary-base: #003b73;
|
|
58
|
+
--cyan: #0074b7;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.theme-dark,
|
|
62
|
+
.theme-light {
|
|
63
|
+
--text-main: var(--text-base);
|
|
64
|
+
--border-color: var(--border-base);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
*,
|
|
68
|
+
*::before,
|
|
69
|
+
*::after {
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
body {
|
|
74
|
+
background-color: var(--bg-page);
|
|
75
|
+
color: var(--text-base);
|
|
76
|
+
margin: 0;
|
|
77
|
+
min-height: 100vh;
|
|
78
|
+
transition:
|
|
79
|
+
background-color 0.3s ease,
|
|
80
|
+
color 0.3s ease;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main {
|
|
84
|
+
padding: 0 2rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.page-wrapper {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.page-wrapper.with-sidebar {
|
|
93
|
+
display: grid;
|
|
94
|
+
grid-template-columns: 240px 1fr;
|
|
95
|
+
min-height: 100vh;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.sidebar-area {
|
|
99
|
+
position: sticky;
|
|
100
|
+
top: 0;
|
|
101
|
+
height: 100vh;
|
|
102
|
+
overflow-y: auto;
|
|
103
|
+
border-right: 1px solid var(--border-color);
|
|
104
|
+
background: var(--bg-page);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@media (max-width: 768px) {
|
|
108
|
+
.page-wrapper.with-sidebar {
|
|
109
|
+
grid-template-columns: 1fr;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sidebar-area {
|
|
113
|
+
display: none;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
117
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
|
|
14
|
+
export async function getStaticPaths() {
|
|
15
|
+
const paths = [];
|
|
16
|
+
|
|
17
|
+
for (const { entry, Component } of ALL_TOOLS) {
|
|
18
|
+
const localeEntries = Object.entries(entry.i18n) as [
|
|
19
|
+
KnownLocale,
|
|
20
|
+
() => Promise<ToolLocaleContent>,
|
|
21
|
+
][];
|
|
22
|
+
const localeContents = await Promise.all(
|
|
23
|
+
localeEntries.map(async ([locale, loader]) => ({
|
|
24
|
+
locale,
|
|
25
|
+
content: await loader(),
|
|
26
|
+
})),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const localeUrls = Object.fromEntries(
|
|
30
|
+
localeContents.map(({ locale, content }) => [
|
|
31
|
+
locale,
|
|
32
|
+
`/${locale}/${content.slug}`,
|
|
33
|
+
]),
|
|
34
|
+
) as Partial<Record<KnownLocale, string>>;
|
|
35
|
+
|
|
36
|
+
for (const { locale, content } of localeContents) {
|
|
37
|
+
const allToolsNav = await Promise.all(
|
|
38
|
+
ALL_TOOLS.map(async ({ entry: navEntry }) => ({
|
|
39
|
+
id: navEntry.id,
|
|
40
|
+
title: (await navEntry.i18n[locale]!()).title,
|
|
41
|
+
href: `/${locale}/${(await navEntry.i18n[locale]!()).slug}`,
|
|
42
|
+
isActive: navEntry.id === entry.id,
|
|
43
|
+
})),
|
|
44
|
+
);
|
|
45
|
+
paths.push({
|
|
46
|
+
params: { locale, slug: content.slug },
|
|
47
|
+
props: { Component, locale, content, localeUrls, allToolsNav },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return paths;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ToolComponent = (props: { ui: Record<string, string> }) => unknown;
|
|
56
|
+
|
|
57
|
+
interface NavItem {
|
|
58
|
+
id: string;
|
|
59
|
+
title: string;
|
|
60
|
+
href: string;
|
|
61
|
+
isActive?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Props {
|
|
65
|
+
Component: ToolComponent;
|
|
66
|
+
locale: KnownLocale;
|
|
67
|
+
content: ToolLocaleContent;
|
|
68
|
+
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
69
|
+
allToolsNav: NavItem[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { Component, locale, content, localeUrls, allToolsNav } = Astro.props;
|
|
73
|
+
|
|
74
|
+
const seoContent: UtilitySEOContent = { locale, sections: content.seo };
|
|
75
|
+
|
|
76
|
+
const words = content.title.split(" ");
|
|
77
|
+
const titleHighlight = words[0] || "";
|
|
78
|
+
const titleBase = words.slice(1).join(" ") || "";
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<PreviewLayout
|
|
82
|
+
title={content.title}
|
|
83
|
+
currentLocale={locale}
|
|
84
|
+
localeUrls={localeUrls}
|
|
85
|
+
hasSidebar={true}
|
|
86
|
+
>
|
|
87
|
+
<PreviewNavSidebar
|
|
88
|
+
slot="sidebar"
|
|
89
|
+
categoryTitle="Tools"
|
|
90
|
+
tools={allToolsNav}
|
|
91
|
+
/>
|
|
92
|
+
<Fragment slot="head">
|
|
93
|
+
{
|
|
94
|
+
content.schemas.map((schema: unknown) => (
|
|
95
|
+
<script
|
|
96
|
+
is:inline
|
|
97
|
+
type="application/ld+json"
|
|
98
|
+
set:html={JSON.stringify(schema)}
|
|
99
|
+
/>
|
|
100
|
+
))
|
|
101
|
+
}
|
|
102
|
+
</Fragment>
|
|
103
|
+
|
|
104
|
+
<div class="tool-page">
|
|
105
|
+
<UtilityHeader
|
|
106
|
+
titleHighlight={titleHighlight}
|
|
107
|
+
titleBase={titleBase}
|
|
108
|
+
description={content.description}
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<section class="section-tool">
|
|
112
|
+
<Component ui={content.ui} />
|
|
113
|
+
</section>
|
|
114
|
+
|
|
115
|
+
<section class="section-seo">
|
|
116
|
+
<SEORenderer content={seoContent} />
|
|
117
|
+
</section>
|
|
118
|
+
|
|
119
|
+
<section class="section-faq">
|
|
120
|
+
<FAQSection items={content.faq} title={content.ui.faqTitle} inLanguage={locale} />
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section class="section-bibliography">
|
|
124
|
+
<Bibliography links={content.bibliography} title={content.ui.bibliographyTitle} />
|
|
125
|
+
</section>
|
|
126
|
+
</div>
|
|
127
|
+
</PreviewLayout>
|
|
128
|
+
|
|
129
|
+
<style>
|
|
130
|
+
.tool-page {
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-direction: column;
|
|
133
|
+
gap: 2rem;
|
|
134
|
+
}
|
|
135
|
+
.section-tool {
|
|
136
|
+
max-width: 1200px;
|
|
137
|
+
margin: 0 auto;
|
|
138
|
+
width: 100%;
|
|
139
|
+
}
|
|
140
|
+
.section-seo,
|
|
141
|
+
.section-faq,
|
|
142
|
+
.section-bibliography {
|
|
143
|
+
padding-top: 2rem;
|
|
144
|
+
border-top: 1px solid var(--border-color);
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PreviewLayout from '../layouts/PreviewLayout.astro';
|
|
3
|
+
import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
|
|
4
|
+
import { 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 = ['en', 'es', 'fr'] as KnownLocale[];
|
|
10
|
+
return locales.map(locale => ({ params: { locale } }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { locale: currentLocale } = Astro.params as { locale: KnownLocale };
|
|
14
|
+
|
|
15
|
+
const tools = ALL_TOOLS || [];
|
|
16
|
+
|
|
17
|
+
const toolsWithContent = tools.length > 0
|
|
18
|
+
? await Promise.all(
|
|
19
|
+
tools.map(async ({ entry, Component }) => {
|
|
20
|
+
const languages = Object.keys(entry.i18n);
|
|
21
|
+
const localeEntries = await Promise.all(
|
|
22
|
+
languages.map(async (l) => {
|
|
23
|
+
const content = await entry.i18n[l as KnownLocale]!();
|
|
24
|
+
return [l, content];
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
|
|
29
|
+
|
|
30
|
+
const currentLocaleContent = localeContents[currentLocale] || localeContents['en'] || localeContents['es'];
|
|
31
|
+
const availableLocales: Record<string, string> = {};
|
|
32
|
+
|
|
33
|
+
for (const l of languages) {
|
|
34
|
+
const lCont = localeContents[l];
|
|
35
|
+
if (lCont) {
|
|
36
|
+
availableLocales[l] = `/${l}/${lCont.slug}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { entry, Component, locale: currentLocaleContent, availableLocales };
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
: [];
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<PreviewLayout
|
|
47
|
+
title="Vela y Náutica"
|
|
48
|
+
currentLocale={currentLocale}
|
|
49
|
+
hasSidebar={true}
|
|
50
|
+
>
|
|
51
|
+
<PreviewNavSidebar
|
|
52
|
+
slot="sidebar"
|
|
53
|
+
categoryTitle="Vela y Náutica"
|
|
54
|
+
tools={toolsWithContent.map(({ entry, locale, availableLocales }) => {
|
|
55
|
+
const href = availableLocales[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#');
|
|
56
|
+
return {
|
|
57
|
+
id: entry.id,
|
|
58
|
+
title: locale?.title || entry.id,
|
|
59
|
+
href: href,
|
|
60
|
+
};
|
|
61
|
+
})}
|
|
62
|
+
/>
|
|
63
|
+
<div class="dashboard">
|
|
64
|
+
<header class="preview-header">
|
|
65
|
+
<span class="badge">preview · @jjlmoya/utils-nautical</span>
|
|
66
|
+
<h1>Vela y Náutica</h1>
|
|
67
|
+
<p>Herramientas de navegación y vela</p>
|
|
68
|
+
</header>
|
|
69
|
+
|
|
70
|
+
<div class="tool-list">
|
|
71
|
+
{toolsWithContent?.map(({ entry, locale, availableLocales }) => (
|
|
72
|
+
<article class="tool-card">
|
|
73
|
+
<a href={availableLocales?.[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#')} class="tool-card-link">
|
|
74
|
+
<div class="tool-icons">
|
|
75
|
+
<div class="icon-wrapper bg">
|
|
76
|
+
<Icon name={entry.icons.bg} />
|
|
77
|
+
</div>
|
|
78
|
+
<div class="icon-wrapper fg">
|
|
79
|
+
<Icon name={entry.icons.fg} />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="tool-card-content">
|
|
83
|
+
<h2 class="tool-title">{locale?.title}</h2>
|
|
84
|
+
<p class="tool-description">{locale?.description}</p>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="tool-card-meta">
|
|
87
|
+
<span class="tool-id">{entry.id}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</a>
|
|
90
|
+
|
|
91
|
+
{availableLocales && Object.keys(availableLocales).length > 1 && (
|
|
92
|
+
<div class="tool-locales">
|
|
93
|
+
{Object.entries(availableLocales).map(([l, url]) => (
|
|
94
|
+
<a href={url} class="locale-badge" title={`Ver en ${l.toUpperCase()}`} class:list={{ active: l === currentLocale }}>
|
|
95
|
+
{l.toUpperCase()}
|
|
96
|
+
</a>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</article>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</PreviewLayout>
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
.dashboard {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: 5rem;
|
|
111
|
+
}
|
|
112
|
+
.preview-header {
|
|
113
|
+
text-align: center;
|
|
114
|
+
padding-bottom: 3rem;
|
|
115
|
+
border-bottom: 1px solid var(--border-color);
|
|
116
|
+
}
|
|
117
|
+
.badge {
|
|
118
|
+
display: inline-block;
|
|
119
|
+
padding: 0.25rem 0.75rem;
|
|
120
|
+
background: var(--accent);
|
|
121
|
+
border-radius: 99px;
|
|
122
|
+
font-size: 0.7rem;
|
|
123
|
+
font-weight: 800;
|
|
124
|
+
margin-bottom: 1.5rem;
|
|
125
|
+
color: var(--text-base);
|
|
126
|
+
letter-spacing: 0.05em;
|
|
127
|
+
}
|
|
128
|
+
h1 {
|
|
129
|
+
font-size: clamp(2rem, 6vw, 3.5rem);
|
|
130
|
+
font-weight: 900;
|
|
131
|
+
margin: 0 0 1rem;
|
|
132
|
+
background: linear-gradient(to bottom, var(--text-base), var(--text-muted));
|
|
133
|
+
-webkit-background-clip: text;
|
|
134
|
+
-webkit-text-fill-color: transparent;
|
|
135
|
+
background-clip: text;
|
|
136
|
+
}
|
|
137
|
+
.preview-header p {
|
|
138
|
+
color: var(--text-muted);
|
|
139
|
+
font-size: 1.1rem;
|
|
140
|
+
margin: 0;
|
|
141
|
+
}
|
|
142
|
+
.tool-list {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
145
|
+
gap: 2rem;
|
|
146
|
+
}
|
|
147
|
+
.tool-card {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 1rem;
|
|
151
|
+
}
|
|
152
|
+
.tool-card-link {
|
|
153
|
+
flex: 1;
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-direction: column;
|
|
156
|
+
padding: 1.5rem;
|
|
157
|
+
background: var(--bg-surface);
|
|
158
|
+
border: 1px solid var(--border-color);
|
|
159
|
+
border-radius: 0.75rem;
|
|
160
|
+
text-decoration: none;
|
|
161
|
+
transition: all 0.2s ease;
|
|
162
|
+
}
|
|
163
|
+
.tool-card-link:hover {
|
|
164
|
+
border-color: var(--accent);
|
|
165
|
+
background: rgba(244, 63, 94, 0.05);
|
|
166
|
+
transform: translateY(-2px);
|
|
167
|
+
}
|
|
168
|
+
.tool-icons {
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 1rem;
|
|
172
|
+
margin-bottom: 1.25rem;
|
|
173
|
+
}
|
|
174
|
+
.icon-wrapper {
|
|
175
|
+
display: flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
justify-content: center;
|
|
178
|
+
width: 3rem;
|
|
179
|
+
height: 3rem;
|
|
180
|
+
border-radius: 0.5rem;
|
|
181
|
+
font-size: 1.5rem;
|
|
182
|
+
}
|
|
183
|
+
.icon-wrapper.bg {
|
|
184
|
+
background: var(--accent);
|
|
185
|
+
color: var(--text-base);
|
|
186
|
+
}
|
|
187
|
+
.icon-wrapper.fg {
|
|
188
|
+
background: var(--bg-page);
|
|
189
|
+
border: 1px solid var(--border-color);
|
|
190
|
+
color: var(--accent);
|
|
191
|
+
}
|
|
192
|
+
.tool-card-content {
|
|
193
|
+
flex: 1;
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
}
|
|
197
|
+
.tool-title {
|
|
198
|
+
font-size: 1.25rem;
|
|
199
|
+
font-weight: 700;
|
|
200
|
+
margin: 0 0 0.5rem;
|
|
201
|
+
color: var(--text-base);
|
|
202
|
+
}
|
|
203
|
+
.tool-description {
|
|
204
|
+
font-size: 0.9375rem;
|
|
205
|
+
color: var(--text-muted);
|
|
206
|
+
line-height: 1.5;
|
|
207
|
+
margin: 0;
|
|
208
|
+
}
|
|
209
|
+
.tool-card-meta {
|
|
210
|
+
padding-top: 1rem;
|
|
211
|
+
border-top: 1px solid var(--border-color);
|
|
212
|
+
margin-top: 1.5rem;
|
|
213
|
+
}
|
|
214
|
+
.tool-id {
|
|
215
|
+
display: inline-block;
|
|
216
|
+
font-size: 0.7rem;
|
|
217
|
+
background: var(--bg-page);
|
|
218
|
+
border: 1px solid var(--border-color);
|
|
219
|
+
padding: 0.35rem 0.75rem;
|
|
220
|
+
border-radius: 0.4rem;
|
|
221
|
+
color: var(--accent);
|
|
222
|
+
font-weight: 600;
|
|
223
|
+
}
|
|
224
|
+
.tool-locales {
|
|
225
|
+
display: flex;
|
|
226
|
+
gap: 0.5rem;
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
}
|
|
229
|
+
.locale-badge {
|
|
230
|
+
display: inline-block;
|
|
231
|
+
padding: 0.4rem 0.85rem;
|
|
232
|
+
background: var(--bg-page);
|
|
233
|
+
border: 1px solid var(--border-color);
|
|
234
|
+
border-radius: 0.4rem;
|
|
235
|
+
color: var(--text-muted);
|
|
236
|
+
text-decoration: none;
|
|
237
|
+
font-size: 0.75rem;
|
|
238
|
+
font-weight: 600;
|
|
239
|
+
text-transform: uppercase;
|
|
240
|
+
letter-spacing: 0.05em;
|
|
241
|
+
transition: all 0.15s ease;
|
|
242
|
+
}
|
|
243
|
+
.locale-badge:hover,
|
|
244
|
+
.locale-badge.active {
|
|
245
|
+
color: var(--accent);
|
|
246
|
+
border-color: var(--accent);
|
|
247
|
+
background: rgba(244, 63, 94, 0.1);
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
describe('FAQ Content Validation', () => {
|
|
5
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
6
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
7
|
+
const locales = Object.keys(entry.i18n) as string[];
|
|
8
|
+
|
|
9
|
+
locales.forEach((locale) => {
|
|
10
|
+
it(`${locale}: should have at least 3 FAQ items`, async () => {
|
|
11
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
12
|
+
if (!loader) return;
|
|
13
|
+
const content = await loader();
|
|
14
|
+
expect(Array.isArray(content.faq)).toBe(true);
|
|
15
|
+
expect(content.faq.length).toBeGreaterThanOrEqual(3);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it(`${locale}: each FAQ item should have question and answer`, async () => {
|
|
19
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
20
|
+
if (!loader) return;
|
|
21
|
+
const content = await loader();
|
|
22
|
+
for (const item of content.faq) {
|
|
23
|
+
expect(item.question).toBeTruthy();
|
|
24
|
+
expect(item.answer).toBeTruthy();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
describe('SEO Content Length Validation', () => {
|
|
5
|
+
describe('Tool SEO', () => {
|
|
6
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
7
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
8
|
+
const locales = Object.keys(entry.i18n) as string[];
|
|
9
|
+
|
|
10
|
+
locales.forEach((locale) => {
|
|
11
|
+
it(`${locale}: SEO sections should exist and be non-empty`, async () => {
|
|
12
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
13
|
+
if (!loader) return;
|
|
14
|
+
const content = await loader();
|
|
15
|
+
expect(Array.isArray(content.seo)).toBe(true);
|
|
16
|
+
expect(content.seo.length).toBeGreaterThan(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it(`${locale}: slug should be defined and non-empty`, async () => {
|
|
20
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
21
|
+
if (!loader) return;
|
|
22
|
+
const content = await loader();
|
|
23
|
+
expect(content.slug).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const TOOL_ID_REGEX = /^[a-z0-9]+-?[a-z0-9]*$/;
|
|
5
|
+
|
|
6
|
+
describe('Tool Validation Suite', () => {
|
|
7
|
+
describe('Library Registration', () => {
|
|
8
|
+
it('should have 6 tools in ALL_TOOLS', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(6);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('Tool ID Format', () => {
|
|
14
|
+
it('all tool IDs should match the required regex', () => {
|
|
15
|
+
for (const tool of ALL_TOOLS) {
|
|
16
|
+
expect(tool.entry.id).toMatch(TOOL_ID_REGEX);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('all tool IDs should be unique', () => {
|
|
21
|
+
const ids = ALL_TOOLS.map((t) => t.entry.id);
|
|
22
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Tool Icons', () => {
|
|
27
|
+
it('all tools should have bg and fg icons', () => {
|
|
28
|
+
for (const tool of ALL_TOOLS) {
|
|
29
|
+
expect(tool.entry.icons.bg).toBeTruthy();
|
|
30
|
+
expect(tool.entry.icons.fg).toBeTruthy();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('Tool i18n', () => {
|
|
36
|
+
it('all tools should have es and en locales', () => {
|
|
37
|
+
for (const tool of ALL_TOOLS) {
|
|
38
|
+
expect(tool.entry.i18n.es).toBeDefined();
|
|
39
|
+
expect(tool.entry.i18n.en).toBeDefined();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('all tools should have Component, SEOComponent, BibliographyComponent', () => {
|
|
44
|
+
for (const tool of ALL_TOOLS) {
|
|
45
|
+
expect(tool.Component).toBeDefined();
|
|
46
|
+
expect(tool.SEOComponent).toBeDefined();
|
|
47
|
+
expect(tool.BibliographyComponent).toBeDefined();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { endurance } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'es' } = Astro.props;
|
|
11
|
+
const content = await endurance.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} title={content.ui.bibliographyTitle} />}
|