@jjlmoya/utils-tabletop 1.32.0 → 1.34.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/de.ts +1 -1
- package/src/category/i18n/es.ts +1 -1
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/id.ts +1 -1
- package/src/category/i18n/it.ts +1 -1
- package/src/category/i18n/nl.ts +1 -1
- package/src/category/i18n/pl.ts +1 -1
- package/src/category/i18n/pt.ts +1 -1
- package/src/category/i18n/ru.ts +1 -1
- package/src/category/i18n/sv.ts +1 -1
- package/src/category/i18n/tr.ts +1 -1
- package/src/category/index.ts +25 -25
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/entries.ts +43 -43
- package/src/env.d.ts +1 -1
- package/src/index.ts +24 -24
- package/src/layouts/PreviewLayout.astro +93 -93
- package/src/pages/[locale]/[slug].astro +143 -143
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_em_dash.test.ts +47 -47
- package/src/tests/no_en_dash.test.ts +70 -70
- package/src/tests/seo_length.test.ts +3 -3
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_translation_completeness.test.ts +18 -12
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tool/dungeon-map-generator/component.astro +135 -135
- package/src/tool/dungeon-map-generator/controller.ts +243 -243
- package/src/tool/dungeon-map-generator/dom-views.ts +144 -144
- package/src/tool/dungeon-map-generator/i18n/de.ts +172 -172
- package/src/tool/dungeon-map-generator/logic.ts +281 -281
- package/src/tool/dungeon-map-generator/random-dungeon-map-generator.css +692 -692
- package/src/tool/dungeon-map-generator/ui.ts +54 -54
- package/src/tool/fantasy-runes-translator/logic.ts +1 -1
- package/src/tool/first-player-selector/i18n/tr.ts +1 -1
- package/src/tool/investigation-board/i18n/ru.ts +209 -209
- package/src/tool/investigation-board/i18n/zh.ts +209 -209
- package/src/tool/lunar-tide-tracker/client.ts +264 -264
- package/src/tool/rpg-initiative-tracker/i18n/de.ts +3 -3
- package/src/tool/rpg-initiative-tracker/i18n/es.ts +1 -1
- package/src/tool/rpg-initiative-tracker/i18n/pl.ts +2 -2
- package/src/tool/rpg-initiative-tracker/i18n/ru.ts +1 -1
- package/src/tool/score-tracker/i18n/pt.ts +1 -1
- package/src/tool/score-tracker/i18n/sv.ts +1 -1
- package/src/tool/score-tracker/i18n/tr.ts +1 -1
- package/src/tool/score-tracker/i18n/zh.ts +1 -1
- package/src/tools.ts +25 -25
|
@@ -1,32 +1,32 @@
|
|
|
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: lazyComp } of ALL_TOOLS) {
|
|
18
|
-
const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
|
|
19
|
-
const localeEntries = Object.entries(entry.i18n) as [
|
|
20
|
-
KnownLocale,
|
|
21
|
-
() => Promise<ToolLocaleContent>,
|
|
22
|
-
][];
|
|
23
|
-
const localeContents = await Promise.all(
|
|
24
|
-
localeEntries.map(async ([locale, loader]) => ({
|
|
25
|
-
locale,
|
|
26
|
-
content: await loader(),
|
|
27
|
-
})),
|
|
28
|
-
);
|
|
29
|
-
|
|
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: lazyComp } of ALL_TOOLS) {
|
|
18
|
+
const { default: Component } = await (lazyComp as () => Promise<{ default: unknown }>)();
|
|
19
|
+
const localeEntries = Object.entries(entry.i18n) as [
|
|
20
|
+
KnownLocale,
|
|
21
|
+
() => Promise<ToolLocaleContent>,
|
|
22
|
+
][];
|
|
23
|
+
const localeContents = await Promise.all(
|
|
24
|
+
localeEntries.map(async ([locale, loader]) => ({
|
|
25
|
+
locale,
|
|
26
|
+
content: await loader(),
|
|
27
|
+
})),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
30
|
const localeUrls = Object.fromEntries(
|
|
31
31
|
localeContents.map(({ locale, content }) => [
|
|
32
32
|
locale,
|
|
@@ -38,44 +38,44 @@ export async function getStaticPaths() {
|
|
|
38
38
|
const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
|
|
39
39
|
|
|
40
40
|
for (const { locale, content } of localeContents) {
|
|
41
|
-
const allToolsNav = (
|
|
42
|
-
await Promise.all(
|
|
43
|
-
ALL_TOOLS.map(async ({ entry: navEntry }) => {
|
|
44
|
-
const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
|
|
45
|
-
if (!loader) return null;
|
|
46
|
-
const navContent = await loader();
|
|
47
|
-
return {
|
|
48
|
-
id: navEntry.id,
|
|
49
|
-
title: navContent.title,
|
|
50
|
-
href: `/${locale}/${navContent.slug}`,
|
|
51
|
-
isActive: navEntry.id === entry.id,
|
|
52
|
-
};
|
|
53
|
-
}),
|
|
54
|
-
)
|
|
55
|
-
).filter(Boolean) as NavItem[];
|
|
56
|
-
paths.push({
|
|
57
|
-
params: { locale, slug: content.slug },
|
|
41
|
+
const allToolsNav = (
|
|
42
|
+
await Promise.all(
|
|
43
|
+
ALL_TOOLS.map(async ({ entry: navEntry }) => {
|
|
44
|
+
const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
|
|
45
|
+
if (!loader) return null;
|
|
46
|
+
const navContent = await loader();
|
|
47
|
+
return {
|
|
48
|
+
id: navEntry.id,
|
|
49
|
+
title: navContent.title,
|
|
50
|
+
href: `/${locale}/${navContent.slug}`,
|
|
51
|
+
isActive: navEntry.id === entry.id,
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
).filter(Boolean) as NavItem[];
|
|
56
|
+
paths.push({
|
|
57
|
+
params: { locale, slug: content.slug },
|
|
58
58
|
props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return paths;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface NavItem {
|
|
67
|
-
id: string;
|
|
68
|
-
title: string;
|
|
69
|
-
href: string;
|
|
70
|
-
isActive?: boolean;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type PageProps = {
|
|
74
|
-
Component: unknown;
|
|
75
|
-
locale: KnownLocale;
|
|
76
|
-
content: ToolLocaleContent;
|
|
77
|
-
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
78
|
-
allToolsNav: NavItem[];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return paths;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface NavItem {
|
|
67
|
+
id: string;
|
|
68
|
+
title: string;
|
|
69
|
+
href: string;
|
|
70
|
+
isActive?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type PageProps = {
|
|
74
|
+
Component: unknown;
|
|
75
|
+
locale: KnownLocale;
|
|
76
|
+
content: ToolLocaleContent;
|
|
77
|
+
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
78
|
+
allToolsNav: NavItem[];
|
|
79
79
|
englishSlug: string;
|
|
80
80
|
};
|
|
81
81
|
|
|
@@ -83,80 +83,80 @@ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Ast
|
|
|
83
83
|
|
|
84
84
|
const cssFiles = import.meta.glob<{ default: string }>("../../tool/*/*.css", { query: "?raw", import: "default" });
|
|
85
85
|
const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
|
|
86
|
-
const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
|
|
87
|
-
const toolCss = cssLoader ? await cssLoader() as string : "";
|
|
88
|
-
|
|
89
|
-
const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
|
|
90
|
-
|
|
91
|
-
const words = content.title.split(" ");
|
|
92
|
-
const titleHighlight = words[0] || "";
|
|
93
|
-
const titleBase = words.slice(1).join(" ") || "";
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
<PreviewLayout
|
|
97
|
-
title={content.title}
|
|
98
|
-
currentLocale={locale}
|
|
99
|
-
localeUrls={localeUrls}
|
|
100
|
-
hasSidebar={true}
|
|
101
|
-
>
|
|
102
|
-
<PreviewNavSidebar
|
|
103
|
-
slot="sidebar"
|
|
104
|
-
categoryTitle="Tools"
|
|
105
|
-
tools={allToolsNav}
|
|
106
|
-
/>
|
|
107
|
-
<Fragment slot="head">
|
|
108
|
-
{toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
|
|
109
|
-
{
|
|
110
|
-
( content.schemas ?? []).map((schema: unknown) => (
|
|
111
|
-
<script
|
|
112
|
-
is:inline
|
|
113
|
-
type="application/ld+json"
|
|
114
|
-
set:html={JSON.stringify(schema)}
|
|
115
|
-
/>
|
|
116
|
-
))
|
|
117
|
-
}
|
|
118
|
-
</Fragment>
|
|
119
|
-
|
|
120
|
-
<div class="tool-page">
|
|
121
|
-
<UtilityHeader
|
|
122
|
-
titleHighlight={titleHighlight}
|
|
123
|
-
titleBase={titleBase}
|
|
124
|
-
description={content.description}
|
|
125
|
-
/>
|
|
126
|
-
|
|
127
|
-
<section class="section-tool">
|
|
128
|
-
<Component ui={content.ui} />
|
|
129
|
-
</section>
|
|
130
|
-
|
|
131
|
-
<section class="section-seo">
|
|
132
|
-
<SEORenderer content={seoContent} />
|
|
133
|
-
</section>
|
|
134
|
-
|
|
135
|
-
<section class="section-faq">
|
|
136
|
-
<FAQSection items={content.faq} />
|
|
137
|
-
</section>
|
|
138
|
-
|
|
139
|
-
<section class="section-bibliography">
|
|
140
|
-
<Bibliography links={content.bibliography} />
|
|
141
|
-
</section>
|
|
142
|
-
</div>
|
|
143
|
-
</PreviewLayout>
|
|
144
|
-
|
|
145
|
-
<style>
|
|
146
|
-
.tool-page {
|
|
147
|
-
display: flex;
|
|
148
|
-
flex-direction: column;
|
|
149
|
-
gap: 2rem;
|
|
150
|
-
}
|
|
151
|
-
.section-tool {
|
|
152
|
-
max-width: 1200px;
|
|
153
|
-
margin: 0 auto;
|
|
154
|
-
width: 100%;
|
|
155
|
-
}
|
|
156
|
-
.section-seo,
|
|
157
|
-
.section-faq,
|
|
158
|
-
.section-bibliography {
|
|
159
|
-
padding-top: 2rem;
|
|
160
|
-
border-top: 1px solid var(--border-color);
|
|
161
|
-
}
|
|
162
|
-
</style>
|
|
86
|
+
const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
|
|
87
|
+
const toolCss = cssLoader ? await cssLoader() as string : "";
|
|
88
|
+
|
|
89
|
+
const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
|
|
90
|
+
|
|
91
|
+
const words = content.title.split(" ");
|
|
92
|
+
const titleHighlight = words[0] || "";
|
|
93
|
+
const titleBase = words.slice(1).join(" ") || "";
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
<PreviewLayout
|
|
97
|
+
title={content.title}
|
|
98
|
+
currentLocale={locale}
|
|
99
|
+
localeUrls={localeUrls}
|
|
100
|
+
hasSidebar={true}
|
|
101
|
+
>
|
|
102
|
+
<PreviewNavSidebar
|
|
103
|
+
slot="sidebar"
|
|
104
|
+
categoryTitle="Tools"
|
|
105
|
+
tools={allToolsNav}
|
|
106
|
+
/>
|
|
107
|
+
<Fragment slot="head">
|
|
108
|
+
{toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
|
|
109
|
+
{
|
|
110
|
+
( content.schemas ?? []).map((schema: unknown) => (
|
|
111
|
+
<script
|
|
112
|
+
is:inline
|
|
113
|
+
type="application/ld+json"
|
|
114
|
+
set:html={JSON.stringify(schema)}
|
|
115
|
+
/>
|
|
116
|
+
))
|
|
117
|
+
}
|
|
118
|
+
</Fragment>
|
|
119
|
+
|
|
120
|
+
<div class="tool-page">
|
|
121
|
+
<UtilityHeader
|
|
122
|
+
titleHighlight={titleHighlight}
|
|
123
|
+
titleBase={titleBase}
|
|
124
|
+
description={content.description}
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
<section class="section-tool">
|
|
128
|
+
<Component ui={content.ui} />
|
|
129
|
+
</section>
|
|
130
|
+
|
|
131
|
+
<section class="section-seo">
|
|
132
|
+
<SEORenderer content={seoContent} />
|
|
133
|
+
</section>
|
|
134
|
+
|
|
135
|
+
<section class="section-faq">
|
|
136
|
+
<FAQSection items={content.faq} />
|
|
137
|
+
</section>
|
|
138
|
+
|
|
139
|
+
<section class="section-bibliography">
|
|
140
|
+
<Bibliography links={content.bibliography} />
|
|
141
|
+
</section>
|
|
142
|
+
</div>
|
|
143
|
+
</PreviewLayout>
|
|
144
|
+
|
|
145
|
+
<style>
|
|
146
|
+
.tool-page {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-direction: column;
|
|
149
|
+
gap: 2rem;
|
|
150
|
+
}
|
|
151
|
+
.section-tool {
|
|
152
|
+
max-width: 1200px;
|
|
153
|
+
margin: 0 auto;
|
|
154
|
+
width: 100%;
|
|
155
|
+
}
|
|
156
|
+
.section-seo,
|
|
157
|
+
.section-faq,
|
|
158
|
+
.section-bibliography {
|
|
159
|
+
padding-top: 2rem;
|
|
160
|
+
border-top: 1px solid var(--border-color);
|
|
161
|
+
}
|
|
162
|
+
</style>
|
package/src/pages/index.astro
CHANGED
|
@@ -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,36 +1,36 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
|
|
4
|
-
const EXPECTED_LOCALES = [
|
|
5
|
-
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
describe('I18n Coverage Validation', () => {
|
|
9
|
-
it('all tools should be registered', () => {
|
|
10
|
-
expect(ALL_TOOLS.length).toBeGreaterThan(0);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
|
|
14
|
-
describe(`Tool: ${entry.id}`, () => {
|
|
15
|
-
it('should have all 15 required locales', () => {
|
|
16
|
-
const registeredLocales = Object.keys(entry.i18n);
|
|
17
|
-
EXPECTED_LOCALES.forEach((locale) => {
|
|
18
|
-
expect(
|
|
19
|
-
registeredLocales,
|
|
20
|
-
`Tool "${entry.id}" is missing locale "${locale}"`,
|
|
21
|
-
).toContain(locale);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('all locale loaders should be functions', () => {
|
|
26
|
-
EXPECTED_LOCALES.forEach((locale) => {
|
|
27
|
-
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
28
|
-
expect(
|
|
29
|
-
typeof loader,
|
|
30
|
-
`Tool "${entry.id}" locale "${locale}" loader is not a function`,
|
|
31
|
-
).toBe('function');
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_LOCALES = [
|
|
5
|
+
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
describe('I18n Coverage Validation', () => {
|
|
9
|
+
it('all tools should be registered', () => {
|
|
10
|
+
expect(ALL_TOOLS.length).toBeGreaterThan(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
|
|
14
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
15
|
+
it('should have all 15 required locales', () => {
|
|
16
|
+
const registeredLocales = Object.keys(entry.i18n);
|
|
17
|
+
EXPECTED_LOCALES.forEach((locale) => {
|
|
18
|
+
expect(
|
|
19
|
+
registeredLocales,
|
|
20
|
+
`Tool "${entry.id}" is missing locale "${locale}"`,
|
|
21
|
+
).toContain(locale);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('all locale loaders should be functions', () => {
|
|
26
|
+
EXPECTED_LOCALES.forEach((locale) => {
|
|
27
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
28
|
+
expect(
|
|
29
|
+
typeof loader,
|
|
30
|
+
`Tool "${entry.id}" locale "${locale}" loader is not a function`,
|
|
31
|
+
).toBe('function');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export default function () { return null; }
|
|
2
|
-
|
|
2
|
+
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import { describe, it } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
function getFiles(dir: string, ext: string[]): string[] {
|
|
6
|
-
const results: string[] = [];
|
|
7
|
-
if (!fs.existsSync(dir)) return results;
|
|
8
|
-
const list = fs.readdirSync(dir);
|
|
9
|
-
for (const file of list) {
|
|
10
|
-
const fullPath = path.join(dir, file);
|
|
11
|
-
const stat = fs.statSync(fullPath);
|
|
12
|
-
if (stat && stat.isDirectory()) {
|
|
13
|
-
results.push(...getFiles(fullPath, ext));
|
|
14
|
-
} else if (ext.some((e) => file.endsWith(e))) {
|
|
15
|
-
results.push(fullPath);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return results;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const EM_DASH = '\u2014';
|
|
22
|
-
|
|
23
|
-
describe('Em Dash Prohibition', () => {
|
|
24
|
-
const files = [
|
|
25
|
-
...getFiles(path.join(process.cwd(), 'src', 'tool'), ['.ts']),
|
|
26
|
-
...getFiles(path.join(process.cwd(), 'src', 'category'), ['.ts']),
|
|
27
|
-
].filter(f => f.includes('i18n'));
|
|
28
|
-
|
|
29
|
-
it.each(files)('should not contain em dash (—) in %s', (filePath) => {
|
|
30
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
-
const relativePath = path.relative(process.cwd(), filePath);
|
|
32
|
-
|
|
33
|
-
const lines = content.split('\n');
|
|
34
|
-
const findings: { line: number; text: string }[] = [];
|
|
35
|
-
|
|
36
|
-
lines.forEach((line, index) => {
|
|
37
|
-
if (line.includes(EM_DASH)) {
|
|
38
|
-
findings.push({ line: index + 1, text: line.trim() });
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
if (findings.length > 0) {
|
|
43
|
-
const list = findings.map((f) => ` Line ${f.line}: ${f.text}`).join('\n');
|
|
44
|
-
throw new Error(`Em dash (—) found in ${relativePath}:\n${list}`);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
});
|
|
1
|
+
import { describe, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string, ext: string[]): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) return results;
|
|
8
|
+
const list = fs.readdirSync(dir);
|
|
9
|
+
for (const file of list) {
|
|
10
|
+
const fullPath = path.join(dir, file);
|
|
11
|
+
const stat = fs.statSync(fullPath);
|
|
12
|
+
if (stat && stat.isDirectory()) {
|
|
13
|
+
results.push(...getFiles(fullPath, ext));
|
|
14
|
+
} else if (ext.some((e) => file.endsWith(e))) {
|
|
15
|
+
results.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EM_DASH = '\u2014';
|
|
22
|
+
|
|
23
|
+
describe('Em Dash Prohibition', () => {
|
|
24
|
+
const files = [
|
|
25
|
+
...getFiles(path.join(process.cwd(), 'src', 'tool'), ['.ts']),
|
|
26
|
+
...getFiles(path.join(process.cwd(), 'src', 'category'), ['.ts']),
|
|
27
|
+
].filter(f => f.includes('i18n'));
|
|
28
|
+
|
|
29
|
+
it.each(files)('should not contain em dash (—) in %s', (filePath) => {
|
|
30
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
32
|
+
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const findings: { line: number; text: string }[] = [];
|
|
35
|
+
|
|
36
|
+
lines.forEach((line, index) => {
|
|
37
|
+
if (line.includes(EM_DASH)) {
|
|
38
|
+
findings.push({ line: index + 1, text: line.trim() });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (findings.length > 0) {
|
|
43
|
+
const list = findings.map((f) => ` Line ${f.line}: ${f.text}`).join('\n');
|
|
44
|
+
throw new Error(`Em dash (—) found in ${relativePath}:\n${list}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|