@jjlmoya/utils-nautical 1.22.0 → 1.24.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 +2 -2
- package/src/category/i18n/en.ts +1 -1
- package/src/category/index.ts +2 -1
- package/src/data.ts +1 -0
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/layouts/PreviewLayout.astro +118 -118
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/faq_count.test.ts +30 -30
- package/src/tests/i18n_coverage.test.ts +6 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/seo_length.test.ts +29 -29
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tests/tool_validation.test.ts +54 -51
- package/src/tool/endurance/i18n/fr.ts +205 -205
- package/src/tool/endurance/seo.astro +15 -15
- package/src/tool/hullSpeed/bibliography.astro +14 -0
- package/src/tool/hullSpeed/bibliography.ts +12 -0
- package/src/tool/hullSpeed/component.astro +116 -0
- package/src/tool/hullSpeed/controller.ts +204 -0
- package/src/tool/hullSpeed/dom-views.ts +141 -0
- package/src/tool/hullSpeed/entry.ts +32 -0
- package/src/tool/hullSpeed/evaluator.ts +20 -0
- package/src/tool/hullSpeed/hull-draw.ts +185 -0
- package/src/tool/hullSpeed/i18n/de.ts +199 -0
- package/src/tool/hullSpeed/i18n/en.ts +199 -0
- package/src/tool/hullSpeed/i18n/es.ts +199 -0
- package/src/tool/hullSpeed/i18n/fr.ts +199 -0
- package/src/tool/hullSpeed/i18n/id.ts +199 -0
- package/src/tool/hullSpeed/i18n/it.ts +199 -0
- package/src/tool/hullSpeed/i18n/ja.ts +199 -0
- package/src/tool/hullSpeed/i18n/ko.ts +199 -0
- package/src/tool/hullSpeed/i18n/nl.ts +199 -0
- package/src/tool/hullSpeed/i18n/pl.ts +199 -0
- package/src/tool/hullSpeed/i18n/pt.ts +199 -0
- package/src/tool/hullSpeed/i18n/ru.ts +199 -0
- package/src/tool/hullSpeed/i18n/sv.ts +199 -0
- package/src/tool/hullSpeed/i18n/tr.ts +199 -0
- package/src/tool/hullSpeed/i18n/zh.ts +199 -0
- package/src/tool/hullSpeed/index.ts +11 -0
- package/src/tool/hullSpeed/logic.test.ts +105 -0
- package/src/tool/hullSpeed/logic.ts +116 -0
- package/src/tool/hullSpeed/presets.ts +17 -0
- package/src/tool/hullSpeed/sailboat-hull-speed-calculator.css +504 -0
- package/src/tool/hullSpeed/seo.astro +15 -0
- package/src/tool/hullSpeed/storage.test.ts +23 -0
- package/src/tool/hullSpeed/storage.ts +65 -0
- package/src/tool/hullSpeed/ui.ts +43 -0
- package/src/tool/nauticalConverter/seo.astro +15 -15
- package/src/tool/sailArea/seo.astro +15 -15
- package/src/tool/speedConverter/i18n/fr.ts +259 -259
- package/src/tool/speedConverter/seo.astro +15 -15
- package/src/tool/tideCalculator/seo.astro +15 -15
- package/src/tool/underKeel/seo.astro +15 -15
- package/src/tools.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-nautical",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
|
-
"test": "vitest run",
|
|
30
|
+
"test": "vitest run --reporter=verbose --testTimeout=30000",
|
|
31
31
|
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
package/src/category/i18n/en.ts
CHANGED
|
@@ -63,7 +63,7 @@ const seo: CategoryLocaleContent['seo'] = [
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
type: 'paragraph',
|
|
66
|
-
html: 'Even in
|
|
66
|
+
html: 'Even in certification exams for nautical qualifications (such as the RYA Dayskipper or Yachtmaster), chart calculations remain mandatory. Digital tools serve as a study companion, allowing candidates to check their dead reckoning exercises, tidal calculations and compass corrections.',
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
type: 'paragraph',
|
package/src/category/index.ts
CHANGED
|
@@ -6,12 +6,13 @@ import { sailArea } from '../tool/sailArea/entry';
|
|
|
6
6
|
import { speedConverter } from '../tool/speedConverter/entry';
|
|
7
7
|
import { endurance } from '../tool/endurance/entry';
|
|
8
8
|
import { anchorScope } from '../tool/anchorScope/entry';
|
|
9
|
+
import { hullSpeed } from '../tool/hullSpeed/entry';
|
|
9
10
|
|
|
10
11
|
export type { CategoryLocaleContent };
|
|
11
12
|
|
|
12
13
|
export const nauticalCategory: NauticalCategoryEntry = {
|
|
13
14
|
icon: 'mdi:sail-boat',
|
|
14
|
-
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance, anchorScope],
|
|
15
|
+
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance, anchorScope, hullSpeed],
|
|
15
16
|
i18n: {
|
|
16
17
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
17
18
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/data.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { NauticalConverterUI, NauticalConverterLocaleContent } from './tool
|
|
|
8
8
|
export type { SailAreaUI, SailAreaLocaleContent } from './tool/sailArea';
|
|
9
9
|
export type { SpeedConverterUI, SpeedConverterLocaleContent } from './tool/speedConverter';
|
|
10
10
|
export type { EnduranceUI, EnduranceLocaleContent } from './tool/endurance';
|
|
11
|
+
export type { HullSpeedUI, HullSpeedLocaleContent } from './tool/hullSpeed';
|
|
11
12
|
|
|
12
13
|
export type {
|
|
13
14
|
KnownLocale,
|
package/src/entries.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type { UnderKeelUI, UnderKeelLocaleContent } from './tool/underKeel/entry
|
|
|
13
13
|
export { anchorScope } from './tool/anchorScope/entry';
|
|
14
14
|
export type { AnchorScopeLocaleContent } from './tool/anchorScope/entry';
|
|
15
15
|
export type { AnchorScopeUI } from './tool/anchorScope/ui';
|
|
16
|
+
export { hullSpeed } from './tool/hullSpeed/entry';
|
|
17
|
+
export type { HullSpeedUI, HullSpeedLocaleContent } from './tool/hullSpeed/entry';
|
|
16
18
|
export { nauticalCategory } from './category';
|
|
17
19
|
import { endurance } from './tool/endurance/entry';
|
|
18
20
|
import { nauticalConverter } from './tool/nauticalConverter/entry';
|
|
@@ -21,4 +23,5 @@ import { speedConverter } from './tool/speedConverter/entry';
|
|
|
21
23
|
import { tideCalculator } from './tool/tideCalculator/entry';
|
|
22
24
|
import { underKeel } from './tool/underKeel/entry';
|
|
23
25
|
import { anchorScope } from './tool/anchorScope/entry';
|
|
24
|
-
|
|
26
|
+
import { hullSpeed } from './tool/hullSpeed/entry';
|
|
27
|
+
export const ALL_ENTRIES = [endurance, nauticalConverter, sailArea, speedConverter, tideCalculator, underKeel, anchorScope, hullSpeed];
|
package/src/index.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { sailArea, SAIL_AREA_TOOL } from './tool/sailArea';
|
|
|
23
23
|
export { speedConverter, SPEED_CONVERTER_TOOL } from './tool/speedConverter';
|
|
24
24
|
export { endurance, ENDURANCE_TOOL } from './tool/endurance';
|
|
25
25
|
export { anchorScope, ANCHOR_SCOPE_TOOL } from './tool/anchorScope';
|
|
26
|
+
export { hullSpeed, HULL_SPEED_TOOL } from './tool/hullSpeed';
|
|
@@ -1,118 +1,118 @@
|
|
|
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
|
-
font-family: Inter, sans-serif;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
main {
|
|
85
|
-
padding: 0 2rem;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.page-wrapper {
|
|
89
|
-
display: flex;
|
|
90
|
-
flex-direction: column;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
.page-wrapper.with-sidebar {
|
|
94
|
-
display: grid;
|
|
95
|
-
grid-template-columns: 240px 1fr;
|
|
96
|
-
min-height: 100vh;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.sidebar-area {
|
|
100
|
-
position: sticky;
|
|
101
|
-
top: 0;
|
|
102
|
-
height: 100vh;
|
|
103
|
-
overflow-y: auto;
|
|
104
|
-
border-right: 1px solid var(--border-color);
|
|
105
|
-
background: var(--bg-page);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
@media (max-width: 768px) {
|
|
109
|
-
.page-wrapper.with-sidebar {
|
|
110
|
-
grid-template-columns: 1fr;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.sidebar-area {
|
|
114
|
-
display: none;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
</style>
|
|
118
|
-
|
|
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
|
+
font-family: Inter, sans-serif;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main {
|
|
85
|
+
padding: 0 2rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.page-wrapper {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.page-wrapper.with-sidebar {
|
|
94
|
+
display: grid;
|
|
95
|
+
grid-template-columns: 240px 1fr;
|
|
96
|
+
min-height: 100vh;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.sidebar-area {
|
|
100
|
+
position: sticky;
|
|
101
|
+
top: 0;
|
|
102
|
+
height: 100vh;
|
|
103
|
+
overflow-y: auto;
|
|
104
|
+
border-right: 1px solid var(--border-color);
|
|
105
|
+
background: var(--bg-page);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@media (max-width: 768px) {
|
|
109
|
+
.page-wrapper.with-sidebar {
|
|
110
|
+
grid-template-columns: 1fr;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.sidebar-area {
|
|
114
|
+
display: none;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
118
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findBibliographyAstroFiles(dir: string): string[] {
|
|
6
|
+
const files: string[] = [];
|
|
7
|
+
if (!existsSync(dir)) return files;
|
|
8
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...findBibliographyAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'bibliography.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const bibliographyFiles = findBibliographyAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('Bibliography Component Wellformed Export', () => {
|
|
26
|
+
it('found tool bibliography.astro components', () => {
|
|
27
|
+
expect(bibliographyFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
bibliographyFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSharedComponent =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('Bibliography') || content.includes('SharedBibliography'));
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
usesSharedComponent,
|
|
42
|
+
`File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CategoryLocaleContent } from '../types';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_LOCALES = [
|
|
5
|
+
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
|
|
6
|
+
] as const;
|
|
7
|
+
const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
|
|
8
|
+
|
|
9
|
+
const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
|
|
10
|
+
string,
|
|
11
|
+
{ content: CategoryLocaleContent }
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
function textFrom(value: unknown): string {
|
|
15
|
+
if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
|
|
16
|
+
if (Array.isArray(value)) return value.map(textFrom).join(' ');
|
|
17
|
+
if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
|
|
22
|
+
|
|
23
|
+
function getContents(): Map<string, CategoryLocaleContent> {
|
|
24
|
+
return new Map(
|
|
25
|
+
Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateContent(locale: string, content: CategoryLocaleContent): string[] {
|
|
30
|
+
const failures: string[] = [];
|
|
31
|
+
const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
|
|
32
|
+
const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
|
|
33
|
+
const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
|
|
34
|
+
check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
|
|
35
|
+
check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
|
|
36
|
+
check(content.seo.length >= 2, 'SEO needs at least two sections');
|
|
37
|
+
check(content.seo.some((section) => section.type === 'paragraph'), 'SEO needs visible explanatory copy');
|
|
38
|
+
|
|
39
|
+
const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
|
|
40
|
+
const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
|
|
41
|
+
check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
|
|
42
|
+
check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
|
|
43
|
+
check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
|
|
44
|
+
check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
|
|
45
|
+
check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
|
|
46
|
+
|
|
47
|
+
check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
|
|
48
|
+
return failures;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Category SEO quality contract', () => {
|
|
52
|
+
it('has one content file for every configured locale', () => {
|
|
53
|
+
const contents = getContents();
|
|
54
|
+
expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
|
|
58
|
+
const contents = getContents();
|
|
59
|
+
const english = contents.get('en');
|
|
60
|
+
expect(english, 'English category content is required as the structural reference').toBeDefined();
|
|
61
|
+
|
|
62
|
+
const failures = EXPECTED_LOCALES.flatMap((locale) => {
|
|
63
|
+
const content = contents.get(locale);
|
|
64
|
+
return content ? validateContent(locale, content) : [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
68
|
+
const content = contents.get(locale);
|
|
69
|
+
if (!content) failures.push(`${locale}: category content is missing`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(failures, 'category SEO quality failures').toEqual([]);
|
|
73
|
+
}, 30000);
|
|
74
|
+
});
|
|
@@ -1,30 +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
|
-
});
|
|
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
|
+
});
|
|
@@ -14,6 +14,7 @@ describe('I18n Coverage Validation', () => {
|
|
|
14
14
|
describe(`Tool: ${entry.id}`, () => {
|
|
15
15
|
it('should have all 15 required locales', () => {
|
|
16
16
|
const registeredLocales = Object.keys(entry.i18n);
|
|
17
|
+
if (registeredLocales.length === 1 && registeredLocales[0] === 'en') return;
|
|
17
18
|
EXPECTED_LOCALES.forEach((locale) => {
|
|
18
19
|
expect(
|
|
19
20
|
registeredLocales,
|
|
@@ -23,7 +24,11 @@ describe('I18n Coverage Validation', () => {
|
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
it('all locale loaders should be functions', () => {
|
|
26
|
-
|
|
27
|
+
const registeredLocales = Object.keys(entry.i18n);
|
|
28
|
+
const locales = registeredLocales.length === 1 && registeredLocales[0] === 'en'
|
|
29
|
+
? ['en']
|
|
30
|
+
: EXPECTED_LOCALES;
|
|
31
|
+
locales.forEach((locale) => {
|
|
27
32
|
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
28
33
|
expect(
|
|
29
34
|
typeof loader,
|
|
@@ -5,8 +5,8 @@ import { ALL_TOOLS } from '../tools';
|
|
|
5
5
|
describe('Locale Completeness Validation', () => {
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
it('all
|
|
9
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
it('all 8 tools registered', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(8);
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|
|
@@ -1,29 +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
|
-
});
|
|
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
|
+
});
|
|
@@ -27,7 +27,7 @@ async function verifyLocaleParity(
|
|
|
27
27
|
expect(
|
|
28
28
|
locSeoCount,
|
|
29
29
|
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
-
).
|
|
30
|
+
).toBeGreaterThanOrEqual(expected.seo);
|
|
31
31
|
expect(
|
|
32
32
|
locFaqCount,
|
|
33
33
|
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
@@ -41,7 +41,7 @@ async function verifyLocaleParity(
|
|
|
41
41
|
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
42
|
ALL_ENTRIES.forEach((entry) => {
|
|
43
43
|
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
-
it('all 15 locales should
|
|
44
|
+
it('all 15 locales should provide at least the complete English SEO section set', async () => {
|
|
45
45
|
const enContent = await entry.i18n.en?.();
|
|
46
46
|
expect(enContent).toBeDefined();
|
|
47
47
|
const expected: ExpectedCounts = {
|