@jjlmoya/utils-aquarium 1.53.0 → 1.55.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-aquarium",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
27
27
  "check": "astro check",
28
28
  "type-check": "astro check",
29
- "test": "vitest run",
29
+ "test": "vitest run --reporter=verbose --testTimeout=30000",
30
30
  "preversion": "npm run lint && npm run test && npm run build",
31
31
  "postversion": "git push && git push --tags",
32
32
  "patch": "npm version patch",
@@ -6,7 +6,7 @@ export const content: CategoryLocaleContent = {
6
6
  description: '根据鱼缸内尺寸计算可用水量、底砂占用体积和换水量,并可切换单位离线使用,适合日常维护。',
7
7
  seo: [
8
8
  { type: 'title', text: '鱼缸真正能装多少水', level: 2 },
9
- { type: 'paragraph', html: '这个工具集合把内尺寸转换为清晰的工作估算,并分别展示底砂占用和换水量。' },
9
+ { type: 'paragraph', html: '这个工具集合把内尺寸转换为清晰的工作估算,并分别展示底砂占用和换水量。输入内尺寸、形状和底床厚度后,再根据换水比例检查维护计划。实际水量还会受到装饰物和设备占用的影响。' },
10
10
  { type: 'title', text: '清晰的几何计算', level: 2 },
11
11
  { type: 'list', items: ['支持长方体、圆柱体和弧形前面的鱼缸,并明确说明计算假设。'] },
12
12
  ],
@@ -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
+ });
@@ -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
- ).toBe(expected.seo);
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 have identical SEO section counts and types as English', async () => {
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 = {
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { Bibliography } from '@jjlmoya/utils-shared';
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
3
  import { bibliography } from './bibliography';
4
4
  ---
5
5
 
6
- <Bibliography links={bibliography} />
6
+ <SharedBibliography links={bibliography} />