@jjlmoya/utils-aquarium 1.0.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 +56 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/i18n/de.ts +13 -0
- package/src/category/i18n/en.ts +13 -0
- package/src/category/i18n/es.ts +13 -0
- package/src/category/i18n/fr.ts +13 -0
- package/src/category/i18n/id.ts +13 -0
- package/src/category/i18n/it.ts +13 -0
- package/src/category/i18n/ja.ts +13 -0
- package/src/category/i18n/ko.ts +13 -0
- package/src/category/i18n/nl.ts +13 -0
- package/src/category/i18n/pl.ts +13 -0
- package/src/category/i18n/pt.ts +13 -0
- package/src/category/i18n/ru.ts +13 -0
- package/src/category/i18n/sv.ts +13 -0
- package/src/category/i18n/tr.ts +13 -0
- package/src/category/i18n/zh.ts +13 -0
- package/src/category/index.ts +24 -0
- package/src/category/seo.astro +12 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +14 -0
- package/src/entries.ts +7 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +20 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +65 -0
- package/src/pages/[locale].astro +69 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/faq_count.test.ts +13 -0
- package/src/tests/i18n_coverage.test.ts +14 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +29 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/seo_length.test.ts +26 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/spanish_leakage.test.ts +175 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +11 -0
- package/src/tests/translation_copy.test.ts +123 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/aquarium-tank-volume-water-change-calculator.css +391 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.astro +6 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.ts +14 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/component.astro +93 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/controller.ts +147 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/dom-views.ts +64 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/entry.ts +13 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/evaluator.ts +19 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/en.ts +186 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/index.ts +11 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.test.ts +60 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.ts +132 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/seo.astro +13 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/storage.ts +30 -0
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/ui.ts +41 -0
- package/src/tools.ts +9 -0
- package/src/types.ts +54 -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: #f43f5e;
|
|
57
|
+
--primary-base: #9f1239;
|
|
58
|
+
--cyan: #06b6d4;
|
|
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
|
+
font-family: Inter, sans-serif;}
|
|
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,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PreviewLayout from '../../layouts/PreviewLayout.astro';
|
|
3
|
+
import PreviewNavSidebar from '../../components/PreviewNavSidebar.astro';
|
|
4
|
+
import { ALL_TOOLS } from '../../index';
|
|
5
|
+
import { UtilityHeader, FAQSection, Bibliography, SEORenderer } from '@jjlmoya/utils-shared';
|
|
6
|
+
import type { KnownLocale, ToolLocaleContent } from '../../types';
|
|
7
|
+
import type { UtilitySEOContent } from '@jjlmoya/utils-shared';
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const paths = [];
|
|
11
|
+
for (const { entry, Component: lazyComponent } of ALL_TOOLS) {
|
|
12
|
+
const { default: Component } = await lazyComponent();
|
|
13
|
+
const localeContents = await Promise.all(Object.entries(entry.i18n).map(async ([locale, loader]) => ({
|
|
14
|
+
locale: locale as KnownLocale,
|
|
15
|
+
content: await loader(),
|
|
16
|
+
})));
|
|
17
|
+
const localeUrls = Object.fromEntries(localeContents.map(({ locale, content }) => [locale, '/' + locale + '/' + content.slug]));
|
|
18
|
+
for (const { locale, content } of localeContents) {
|
|
19
|
+
const allToolsNav = (await Promise.all(ALL_TOOLS.map(async ({ entry: navEntry }) => {
|
|
20
|
+
const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
|
|
21
|
+
if (loader === undefined) return null;
|
|
22
|
+
const navContent = await loader();
|
|
23
|
+
return { id: navEntry.id, title: navContent.title, href: '/' + locale + '/' + navContent.slug, isActive: navEntry.id === entry.id };
|
|
24
|
+
}))).filter((item): item is { id: string; title: string; href: string; isActive: boolean } => item !== null);
|
|
25
|
+
const englishLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
|
|
26
|
+
const englishSlug = englishLoader === undefined ? entry.id : (await englishLoader()).slug;
|
|
27
|
+
paths.push({ params: { locale, slug: content.slug }, props: { Component, locale, content, localeUrls, allToolsNav, englishSlug } });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return paths;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as {
|
|
34
|
+
Component: (_props: Record<string, unknown>) => unknown;
|
|
35
|
+
locale: KnownLocale;
|
|
36
|
+
content: ToolLocaleContent;
|
|
37
|
+
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
38
|
+
allToolsNav: { id: string; title: string; href: string; isActive?: boolean }[];
|
|
39
|
+
englishSlug: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const cssFiles = import.meta.glob('../../tool/**' + '/' + '*.css', { query: '?raw', import: 'default' });
|
|
43
|
+
const cssKey = Object.keys(cssFiles).find((key) => key.endsWith('/' + englishSlug + '.css'));
|
|
44
|
+
const cssLoader = cssKey === undefined ? undefined : cssFiles[cssKey];
|
|
45
|
+
const toolCss = cssLoader === undefined ? '' : await cssLoader() as string;
|
|
46
|
+
const seoContent: UtilitySEOContent = { locale, sections: content.seo };
|
|
47
|
+
const words = content.title.split(' ');
|
|
48
|
+
const titleHighlight = words[0] ?? '';
|
|
49
|
+
const titleBase = words.slice(1).join(' ');
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<PreviewLayout title={content.title} currentLocale={locale} localeUrls={localeUrls} hasSidebar={true}>
|
|
53
|
+
<PreviewNavSidebar slot="sidebar" categoryTitle="Aquarium tools" tools={allToolsNav} />
|
|
54
|
+
<Fragment slot="head">
|
|
55
|
+
{toolCss !== '' && <Fragment set:html={'<style is:inline>' + toolCss + '</style>'} />}
|
|
56
|
+
{content.schemas.map((schema: unknown) => <script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />)}
|
|
57
|
+
</Fragment>
|
|
58
|
+
<div class="tool-page">
|
|
59
|
+
<UtilityHeader titleHighlight={titleHighlight} titleBase={titleBase} description={content.description} />
|
|
60
|
+
<section class="section-tool"><Component ui={content.ui} /></section>
|
|
61
|
+
<section class="section-seo"><SEORenderer content={seoContent} /></section>
|
|
62
|
+
<section class="section-faq"><FAQSection items={content.faq} /></section>
|
|
63
|
+
<section class="section-bibliography"><Bibliography links={content.bibliography} /></section>
|
|
64
|
+
</div>
|
|
65
|
+
</PreviewLayout>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
import PreviewLayout from '../layouts/PreviewLayout.astro';
|
|
3
|
+
import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
|
|
4
|
+
import { aquariumCategory, 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'] as KnownLocale[];
|
|
10
|
+
return locales.map((locale) => ({ params: { locale } }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const currentLocale = (Astro.params.locale ?? 'en') as KnownLocale;
|
|
14
|
+
const categoryLoader = aquariumCategory.i18n[currentLocale] ?? aquariumCategory.i18n.en;
|
|
15
|
+
const categoryContent = categoryLoader === undefined ? {
|
|
16
|
+
slug: 'aquarium',
|
|
17
|
+
title: 'Aquarium Tools',
|
|
18
|
+
description: 'Aquarium volume tools',
|
|
19
|
+
seo: [],
|
|
20
|
+
} : await categoryLoader();
|
|
21
|
+
|
|
22
|
+
const toolsWithContent = await Promise.all(
|
|
23
|
+
ALL_TOOLS.map(async ({ entry, Component }) => {
|
|
24
|
+
const localeEntries = await Promise.all(
|
|
25
|
+
Object.entries(entry.i18n).map(async ([locale, loader]) => [locale, await loader()] as const),
|
|
26
|
+
);
|
|
27
|
+
const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent>;
|
|
28
|
+
const locale = localeContents[currentLocale] ?? localeContents.en;
|
|
29
|
+
const availableLocales: Record<string, string> = {};
|
|
30
|
+
for (const [localeCode, content] of Object.entries(localeContents)) {
|
|
31
|
+
availableLocales[localeCode] = '/' + localeCode + '/' + content.slug;
|
|
32
|
+
}
|
|
33
|
+
return { entry, Component, locale, availableLocales };
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<PreviewLayout title={categoryContent.title} currentLocale={currentLocale} hasSidebar={true}>
|
|
39
|
+
<PreviewNavSidebar
|
|
40
|
+
slot="sidebar"
|
|
41
|
+
categoryTitle={categoryContent.title}
|
|
42
|
+
tools={toolsWithContent.map(({ entry, locale, availableLocales }) => ({
|
|
43
|
+
id: entry.id,
|
|
44
|
+
title: locale?.title ?? entry.id,
|
|
45
|
+
href: availableLocales[currentLocale] ?? '#',
|
|
46
|
+
}))}
|
|
47
|
+
/>
|
|
48
|
+
<div class="dashboard">
|
|
49
|
+
<header class="preview-header">
|
|
50
|
+
<span class="badge">preview · @jjlmoya/utils-aquarium</span>
|
|
51
|
+
<h1>{categoryContent.title}</h1>
|
|
52
|
+
<p>{categoryContent.description}</p>
|
|
53
|
+
</header>
|
|
54
|
+
<div class="tool-list">
|
|
55
|
+
{toolsWithContent.map(({ entry, locale, availableLocales }) => (
|
|
56
|
+
<article class="tool-card">
|
|
57
|
+
<a href={availableLocales[currentLocale] ?? '#'} class="tool-card-link">
|
|
58
|
+
<div class="tool-icons"><div class="icon-wrapper bg"><Icon name={entry.icons.bg} /></div><div class="icon-wrapper fg"><Icon name={entry.icons.fg} /></div></div>
|
|
59
|
+
<div class="tool-card-content"><h2 class="tool-title">{locale?.title}</h2><p class="tool-description">{locale?.description}</p></div>
|
|
60
|
+
<div class="tool-card-meta"><span class="tool-id">{entry.id}</span></div>
|
|
61
|
+
</a>
|
|
62
|
+
<div class="tool-locales">
|
|
63
|
+
{Object.entries(availableLocales).map(([localeCode, url]) => <a href={url} class:list={{ active: localeCode === currentLocale }} class="locale-badge">{localeCode.toUpperCase()}</a>)}
|
|
64
|
+
</div>
|
|
65
|
+
</article>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</PreviewLayout>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
|
|
5
|
+
|
|
6
|
+
const DIACRITIC_RULES = {
|
|
7
|
+
de: {
|
|
8
|
+
language: 'German',
|
|
9
|
+
expectedCharacters: 'ä ö ü ß',
|
|
10
|
+
characters: /[äöüÄÖÜß]/g,
|
|
11
|
+
minPerThousandLetters: 0.1,
|
|
12
|
+
},
|
|
13
|
+
es: {
|
|
14
|
+
language: 'Spanish',
|
|
15
|
+
expectedCharacters: 'á é í ó ú ü ñ',
|
|
16
|
+
characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
|
|
17
|
+
minPerThousandLetters: 0.1,
|
|
18
|
+
},
|
|
19
|
+
fr: {
|
|
20
|
+
language: 'French',
|
|
21
|
+
expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
|
|
22
|
+
characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
|
|
23
|
+
minPerThousandLetters: 0.1,
|
|
24
|
+
},
|
|
25
|
+
it: {
|
|
26
|
+
language: 'Italian',
|
|
27
|
+
expectedCharacters: 'à è é ì í î ò ó ù ú',
|
|
28
|
+
characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
|
|
29
|
+
minPerThousandLetters: 0.1,
|
|
30
|
+
},
|
|
31
|
+
pl: {
|
|
32
|
+
language: 'Polish',
|
|
33
|
+
expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
|
|
34
|
+
characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
|
|
35
|
+
minPerThousandLetters: 0.1,
|
|
36
|
+
},
|
|
37
|
+
pt: {
|
|
38
|
+
language: 'Portuguese',
|
|
39
|
+
expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
|
|
40
|
+
characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
|
|
41
|
+
minPerThousandLetters: 0.1,
|
|
42
|
+
},
|
|
43
|
+
sv: {
|
|
44
|
+
language: 'Swedish',
|
|
45
|
+
expectedCharacters: 'å ä ö',
|
|
46
|
+
characters: /[åäöÅÄÖ]/g,
|
|
47
|
+
minPerThousandLetters: 0.1,
|
|
48
|
+
},
|
|
49
|
+
tr: {
|
|
50
|
+
language: 'Turkish',
|
|
51
|
+
expectedCharacters: 'ç ğ ı İ ö ş ü',
|
|
52
|
+
characters: /[çğıöşüÇĞİÖŞÜ]/g,
|
|
53
|
+
minPerThousandLetters: 0.1,
|
|
54
|
+
},
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const LETTERS = /\p{L}/gu;
|
|
58
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
59
|
+
|
|
60
|
+
function collectStrings(value: unknown): string[] {
|
|
61
|
+
if (typeof value === 'string') return [value];
|
|
62
|
+
if (!value || typeof value !== 'object') return [];
|
|
63
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
64
|
+
return Object.values(value).flatMap(collectStrings);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeText(value: unknown): string {
|
|
68
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
72
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function letterCount(text: string): number {
|
|
76
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
|
|
80
|
+
return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
|
|
84
|
+
const letters = letterCount(text);
|
|
85
|
+
if (letters === 0) return 0;
|
|
86
|
+
return diacriticCount(text, locale) / letters * 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('Diacritics density validation', () => {
|
|
90
|
+
ALL_TOOLS.forEach((tool) => {
|
|
91
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
92
|
+
Object.keys(DIACRITIC_RULES).forEach((locale) => {
|
|
93
|
+
it(`${locale} keeps the expected accent and special-letter set`, async () => {
|
|
94
|
+
const typedLocale = locale as LocaleWithDiacritics;
|
|
95
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
96
|
+
if (!loader) return;
|
|
97
|
+
|
|
98
|
+
const content = await loader();
|
|
99
|
+
const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
|
|
100
|
+
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
|
+
const letters = letterCount(text);
|
|
102
|
+
const matches = diacriticCount(text, typedLocale);
|
|
103
|
+
const density = diacriticsPerThousandLetters(text, typedLocale);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
density,
|
|
107
|
+
[
|
|
108
|
+
`Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
109
|
+
`The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
|
|
110
|
+
`This locale should include some of these characters: ${rule.expectedCharacters}.`,
|
|
111
|
+
'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
|
|
112
|
+
].join(' '),
|
|
113
|
+
).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
describe('FAQ Content Validation', () => {
|
|
5
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
6
|
+
it(entry.id + ' has enough FAQ coverage', async () => {
|
|
7
|
+
const loader = entry.i18n.en;
|
|
8
|
+
if (loader === undefined) return;
|
|
9
|
+
const content = await loader();
|
|
10
|
+
expect(content.faq.length).toBeGreaterThanOrEqual(3);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_LOCALES = ['en'];
|
|
5
|
+
|
|
6
|
+
describe('I18n Coverage Validation', () => {
|
|
7
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
8
|
+
describe('Tool: ' + entry.id, () => {
|
|
9
|
+
it('has the current phase locale set', () => {
|
|
10
|
+
expect(Object.keys(entry.i18n)).toEqual(EXPECTED_LOCALES);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const INVERTED_PUNCTUATION_LOCALES = {
|
|
5
|
+
es: {
|
|
6
|
+
language: 'Spanish',
|
|
7
|
+
questionStart: '¿',
|
|
8
|
+
questionEnd: '?',
|
|
9
|
+
exclamationStart: '¡',
|
|
10
|
+
exclamationEnd: '!',
|
|
11
|
+
},
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
|
|
15
|
+
|
|
16
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
17
|
+
|
|
18
|
+
function collectStrings(value: unknown): string[] {
|
|
19
|
+
if (typeof value === 'string') return [value];
|
|
20
|
+
if (!value || typeof value !== 'object') return [];
|
|
21
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
22
|
+
return Object.values(value).flatMap(collectStrings);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function translatableStrings(content: Record<string, unknown>): string[] {
|
|
26
|
+
return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sentenceStart(text: string, endIndex: number): string {
|
|
30
|
+
const beforeMark = text.slice(0, endIndex).trimEnd();
|
|
31
|
+
const boundary = Math.max(
|
|
32
|
+
beforeMark.lastIndexOf('.'),
|
|
33
|
+
beforeMark.lastIndexOf(':'),
|
|
34
|
+
beforeMark.lastIndexOf(';'),
|
|
35
|
+
beforeMark.lastIndexOf('\n'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return beforeMark.slice(boundary + 1).trimStart();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findMissingInvertedMarks(
|
|
42
|
+
text: string,
|
|
43
|
+
startMark: string,
|
|
44
|
+
endMark: string,
|
|
45
|
+
): string[] {
|
|
46
|
+
return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
|
|
47
|
+
.map((match) => sentenceStart(text, match.index ?? 0))
|
|
48
|
+
.filter((segment) => segment.length > 0 && !segment.includes(startMark))
|
|
49
|
+
.map((segment) => `${segment}${endMark}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('Inverted punctuation validation', () => {
|
|
53
|
+
ALL_TOOLS.forEach((tool) => {
|
|
54
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
55
|
+
Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
|
|
56
|
+
it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
|
|
57
|
+
const typedLocale = locale as InvertedPunctuationLocale;
|
|
58
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
59
|
+
if (!loader) return;
|
|
60
|
+
|
|
61
|
+
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
|
+
const content = await loader();
|
|
63
|
+
const strings = translatableStrings(content as unknown as Record<string, unknown>);
|
|
64
|
+
const missingQuestions = strings.flatMap((text) =>
|
|
65
|
+
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
|
+
);
|
|
67
|
+
const missingExclamations = strings.flatMap((text) =>
|
|
68
|
+
findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
|
|
69
|
+
);
|
|
70
|
+
const failures = [...missingQuestions, ...missingExclamations];
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
[
|
|
75
|
+
`Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
76
|
+
`Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
|
|
77
|
+
`Examples: ${failures.slice(0, 5).join(' | ')}`,
|
|
78
|
+
].join(' '),
|
|
79
|
+
).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('Locale Completeness and Slug Validation', () => {
|
|
6
|
+
it('all tools registered in ALL_TOOLS', () => {
|
|
7
|
+
expect(ALL_TOOLS.length).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
ALL_TOOLS.forEach((tool) => {
|
|
11
|
+
describe(`Tool entry: ${tool.entry.id}`, () => {
|
|
12
|
+
it('every registered locale content must have a valid non-empty slug', async () => {
|
|
13
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
14
|
+
expect(locales.length).toBeGreaterThan(0);
|
|
15
|
+
|
|
16
|
+
for (const locale of locales) {
|
|
17
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
18
|
+
expect(loader).toBeDefined();
|
|
19
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
20
|
+
expect(content, `Locale "${locale}" for tool "${tool.entry.id}" failed to load content object`).toBeDefined();
|
|
21
|
+
expect(content.slug, `Locale "${locale}" for tool "${tool.entry.id}" has undefined or empty slug`).toBeDefined();
|
|
22
|
+
expect(typeof content.slug).toBe('string');
|
|
23
|
+
expect(content.slug.length).toBeGreaterThan(0);
|
|
24
|
+
expect(content.title, `Locale "${locale}" for tool "${tool.entry.id}" has undefined title`).toBeDefined();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) {
|
|
8
|
+
return results;
|
|
9
|
+
}
|
|
10
|
+
const list = fs.readdirSync(dir);
|
|
11
|
+
for (const file of list) {
|
|
12
|
+
const fullPath = path.join(dir, file);
|
|
13
|
+
const stat = fs.statSync(fullPath);
|
|
14
|
+
if (stat && stat.isDirectory()) {
|
|
15
|
+
if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
|
|
16
|
+
results.push(...getFiles(fullPath));
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
results.push(fullPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isContentFile(filePath: string): boolean {
|
|
26
|
+
return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const srcDir = path.join(process.cwd(), 'src');
|
|
30
|
+
const scriptsDir = path.join(process.cwd(), 'scripts');
|
|
31
|
+
const filesToTest = [
|
|
32
|
+
...getFiles(srcDir).filter(isContentFile),
|
|
33
|
+
...getFiles(scriptsDir).filter(isContentFile),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const aiTypographyGarbage = [
|
|
37
|
+
'\u2013',
|
|
38
|
+
'\u2014',
|
|
39
|
+
'\u2026',
|
|
40
|
+
'\u201C',
|
|
41
|
+
'\u201D',
|
|
42
|
+
'\u2018',
|
|
43
|
+
'\u2019',
|
|
44
|
+
'\u00AB',
|
|
45
|
+
'\u00BB',
|
|
46
|
+
'\u200B',
|
|
47
|
+
'\u201E',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('Typography Garbage Character Validation', () => {
|
|
51
|
+
filesToTest.forEach((filePath) => {
|
|
52
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
53
|
+
it(`should not contain typography garbage characters in ${relativePath}`, () => {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
55
|
+
const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
|
|
56
|
+
expect(hasAiPatterns).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`should not contain space before colon in ${relativePath}`, () => {
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const spaceBeforeColon = / : /.test(content);
|
|
62
|
+
expect(spaceBeforeColon).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`should not contain double hyphen in ${relativePath}`, () => {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
expect(content).not.toContain('--');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const EXCLUDED_DIRS = ['node_modules', 'pages', 'layouts'];
|
|
6
|
+
|
|
7
|
+
function findAstroFiles(dir: string): string[] {
|
|
8
|
+
const files: string[] = [];
|
|
9
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const fullPath = join(dir, entry.name);
|
|
13
|
+
if (entry.isDirectory() && !EXCLUDED_DIRS.includes(entry.name)) {
|
|
14
|
+
files.push(...findAstroFiles(fullPath));
|
|
15
|
+
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
16
|
+
files.push(fullPath);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasH1(content: string): boolean {
|
|
24
|
+
return /<h1[\s>]/i.test(content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const srcDir = join(process.cwd(), 'src');
|
|
28
|
+
const astroFiles = findAstroFiles(srcDir);
|
|
29
|
+
|
|
30
|
+
describe('No H1 in Components', () => {
|
|
31
|
+
if (astroFiles.length === 0) {
|
|
32
|
+
it('no astro components found', () => {
|
|
33
|
+
expect(true).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
astroFiles.forEach((file) => {
|
|
38
|
+
const relativePath = file.replace(process.cwd(), '');
|
|
39
|
+
it(`${relativePath} should not contain <h1>`, () => {
|
|
40
|
+
const content = readFileSync(file, 'utf-8');
|
|
41
|
+
expect(
|
|
42
|
+
hasH1(content),
|
|
43
|
+
`File "${relativePath}" contains a <h1> element. Use <h2> or lower inside components — h1 belongs to the page layout.`,
|
|
44
|
+
).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|