@jjlmoya/utils-pets 1.1.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 +60 -0
- package/src/category/i18n/en.ts +48 -0
- package/src/category/i18n/es.ts +48 -0
- package/src/category/i18n/fr.ts +48 -0
- package/src/category/index.ts +13 -0
- package/src/category/seo.astro +14 -0
- package/src/components/PreviewNavSidebar.astro +115 -0
- package/src/components/PreviewToolbar.astro +142 -0
- package/src/data.ts +15 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +20 -0
- package/src/layouts/PreviewLayout.astro +116 -0
- package/src/pages/[locale]/[slug].astro +148 -0
- package/src/pages/[locale].astro +270 -0
- package/src/pages/index.astro +3 -0
- package/src/tests/faq_count.test.ts +23 -0
- package/src/tests/mocks/astro_mock.js +1 -0
- package/src/tests/seo_length.test.ts +54 -0
- package/src/tests/tool_validation.test.ts +139 -0
- package/src/tool/petAge/bibliography.astro +14 -0
- package/src/tool/petAge/component.astro +267 -0
- package/src/tool/petAge/handlers.ts +142 -0
- package/src/tool/petAge/i18n/en.ts +160 -0
- package/src/tool/petAge/i18n/es.ts +160 -0
- package/src/tool/petAge/i18n/fr.ts +160 -0
- package/src/tool/petAge/index.ts +64 -0
- package/src/tool/petAge/logic.ts +59 -0
- package/src/tool/petAge/seo.astro +61 -0
- package/src/tool/petAge/style.css +649 -0
- package/src/tool/petRation/bibliography.astro +14 -0
- package/src/tool/petRation/component.astro +181 -0
- package/src/tool/petRation/handlers.ts +139 -0
- package/src/tool/petRation/i18n/en.ts +161 -0
- package/src/tool/petRation/i18n/es.ts +161 -0
- package/src/tool/petRation/i18n/fr.ts +161 -0
- package/src/tool/petRation/index.ts +63 -0
- package/src/tool/petRation/logic.ts +44 -0
- package/src/tool/petRation/seo.astro +61 -0
- package/src/tool/petRation/style.css +427 -0
- package/src/tools.ts +13 -0
- package/src/types.ts +71 -0
|
@@ -0,0 +1,148 @@
|
|
|
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) => (
|
|
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} inLanguage={locale} title={content.faqTitle} />
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section class="section-bibliography">
|
|
124
|
+
<Bibliography links={content.bibliography} title={content.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
|
+
|
|
136
|
+
.section-tool {
|
|
137
|
+
max-width: 1200px;
|
|
138
|
+
margin: 0 auto;
|
|
139
|
+
width: 100%;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.section-seo,
|
|
143
|
+
.section-faq,
|
|
144
|
+
.section-bibliography {
|
|
145
|
+
padding-top: 2rem;
|
|
146
|
+
border-top: 1px solid var(--border-color);
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PreviewLayout from '../layouts/PreviewLayout.astro';
|
|
3
|
+
import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
|
|
4
|
+
import { petsCategory, 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 categoryContent = await petsCategory.i18n[currentLocale]!();
|
|
16
|
+
|
|
17
|
+
const tools = ALL_TOOLS || [];
|
|
18
|
+
|
|
19
|
+
const toolsWithContent = tools.length > 0
|
|
20
|
+
? await Promise.all(
|
|
21
|
+
tools.map(async ({ entry, Component }) => {
|
|
22
|
+
const languages = Object.keys(entry.i18n);
|
|
23
|
+
const localeEntries = await Promise.all(
|
|
24
|
+
languages.map(async (l) => {
|
|
25
|
+
const content = await entry.i18n[l as KnownLocale]!();
|
|
26
|
+
return [l, content];
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent<Record<string, string>>>;
|
|
31
|
+
|
|
32
|
+
const currentLocaleContent = localeContents[currentLocale] || localeContents['en'] || localeContents['es'];
|
|
33
|
+
const availableLocales: Record<string, string> = {};
|
|
34
|
+
|
|
35
|
+
for (const l of languages) {
|
|
36
|
+
const lCont = localeContents[l];
|
|
37
|
+
if (lCont) {
|
|
38
|
+
availableLocales[l] = `/${l}/${lCont.slug}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { entry, Component, locale: currentLocaleContent, availableLocales };
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
: [];
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<PreviewLayout
|
|
49
|
+
title={categoryContent.title}
|
|
50
|
+
currentLocale={currentLocale}
|
|
51
|
+
hasSidebar={true}
|
|
52
|
+
>
|
|
53
|
+
<PreviewNavSidebar
|
|
54
|
+
slot="sidebar"
|
|
55
|
+
categoryTitle={categoryContent.title}
|
|
56
|
+
tools={toolsWithContent.map(({ entry, locale, availableLocales }) => {
|
|
57
|
+
const href = availableLocales[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#');
|
|
58
|
+
return {
|
|
59
|
+
id: entry.id,
|
|
60
|
+
title: locale?.title || entry.id,
|
|
61
|
+
href: href,
|
|
62
|
+
};
|
|
63
|
+
})}
|
|
64
|
+
/>
|
|
65
|
+
<div class="dashboard">
|
|
66
|
+
<header class="preview-header">
|
|
67
|
+
<span class="badge">preview · @jjlmoya/utils-pets</span>
|
|
68
|
+
<h1>{categoryContent.title}</h1>
|
|
69
|
+
<p>{categoryContent.description}</p>
|
|
70
|
+
</header>
|
|
71
|
+
|
|
72
|
+
<div class="tool-list">
|
|
73
|
+
{toolsWithContent?.map(({ entry, locale, availableLocales }) => (
|
|
74
|
+
<article class="tool-card">
|
|
75
|
+
<a href={availableLocales?.[currentLocale] || (locale ? `/${currentLocale}/${locale.slug}` : '#')} class="tool-card-link">
|
|
76
|
+
<div class="tool-icons">
|
|
77
|
+
<div class="icon-wrapper bg">
|
|
78
|
+
<Icon name={entry.icons.bg} />
|
|
79
|
+
</div>
|
|
80
|
+
<div class="icon-wrapper fg">
|
|
81
|
+
<Icon name={entry.icons.fg} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="tool-card-content">
|
|
85
|
+
<h2 class="tool-title">{locale?.title}</h2>
|
|
86
|
+
<p class="tool-description">{locale?.description}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="tool-card-meta">
|
|
89
|
+
<span class="tool-id">{entry.id}</span>
|
|
90
|
+
</div>
|
|
91
|
+
</a>
|
|
92
|
+
|
|
93
|
+
{availableLocales && Object.keys(availableLocales).length > 1 && (
|
|
94
|
+
<div class="tool-locales">
|
|
95
|
+
{Object.entries(availableLocales).map(([l, url]) => (
|
|
96
|
+
<a href={url} class="locale-badge" title={`Ver en ${l.toUpperCase()}`} class:list={{ active: l === currentLocale }}>
|
|
97
|
+
{l.toUpperCase()}
|
|
98
|
+
</a>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</article>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</PreviewLayout>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
.dashboard {
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: 5rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.preview-header {
|
|
116
|
+
text-align: center;
|
|
117
|
+
padding-bottom: 3rem;
|
|
118
|
+
border-bottom: 1px solid var(--border-color);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.badge {
|
|
122
|
+
display: inline-block;
|
|
123
|
+
padding: 0.25rem 0.75rem;
|
|
124
|
+
background: var(--accent);
|
|
125
|
+
border-radius: 99px;
|
|
126
|
+
font-size: 0.7rem;
|
|
127
|
+
font-weight: 800;
|
|
128
|
+
margin-bottom: 1.5rem;
|
|
129
|
+
color: var(--text-base);
|
|
130
|
+
letter-spacing: 0.05em;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
h1 {
|
|
134
|
+
font-size: clamp(2rem, 6vw, 3.5rem);
|
|
135
|
+
font-weight: 900;
|
|
136
|
+
margin: 0 0 1rem;
|
|
137
|
+
background: linear-gradient(to bottom, var(--text-base), var(--text-muted));
|
|
138
|
+
-webkit-background-clip: text;
|
|
139
|
+
-webkit-text-fill-color: transparent;
|
|
140
|
+
background-clip: text;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.preview-header p {
|
|
144
|
+
color: var(--text-muted);
|
|
145
|
+
font-size: 1.1rem;
|
|
146
|
+
margin: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.tool-list {
|
|
150
|
+
display: grid;
|
|
151
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
152
|
+
gap: 2rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.tool-card {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
gap: 1rem;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.tool-card-link {
|
|
162
|
+
flex: 1;
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-direction: column;
|
|
165
|
+
padding: 1.5rem;
|
|
166
|
+
background: var(--bg-surface);
|
|
167
|
+
border: 1px solid var(--border-color);
|
|
168
|
+
border-radius: 0.75rem;
|
|
169
|
+
text-decoration: none;
|
|
170
|
+
transition: all 0.2s ease;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.tool-card-link:hover {
|
|
174
|
+
border-color: var(--accent);
|
|
175
|
+
background: rgba(244, 63, 94, 0.05);
|
|
176
|
+
transform: translateY(-2px);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.tool-icons {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 1rem;
|
|
183
|
+
margin-bottom: 1.25rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.icon-wrapper {
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
width: 3rem;
|
|
191
|
+
height: 3rem;
|
|
192
|
+
border-radius: 0.5rem;
|
|
193
|
+
font-size: 1.5rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.icon-wrapper.bg {
|
|
197
|
+
background: var(--accent);
|
|
198
|
+
color: var(--text-base);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.icon-wrapper.fg {
|
|
202
|
+
background: var(--bg-page);
|
|
203
|
+
border: 1px solid var(--border-color);
|
|
204
|
+
color: var(--accent);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.tool-card-content {
|
|
208
|
+
flex: 1;
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.tool-title {
|
|
214
|
+
font-size: 1.25rem;
|
|
215
|
+
font-weight: 700;
|
|
216
|
+
margin: 0 0 0.5rem;
|
|
217
|
+
color: var(--text-base);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.tool-description {
|
|
221
|
+
font-size: 0.9375rem;
|
|
222
|
+
color: var(--text-muted);
|
|
223
|
+
line-height: 1.5;
|
|
224
|
+
margin: 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.tool-card-meta {
|
|
228
|
+
padding-top: 1rem;
|
|
229
|
+
border-top: 1px solid var(--border-color);
|
|
230
|
+
margin-top: 1.5rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.tool-id {
|
|
234
|
+
display: inline-block;
|
|
235
|
+
font-size: 0.7rem;
|
|
236
|
+
background: var(--bg-page);
|
|
237
|
+
border: 1px solid var(--border-color);
|
|
238
|
+
padding: 0.35rem 0.75rem;
|
|
239
|
+
border-radius: 0.4rem;
|
|
240
|
+
color: var(--accent);
|
|
241
|
+
font-weight: 600;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.tool-locales {
|
|
245
|
+
display: flex;
|
|
246
|
+
gap: 0.5rem;
|
|
247
|
+
flex-wrap: wrap;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.locale-badge {
|
|
251
|
+
display: inline-block;
|
|
252
|
+
padding: 0.4rem 0.85rem;
|
|
253
|
+
background: var(--bg-page);
|
|
254
|
+
border: 1px solid var(--border-color);
|
|
255
|
+
border-radius: 0.4rem;
|
|
256
|
+
color: var(--text-muted);
|
|
257
|
+
text-decoration: none;
|
|
258
|
+
font-size: 0.75rem;
|
|
259
|
+
font-weight: 600;
|
|
260
|
+
text-transform: uppercase;
|
|
261
|
+
letter-spacing: 0.05em;
|
|
262
|
+
transition: all 0.15s ease;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.locale-badge:hover, .locale-badge.active {
|
|
266
|
+
color: var(--accent);
|
|
267
|
+
border-color: var(--accent);
|
|
268
|
+
background: rgba(244, 63, 94, 0.1);
|
|
269
|
+
}
|
|
270
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const TOOLS = [DATA.petAge, DATA.petRation];
|
|
5
|
+
|
|
6
|
+
describe('FAQ Content Validation', () => {
|
|
7
|
+
TOOLS.forEach((entry) => {
|
|
8
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
9
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
10
|
+
it(`${locale}: should have at least 3 FAQ items`, async () => {
|
|
11
|
+
const loader = (entry.i18n as any)[locale];
|
|
12
|
+
const content = await loader();
|
|
13
|
+
|
|
14
|
+
if (!content.faq) {
|
|
15
|
+
throw new Error(`Tool ${entry.id} (${locale}) is missing the 'faq' property entirely.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
expect(content.faq.length, `Tool ${entry.id} (${locale}) has only ${content.faq.length} FAQs, minimum 3 required.`).toBeGreaterThanOrEqual(3);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function () { return null; }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const ENTRIES = [
|
|
5
|
+
{ id: 'petAge', i18n: DATA.petAge.i18n },
|
|
6
|
+
{ id: 'petRation', i18n: DATA.petRation.i18n },
|
|
7
|
+
{ id: 'petsCategory', i18n: DATA.petsCategory.i18n },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe('SEO Content Length Validation', () => {
|
|
11
|
+
ENTRIES.forEach((entry) => {
|
|
12
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
13
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
14
|
+
it(`${locale}: SEO section should contain between 400 and 900 words`, async () => {
|
|
15
|
+
const loader = (entry.i18n as any)[locale];
|
|
16
|
+
const content = await loader();
|
|
17
|
+
if (!content.seo) return;
|
|
18
|
+
|
|
19
|
+
let combinedText = '';
|
|
20
|
+
content.seo.forEach((section: any) => {
|
|
21
|
+
if (section.text) combinedText += section.text + ' ';
|
|
22
|
+
if (section.html) combinedText += section.html + ' ';
|
|
23
|
+
if (section.title) combinedText += section.title + ' ';
|
|
24
|
+
if (section.items) {
|
|
25
|
+
section.items.forEach((item: any) => {
|
|
26
|
+
if (typeof item === 'string') combinedText += item + ' ';
|
|
27
|
+
else {
|
|
28
|
+
if (item.label) combinedText += item.label + ' ';
|
|
29
|
+
if (item.value) combinedText += item.value + ' ';
|
|
30
|
+
if (item.term) combinedText += item.term + ' ';
|
|
31
|
+
if (item.definition) combinedText += item.definition + ' ';
|
|
32
|
+
if (item.pro) combinedText += item.pro + ' ';
|
|
33
|
+
if (item.con) combinedText += item.con + ' ';
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (section.headers) combinedText += section.headers.join(' ') + ' ';
|
|
38
|
+
if (section.rows) {
|
|
39
|
+
section.rows.forEach((row: string[]) => {
|
|
40
|
+
combinedText += row.join(' ') + ' ';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const cleanText = combinedText.replace(/<[^>]*>?/gm, ' ').trim();
|
|
46
|
+
const words = cleanText.split(/\s+/).filter((w: string) => w.length > 0);
|
|
47
|
+
|
|
48
|
+
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeGreaterThanOrEqual(400);
|
|
49
|
+
expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeLessThanOrEqual(900);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import { petsCategory } from '../data';
|
|
6
|
+
import type { ToolDefinition } from '../types';
|
|
7
|
+
|
|
8
|
+
function extractSeoText(sections: any[]): string {
|
|
9
|
+
return sections
|
|
10
|
+
.map((section) => extractSectionText(section))
|
|
11
|
+
.join(' ');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractHtmlOrText(section: any): string {
|
|
15
|
+
const hasContent = 'html' in section || 'text' in section;
|
|
16
|
+
const isNotTable = section.type !== 'table';
|
|
17
|
+
return hasContent && isNotTable ? (section.html || section.text || '') : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractListItems(section: any): string {
|
|
21
|
+
return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractTableRows(section: any): string {
|
|
25
|
+
return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractSectionText(section: any): string {
|
|
29
|
+
let text = '';
|
|
30
|
+
text += extractHtmlOrText(section);
|
|
31
|
+
text += extractListItems(section);
|
|
32
|
+
text += extractTableRows(section);
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function countWords(text: string): number {
|
|
37
|
+
return text
|
|
38
|
+
.replace(/<[^>]*>/g, '')
|
|
39
|
+
.trim()
|
|
40
|
+
.split(/\s+/)
|
|
41
|
+
.filter((w) => w.length > 0).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('Tool Validation Suite', () => {
|
|
45
|
+
describe('Strict Tool Definition Validation', () => {
|
|
46
|
+
ALL_TOOLS.forEach((tool: ToolDefinition) => {
|
|
47
|
+
const { entry } = tool;
|
|
48
|
+
|
|
49
|
+
describe(`${entry.id} structure`, () => {
|
|
50
|
+
it('should fulfill ToolDefinition interface', () => {
|
|
51
|
+
expect(tool.entry).toBeDefined();
|
|
52
|
+
expect(tool.Component).toBeDefined();
|
|
53
|
+
expect(tool.SEOComponent).toBeDefined();
|
|
54
|
+
expect(tool.BibliographyComponent).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should have valid ID in kebab-case', () => {
|
|
58
|
+
expect(entry.id).toMatch(/^[a-z0-9]+-?[a-z0-9]*$/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should have valid icons with bg and fg', () => {
|
|
62
|
+
expect(entry.icons.bg).toMatch(/^mdi:/);
|
|
63
|
+
expect(entry.icons.fg).toMatch(/^mdi:/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('i18n Content Validation', () => {
|
|
67
|
+
Object.entries(entry.i18n).forEach(([locale, loader]) => {
|
|
68
|
+
it(`should load ${locale} content and follow slug rules`, async () => {
|
|
69
|
+
const content = await loader();
|
|
70
|
+
expect(content.slug).toBeDefined();
|
|
71
|
+
expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
72
|
+
|
|
73
|
+
if (locale === 'es') {
|
|
74
|
+
const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas'];
|
|
75
|
+
expect(validSlugs).toContain(content.slug);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it(`should have valid basic content in ${locale}`, async () => {
|
|
80
|
+
const content = await loader();
|
|
81
|
+
expect(content.title.length).toBeGreaterThan(0);
|
|
82
|
+
expect(content.description.length).toBeGreaterThan(0);
|
|
83
|
+
expect(Object.keys(content.ui).length).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it(`should have SEO content with > 300 words in ${locale}`, async () => {
|
|
87
|
+
const content = await loader();
|
|
88
|
+
const seoText = extractSeoText(content.seo);
|
|
89
|
+
const wordCount = countWords(seoText);
|
|
90
|
+
expect(wordCount).toBeGreaterThanOrEqual(300);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Library Registration', () => {
|
|
99
|
+
it('should have 2 tools in ALL_TOOLS', () => {
|
|
100
|
+
expect(ALL_TOOLS.length).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should have all tools in petsCategory', () => {
|
|
104
|
+
expect(petsCategory.tools.length).toBe(2);
|
|
105
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
106
|
+
const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
|
|
107
|
+
expect(exists).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('Binary Consistency', () => {
|
|
113
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
114
|
+
it(`${entry.id} files should exist in the tool folder`, () => {
|
|
115
|
+
const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
|
|
116
|
+
const toolDir = path.join('src', 'tool', toolVarName);
|
|
117
|
+
|
|
118
|
+
expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
|
|
119
|
+
expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
|
|
120
|
+
expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
|
|
121
|
+
expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Bibliography Components Structure', () => {
|
|
127
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
128
|
+
it(`${entry.id} should have a standard bibliography structure`, () => {
|
|
129
|
+
const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
|
|
130
|
+
const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
|
|
131
|
+
const content = fs.readFileSync(componentPath, 'utf-8');
|
|
132
|
+
|
|
133
|
+
expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
|
|
134
|
+
expect(content).toContain(`import { ${toolVarName} } from './index'`);
|
|
135
|
+
expect(content).toContain('<SharedBibliography links={content.bibliography} />');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { petAge } 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 petAge.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|