@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
@@ -1,175 +1,175 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { ALL_ENTRIES } from '../entries';
3
-
4
- const STRUCTURAL_KEYS = new Set([
5
- 'id',
6
- 'slug',
7
- 'url',
8
- 'icon',
9
- 'image',
10
- 'imageUrl',
11
- 'keywords',
12
- 'category',
13
- 'tags',
14
- 'toolId',
15
- 'type',
16
- 'locale',
17
- 'language',
18
- 'direction',
19
- ]);
20
-
21
- const TRANSLATABLE_KEYS = [
22
- 'title',
23
- 'description',
24
- 'faqTitle',
25
- 'faq',
26
- 'howTo',
27
- 'seo',
28
- 'schemas',
29
- ] as const;
30
-
31
- const COPY_THRESHOLD = 0.9;
32
-
33
- const SPANISH_MARKERS = [
34
- ['sangre', /\bsangre\b/gi],
35
- ['molino', /\bmolino\b/gi],
36
- ['grano', /\bgrano\b/gi],
37
- ['paladar', /\bpaladar\b/gi],
38
- ['cuchillas', /\bcuchillas\b/gi],
39
- ['trozos', /\btrozos\b/gi],
40
- ['frescura', /\bfrescura\b/gi],
41
- ['ingresa', /\bingresa\b/gi],
42
- ['selecciona', /\bselecciona\b/gi],
43
- ['herramienta', /\bherramienta\b/gi],
44
- ['según', /\bsegún\b/gi],
45
- ['después', /\bdespués\b/gi],
46
- ['puedes', /\bpuedes\b/gi],
47
- ['debes', /\bdebes\b/gi],
48
- ['tus', /\btus\b/gi],
49
- ['a la vez', /\ba la vez\b/gi],
50
- ['los datos', /\blos datos\b/gi],
51
- ['las opciones', /\blas opciones\b/gi],
52
- ['el resultado', /\bel resultado\b/gi],
53
- ['método de extracción', /\bmétodo de extracción\b/gi],
54
- ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
- ['café recién tostado', /\bcafé recién tostado\b/gi],
56
- ] as const;
57
-
58
- type UnknownRecord = Record<string, unknown>;
59
-
60
- function normalize(value: string): string {
61
- return value
62
- .replace(/<[^>]*>/g, ' ')
63
- .replace(/&nbsp;/gi, ' ')
64
- .replace(/\s+/g, ' ')
65
- .trim()
66
- .toLocaleLowerCase('es');
67
- }
68
-
69
- function isTechnicalInvariant(text: string): boolean {
70
- const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
- return [
72
- 'data:image/svg+xml;base64',
73
- 'background-image: url',
74
- '.layout-playground {',
75
- 'const samplerate =',
76
- ].some((pattern) => text.includes(pattern)) ||
77
- (text.includes('presets') && text.includes('hz')) ||
78
- (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
- (text.includes('led') && ledUnitMatches.length >= 3);
80
- }
81
-
82
- function collectString(value: string, output: string[]): void {
83
- const text = normalize(value);
84
- if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
- }
86
-
87
- function collectObject(value: UnknownRecord, output: string[]): void {
88
- Object.entries(value).forEach(([childKey, childValue]) => {
89
- collectText(childValue, output, childKey);
90
- });
91
- }
92
-
93
- function collectText(value: unknown, output: string[], key?: string): void {
94
- if (key && STRUCTURAL_KEYS.has(key)) return;
95
- if (typeof value === 'string') return collectString(value, output);
96
- if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
- if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
- }
99
-
100
- async function loadText(loader: unknown): Promise<string[]> {
101
- if (typeof loader !== 'function') return [];
102
- const module = await (loader as () => Promise<unknown>)();
103
- const output: string[] = [];
104
- if (module && typeof module === 'object') {
105
- const record = module as UnknownRecord;
106
- for (const key of TRANSLATABLE_KEYS) {
107
- collectText(record[key], output, key);
108
- }
109
- }
110
- return output;
111
- }
112
-
113
- function findSpanishMarkers(text: string[]): string[] {
114
- const corpus = text.join(' ');
115
- return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
- pattern.test(corpus) ? [label] : [],
117
- );
118
- }
119
-
120
- function similarity(left: string, right: string): number {
121
- const leftTokens = left.split(/\s+/);
122
- const rightCounts = new Map<string, number>();
123
- right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
- const matches = leftTokens.reduce((total, token) => {
125
- const count = rightCounts.get(token) ?? 0;
126
- if (count > 0) rightCounts.set(token, count - 1);
127
- return total + (count > 0 ? 1 : 0);
128
- }, 0);
129
- return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
- }
131
-
132
- function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
- return spanish
134
- .filter((fragment) => fragment.length >= 80)
135
- .filter((fragment) => translated.some((candidate) =>
136
- candidate.length >= 80 &&
137
- Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
- similarity(fragment, candidate) >= COPY_THRESHOLD,
139
- ))
140
- .sort((a, b) => b.length - a.length)
141
- .slice(0, 3);
142
- }
143
-
144
- describe('Locales must not contain copied Spanish content', () => {
145
- for (const entry of ALL_ENTRIES) {
146
- it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
- const spanish = await loadText(entry.i18n.es);
148
- const failures: string[] = [];
149
-
150
- for (const [locale, loader] of Object.entries(entry.i18n)) {
151
- if (locale === 'es') continue;
152
-
153
- const translated = await loadText(loader);
154
- const copiedFragments = findCopiedFragments(spanish, translated);
155
- const markerHits = findSpanishMarkers(translated);
156
-
157
- if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
- const details = [
159
- copiedFragments.length > 0
160
- ? `copied fragments: ${copiedFragments
161
- .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
- .join(', ')}`
163
- : '',
164
- markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
- ]
166
- .filter(Boolean)
167
- .join('; ');
168
- failures.push(`${locale}: ${details}`);
169
- }
170
- }
171
-
172
- expect(failures, failures.join('\n')).toEqual([]);
173
- });
174
- }
175
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const STRUCTURAL_KEYS = new Set([
5
+ 'id',
6
+ 'slug',
7
+ 'url',
8
+ 'icon',
9
+ 'image',
10
+ 'imageUrl',
11
+ 'keywords',
12
+ 'category',
13
+ 'tags',
14
+ 'toolId',
15
+ 'type',
16
+ 'locale',
17
+ 'language',
18
+ 'direction',
19
+ ]);
20
+
21
+ const TRANSLATABLE_KEYS = [
22
+ 'title',
23
+ 'description',
24
+ 'faqTitle',
25
+ 'faq',
26
+ 'howTo',
27
+ 'seo',
28
+ 'schemas',
29
+ ] as const;
30
+
31
+ const COPY_THRESHOLD = 0.9;
32
+
33
+ const SPANISH_MARKERS = [
34
+ ['sangre', /\bsangre\b/gi],
35
+ ['molino', /\bmolino\b/gi],
36
+ ['grano', /\bgrano\b/gi],
37
+ ['paladar', /\bpaladar\b/gi],
38
+ ['cuchillas', /\bcuchillas\b/gi],
39
+ ['trozos', /\btrozos\b/gi],
40
+ ['frescura', /\bfrescura\b/gi],
41
+ ['ingresa', /\bingresa\b/gi],
42
+ ['selecciona', /\bselecciona\b/gi],
43
+ ['herramienta', /\bherramienta\b/gi],
44
+ ['según', /\bsegún\b/gi],
45
+ ['después', /\bdespués\b/gi],
46
+ ['puedes', /\bpuedes\b/gi],
47
+ ['debes', /\bdebes\b/gi],
48
+ ['tus', /\btus\b/gi],
49
+ ['a la vez', /\ba la vez\b/gi],
50
+ ['los datos', /\blos datos\b/gi],
51
+ ['las opciones', /\blas opciones\b/gi],
52
+ ['el resultado', /\bel resultado\b/gi],
53
+ ['método de extracción', /\bmétodo de extracción\b/gi],
54
+ ['uniformidad del molino', /\buniformidad del molino\b/gi],
55
+ ['café recién tostado', /\bcafé recién tostado\b/gi],
56
+ ] as const;
57
+
58
+ type UnknownRecord = Record<string, unknown>;
59
+
60
+ function normalize(value: string): string {
61
+ return value
62
+ .replace(/<[^>]*>/g, ' ')
63
+ .replace(/&nbsp;/gi, ' ')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .toLocaleLowerCase('es');
67
+ }
68
+
69
+ function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
71
+ return [
72
+ 'data:image/svg+xml;base64',
73
+ 'background-image: url',
74
+ '.layout-playground {',
75
+ 'const samplerate =',
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
80
+ }
81
+
82
+ function collectString(value: string, output: string[]): void {
83
+ const text = normalize(value);
84
+ if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
85
+ }
86
+
87
+ function collectObject(value: UnknownRecord, output: string[]): void {
88
+ Object.entries(value).forEach(([childKey, childValue]) => {
89
+ collectText(childValue, output, childKey);
90
+ });
91
+ }
92
+
93
+ function collectText(value: unknown, output: string[], key?: string): void {
94
+ if (key && STRUCTURAL_KEYS.has(key)) return;
95
+ if (typeof value === 'string') return collectString(value, output);
96
+ if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
97
+ if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
98
+ }
99
+
100
+ async function loadText(loader: unknown): Promise<string[]> {
101
+ if (typeof loader !== 'function') return [];
102
+ const module = await (loader as () => Promise<unknown>)();
103
+ const output: string[] = [];
104
+ if (module && typeof module === 'object') {
105
+ const record = module as UnknownRecord;
106
+ for (const key of TRANSLATABLE_KEYS) {
107
+ collectText(record[key], output, key);
108
+ }
109
+ }
110
+ return output;
111
+ }
112
+
113
+ function findSpanishMarkers(text: string[]): string[] {
114
+ const corpus = text.join(' ');
115
+ return SPANISH_MARKERS.flatMap(([label, pattern]) =>
116
+ pattern.test(corpus) ? [label] : [],
117
+ );
118
+ }
119
+
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
132
+ function findCopiedFragments(spanish: string[], translated: string[]): string[] {
133
+ return spanish
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
140
+ .sort((a, b) => b.length - a.length)
141
+ .slice(0, 3);
142
+ }
143
+
144
+ describe('Locales must not contain copied Spanish content', () => {
145
+ for (const entry of ALL_ENTRIES) {
146
+ it(`${entry.id} has no untranslated Spanish blocks`, async () => {
147
+ const spanish = await loadText(entry.i18n.es);
148
+ const failures: string[] = [];
149
+
150
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
151
+ if (locale === 'es') continue;
152
+
153
+ const translated = await loadText(loader);
154
+ const copiedFragments = findCopiedFragments(spanish, translated);
155
+ const markerHits = findSpanishMarkers(translated);
156
+
157
+ if (copiedFragments.length > 0 || markerHits.length >= 2) {
158
+ const details = [
159
+ copiedFragments.length > 0
160
+ ? `copied fragments: ${copiedFragments
161
+ .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
162
+ .join(', ')}`
163
+ : '',
164
+ markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
165
+ ]
166
+ .filter(Boolean)
167
+ .join('; ');
168
+ failures.push(`${locale}: ${details}`);
169
+ }
170
+ }
171
+
172
+ expect(failures, failures.join('\n')).toEqual([]);
173
+ });
174
+ }
175
+ });
@@ -1,18 +1,6 @@
1
1
  ---
2
- import type { BibliographyEntry } from '../../types';
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
3
  import { bibliography } from './bibliography';
4
-
5
- interface Props {
6
- locale?: string;
7
- }
8
-
9
- const { locale = 'en' } = Astro.props as Props;
10
- const entries = bibliography;
11
4
  ---
12
5
 
13
- <section class="compost-references" aria-labelledby="compost-references-title" data-locale={locale}>
14
- <h2 id="compost-references-title">References</h2>
15
- <ul>
16
- {entries.map((entry: BibliographyEntry) => <li><a href={entry.url} target="_blank" rel="noreferrer">{entry.name}</a></li>)}
17
- </ul>
18
- </section>
6
+ <SharedBibliography links={bibliography} />
@@ -1,23 +1,15 @@
1
1
  ---
2
- import type { SEOSection } from '../../types';
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
3
  import { compostBinVolumeRatioCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
4
5
 
5
6
  interface Props {
6
- locale?: string;
7
+ locale?: KnownLocale;
7
8
  }
8
9
 
9
- const { locale = 'en' } = Astro.props as Props;
10
- const loader = compostBinVolumeRatioCalculator.i18n[locale as keyof typeof compostBinVolumeRatioCalculator.i18n] ?? compostBinVolumeRatioCalculator.i18n.en;
10
+ const { locale = 'es' } = Astro.props;
11
+ const loader = compostBinVolumeRatioCalculator.i18n[locale as KnownLocale] ?? compostBinVolumeRatioCalculator.i18n.es ?? compostBinVolumeRatioCalculator.i18n.en;
11
12
  const content = loader ? await loader() : null;
12
- const sections = (content?.seo ?? []) as SEOSection[];
13
13
  ---
14
14
 
15
- <div class="compost-seo" data-locale={locale}>
16
- {sections.map((section) => {
17
- if (section.type === 'title') return <h2>{section.text}</h2>;
18
- if (section.type === 'paragraph') return <p set:html={section.html} />;
19
- if (section.type === 'list') return <ul>{section.items.map((item) => <li set:html={item} />)}</ul>;
20
- if (section.type === 'tip') return <aside><h3>{section.title}</h3><div set:html={section.html} /></aside>;
21
- return null;
22
- })}
23
- </div>
15
+ {content?.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}