@jjlmoya/utils-nature 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.
Files changed (68) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/es.ts +1 -1
  3. package/src/category/i18n/fr.ts +110 -110
  4. package/src/category/i18n/it.ts +1 -1
  5. package/src/category/i18n/pl.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/i18n/ru.ts +110 -110
  8. package/src/category/seo.astro +1 -1
  9. package/src/components/PreviewNavSidebar.astro +1 -1
  10. package/src/components/PreviewToolbar.astro +1 -1
  11. package/src/data.ts +1 -1
  12. package/src/env.d.ts +1 -1
  13. package/src/layouts/PreviewLayout.astro +117 -117
  14. package/src/pages/[locale]/[slug].astro +164 -164
  15. package/src/pages/index.astro +1 -1
  16. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  17. package/src/tests/category_seo_quality.test.ts +74 -0
  18. package/src/tests/faq_count.test.ts +19 -19
  19. package/src/tests/mocks/astro_mock.js +1 -1
  20. package/src/tests/no_en_dash.test.ts +71 -71
  21. package/src/tests/no_h1_in_components.test.ts +1 -1
  22. package/src/tests/seo_length.test.ts +23 -23
  23. package/src/tests/seo_parity.test.ts +2 -2
  24. package/src/tests/seo_wellformed_export.test.ts +65 -0
  25. package/src/tests/spanish_leakage.test.ts +175 -175
  26. package/src/tool/compostBinVolumeRatioCalculator/bibliography.astro +2 -14
  27. package/src/tool/compostBinVolumeRatioCalculator/seo.astro +6 -14
  28. package/src/tool/cricketThermometer/i18n/de.ts +169 -169
  29. package/src/tool/cricketThermometer/i18n/en.ts +169 -169
  30. package/src/tool/cricketThermometer/i18n/es.ts +169 -169
  31. package/src/tool/cricketThermometer/i18n/fr.ts +169 -169
  32. package/src/tool/cricketThermometer/i18n/id.ts +169 -169
  33. package/src/tool/cricketThermometer/i18n/it.ts +169 -169
  34. package/src/tool/cricketThermometer/i18n/ja.ts +169 -169
  35. package/src/tool/cricketThermometer/i18n/ko.ts +169 -169
  36. package/src/tool/cricketThermometer/i18n/nl.ts +169 -169
  37. package/src/tool/cricketThermometer/i18n/pl.ts +169 -169
  38. package/src/tool/cricketThermometer/i18n/pt.ts +169 -169
  39. package/src/tool/cricketThermometer/i18n/ru.ts +169 -169
  40. package/src/tool/cricketThermometer/i18n/sv.ts +169 -169
  41. package/src/tool/cricketThermometer/i18n/tr.ts +169 -169
  42. package/src/tool/cricketThermometer/i18n/zh.ts +169 -169
  43. package/src/tool/cricketThermometer/seo.astro +15 -15
  44. package/src/tool/digitalCarbon/i18n/de.ts +229 -229
  45. package/src/tool/digitalCarbon/i18n/fr.ts +229 -229
  46. package/src/tool/digitalCarbon/i18n/ru.ts +229 -229
  47. package/src/tool/digitalCarbon/seo.astro +15 -15
  48. package/src/tool/rainHarvester/i18n/de.ts +167 -167
  49. package/src/tool/rainHarvester/i18n/en.ts +167 -167
  50. package/src/tool/rainHarvester/i18n/es.ts +167 -167
  51. package/src/tool/rainHarvester/i18n/fr.ts +167 -167
  52. package/src/tool/rainHarvester/i18n/id.ts +167 -167
  53. package/src/tool/rainHarvester/i18n/it.ts +167 -167
  54. package/src/tool/rainHarvester/i18n/ja.ts +167 -167
  55. package/src/tool/rainHarvester/i18n/ko.ts +167 -167
  56. package/src/tool/rainHarvester/i18n/nl.ts +167 -167
  57. package/src/tool/rainHarvester/i18n/pl.ts +167 -167
  58. package/src/tool/rainHarvester/i18n/pt.ts +167 -167
  59. package/src/tool/rainHarvester/i18n/ru.ts +167 -167
  60. package/src/tool/rainHarvester/i18n/sv.ts +167 -167
  61. package/src/tool/rainHarvester/i18n/tr.ts +167 -167
  62. package/src/tool/rainHarvester/i18n/zh.ts +167 -167
  63. package/src/tool/rainHarvester/seo.astro +15 -15
  64. package/src/tool/seedCalculator/i18n/fr.ts +197 -197
  65. package/src/tool/seedCalculator/i18n/pl.ts +197 -197
  66. package/src/tool/seedCalculator/i18n/ru.ts +197 -197
  67. package/src/tool/seedCalculator/seo.astro +15 -15
  68. package/src/types.ts +1 -1
@@ -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,19 +1,19 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type * as DATA from '../data';
3
-
4
- const TOOLS: typeof DATA.natureCategory[] = [];
5
-
6
- describe('FAQ Content Validation', () => {
7
- TOOLS.forEach((entry) => {
8
- describe(`Tool: ${entry.icon}`, () => {
9
- it('placeholder', () => {
10
- expect(true).toBe(true);
11
- });
12
- });
13
- });
14
-
15
- it('no tools registered yet', () => {
16
- expect(TOOLS.length).toBe(0);
17
- });
18
- });
19
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import type * as DATA from '../data';
3
+
4
+ const TOOLS: typeof DATA.natureCategory[] = [];
5
+
6
+ describe('FAQ Content Validation', () => {
7
+ TOOLS.forEach((entry) => {
8
+ describe(`Tool: ${entry.icon}`, () => {
9
+ it('placeholder', () => {
10
+ expect(true).toBe(true);
11
+ });
12
+ });
13
+ });
14
+
15
+ it('no tools registered yet', () => {
16
+ expect(TOOLS.length).toBe(0);
17
+ });
18
+ });
19
+
@@ -1,2 +1,2 @@
1
1
  export default function () { return null; }
2
-
2
+
@@ -1,71 +1,71 @@
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
- const cleaned = content.replace(/var\(--[\w-]+\)/g, '');
68
- expect(cleaned).not.toContain('--');
69
- });
70
- });
71
- });
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
+ const cleaned = content.replace(/var\(--[\w-]+\)/g, '');
68
+ expect(cleaned).not.toContain('--');
69
+ });
70
+ });
71
+ });
@@ -45,4 +45,4 @@ describe('No H1 in Components', () => {
45
45
  });
46
46
  });
47
47
  });
48
-
48
+
@@ -1,23 +1,23 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
4
- const ENTRIES = [
5
- { id: 'natureCategory', i18n: DATA.natureCategory.i18n },
6
- ];
7
-
8
- describe('SEO Content Length Validation', () => {
9
- ENTRIES.forEach((entry) => {
10
- describe(`Tool: ${entry.id}`, () => {
11
- Object.keys(entry.i18n).forEach((locale) => {
12
- it(`${locale}: SEO section should exist`, async () => {
13
- const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
- if (!loader) return;
15
- const content = await loader();
16
- if (!content.seo) return;
17
- expect(Array.isArray(content.seo)).toBe(true);
18
- });
19
- });
20
- });
21
- });
22
- });
23
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'natureCategory', i18n: DATA.natureCategory.i18n },
6
+ ];
7
+
8
+ describe('SEO Content Length Validation', () => {
9
+ ENTRIES.forEach((entry) => {
10
+ describe(`Tool: ${entry.id}`, () => {
11
+ Object.keys(entry.i18n).forEach((locale) => {
12
+ it(`${locale}: SEO section should exist`, async () => {
13
+ const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
14
+ if (!loader) return;
15
+ const content = await loader();
16
+ if (!content.seo) return;
17
+ expect(Array.isArray(content.seo)).toBe(true);
18
+ });
19
+ });
20
+ });
21
+ });
22
+ });
23
+
@@ -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 = {
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findSeoAstroFiles(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(...findSeoAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'seo.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const seoFiles = findSeoAstroFiles(toolDir);
24
+
25
+ describe('SEO Component Wellformed Export', () => {
26
+ it('found tool seo.astro components', () => {
27
+ expect(seoFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ seoFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSeoRenderer =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('SEORenderer') || content.includes('SEOArticle'));
39
+
40
+ const acquiresDynamicContent =
41
+ content.includes('.i18n') ||
42
+ content.includes('loader') ||
43
+ content.includes('await loader') ||
44
+ content.includes('.seo');
45
+
46
+ const isBrokenPattern =
47
+ /sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
48
+
49
+ expect(
50
+ usesSeoRenderer,
51
+ `File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
52
+ ).toBe(true);
53
+
54
+ expect(
55
+ isBrokenPattern,
56
+ `File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
57
+ ).toBe(false);
58
+
59
+ expect(
60
+ acquiresDynamicContent,
61
+ `File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
62
+ ).toBe(true);
63
+ });
64
+ });
65
+ });