@jjlmoya/utils-streaming 1.16.0 → 1.18.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-streaming",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
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",
34
34
  "minor": "npm version minor",
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+ import type { KnownLocale } from '../types';
4
+
5
+ interface ExpectedCounts {
6
+ seo: number;
7
+ faq: number;
8
+ howTo: number;
9
+ }
10
+
11
+ function countItems(arr: unknown[] | undefined): number {
12
+ return arr?.length ?? 0;
13
+ }
14
+
15
+ async function verifyLocaleParity(
16
+ entry: typeof ALL_ENTRIES[number],
17
+ loc: KnownLocale,
18
+ expected: ExpectedCounts,
19
+ ): Promise<void> {
20
+ const locContent = await entry.i18n[loc]?.();
21
+ expect(locContent, `Locale ${loc} missing content`).toBeDefined();
22
+
23
+ const locSeoCount = countItems(locContent?.seo);
24
+ const locFaqCount = countItems(locContent?.faq);
25
+ const locHowToCount = countItems(locContent?.howTo);
26
+
27
+ expect(
28
+ locSeoCount,
29
+ `Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
30
+ ).toBe(expected.seo);
31
+ expect(
32
+ locFaqCount,
33
+ `Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
34
+ ).toBe(expected.faq);
35
+ expect(
36
+ locHowToCount,
37
+ `Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
38
+ ).toBe(expected.howTo);
39
+ }
40
+
41
+ describe('SEO & i18n Structural Parity Suite', () => {
42
+ ALL_ENTRIES.forEach((entry) => {
43
+ describe(`Tool: ${entry.id}`, () => {
44
+ it('all 15 locales should have identical SEO section counts and types as English', async () => {
45
+ const enContent = await entry.i18n.en?.();
46
+ expect(enContent).toBeDefined();
47
+ const expected: ExpectedCounts = {
48
+ seo: countItems(enContent?.seo),
49
+ faq: countItems(enContent?.faq),
50
+ howTo: countItems(enContent?.howTo),
51
+ };
52
+
53
+ const locales = Object.keys(entry.i18n) as KnownLocale[];
54
+ for (const loc of locales) {
55
+ await verifyLocaleParity(entry, loc, expected);
56
+ }
57
+ });
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,153 @@
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 SPANISH_MARKERS = [
32
+ ['sangre', /\bsangre\b/gi],
33
+ ['molino', /\bmolino\b/gi],
34
+ ['grano', /\bgrano\b/gi],
35
+ ['paladar', /\bpaladar\b/gi],
36
+ ['cuchillas', /\bcuchillas\b/gi],
37
+ ['trozos', /\btrozos\b/gi],
38
+ ['frescura', /\bfrescura\b/gi],
39
+ ['ingresa', /\bingresa\b/gi],
40
+ ['selecciona', /\bselecciona\b/gi],
41
+ ['herramienta', /\bherramienta\b/gi],
42
+ ['según', /\bsegún\b/gi],
43
+ ['después', /\bdespués\b/gi],
44
+ ['puedes', /\bpuedes\b/gi],
45
+ ['debes', /\bdebes\b/gi],
46
+ ['tus', /\btus\b/gi],
47
+ ['a la vez', /\ba la vez\b/gi],
48
+ ['los datos', /\blos datos\b/gi],
49
+ ['las opciones', /\blas opciones\b/gi],
50
+ ['el resultado', /\bel resultado\b/gi],
51
+ ['método de extracción', /\bmétodo de extracción\b/gi],
52
+ ['uniformidad del molino', /\buniformidad del molino\b/gi],
53
+ ['café recién tostado', /\bcafé recién tostado\b/gi],
54
+ ] as const;
55
+
56
+ type UnknownRecord = Record<string, unknown>;
57
+
58
+ function normalize(value: string): string {
59
+ return value
60
+ .replace(/<[^>]*>/g, ' ')
61
+ .replace(/&nbsp;/gi, ' ')
62
+ .replace(/\s+/g, ' ')
63
+ .trim()
64
+ .toLocaleLowerCase('es');
65
+ }
66
+
67
+ function isTechnicalInvariant(text: string): boolean {
68
+ return [
69
+ 'data:image/svg+xml;base64',
70
+ 'background-image: url',
71
+ '.layout-playground {',
72
+ 'const samplerate =',
73
+ ].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
74
+ }
75
+
76
+ function collectString(value: string, output: string[]): void {
77
+ const text = normalize(value);
78
+ if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
79
+ }
80
+
81
+ function collectObject(value: UnknownRecord, output: string[]): void {
82
+ Object.entries(value).forEach(([childKey, childValue]) => {
83
+ collectText(childValue, output, childKey);
84
+ });
85
+ }
86
+
87
+ function collectText(value: unknown, output: string[], key?: string): void {
88
+ if (key && STRUCTURAL_KEYS.has(key)) return;
89
+ if (typeof value === 'string') return collectString(value, output);
90
+ if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
91
+ if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
92
+ }
93
+
94
+ async function loadText(loader: unknown): Promise<string[]> {
95
+ if (typeof loader !== 'function') return [];
96
+ const module = await (loader as () => Promise<unknown>)();
97
+ const output: string[] = [];
98
+ if (module && typeof module === 'object') {
99
+ const record = module as UnknownRecord;
100
+ for (const key of TRANSLATABLE_KEYS) {
101
+ collectText(record[key], output, key);
102
+ }
103
+ }
104
+ return output;
105
+ }
106
+
107
+ function findSpanishMarkers(text: string[]): string[] {
108
+ const corpus = text.join(' ');
109
+ return SPANISH_MARKERS.flatMap(([label, pattern]) =>
110
+ pattern.test(corpus) ? [label] : [],
111
+ );
112
+ }
113
+
114
+ function findCopiedFragments(spanish: string[], translated: string[]): string[] {
115
+ const corpus = translated.join(' ');
116
+ return spanish
117
+ .filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
118
+ .sort((a, b) => b.length - a.length)
119
+ .slice(0, 3);
120
+ }
121
+
122
+ describe('Locales must not contain copied Spanish content', () => {
123
+ for (const entry of ALL_ENTRIES) {
124
+ it(`${entry.id} has no untranslated Spanish blocks`, async () => {
125
+ const spanish = await loadText(entry.i18n.es);
126
+ const failures: string[] = [];
127
+
128
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
129
+ if (locale === 'es') continue;
130
+
131
+ const translated = await loadText(loader);
132
+ const copiedFragments = findCopiedFragments(spanish, translated);
133
+ const markerHits = findSpanishMarkers(translated);
134
+
135
+ if (copiedFragments.length > 0 || markerHits.length >= 2) {
136
+ const details = [
137
+ copiedFragments.length > 0
138
+ ? `copied fragments: ${copiedFragments
139
+ .map((fragment) => JSON.stringify(fragment.slice(0, 120)))
140
+ .join(', ')}`
141
+ : '',
142
+ markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
143
+ ]
144
+ .filter(Boolean)
145
+ .join('; ');
146
+ failures.push(`${locale}: ${details}`);
147
+ }
148
+ }
149
+
150
+ expect(failures, failures.join('\n')).toEqual([]);
151
+ });
152
+ }
153
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const COPY_THRESHOLD = 0.9;
5
+
6
+ const STRUCTURAL_KEYS = new Set([
7
+ '@context',
8
+ '@type',
9
+ 'applicationCategory',
10
+ 'columns',
11
+ 'highlight',
12
+ 'icon',
13
+ 'level',
14
+ 'operatingSystem',
15
+ 'position',
16
+ 'positive',
17
+ 'price',
18
+ 'priceCurrency',
19
+ 'slug',
20
+ 'trend',
21
+ 'type',
22
+ 'url',
23
+ 'value',
24
+ 'variant',
25
+ ]);
26
+
27
+ function normalizeText(value: string): string {
28
+ return value
29
+ .replace(/<[^>]*>/g, ' ')
30
+ .replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
31
+ .replace(/[\u2018\u2019]/g, "'")
32
+ .replace(/[\u201c\u201d]/g, '"')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .toLocaleLowerCase();
36
+ }
37
+
38
+ function collectText(value: unknown, path: string, parts: string[]): void {
39
+ if (typeof value === 'string') {
40
+ const normalized = normalizeText(value);
41
+ if (normalized.length >= 2) parts.push(normalized);
42
+ return;
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
47
+ return;
48
+ }
49
+
50
+ if (!value || typeof value !== 'object') return;
51
+
52
+ Object.entries(value).forEach(([key, child]) => {
53
+ if (STRUCTURAL_KEYS.has(key)) return;
54
+ collectText(child, `${path}.${key}`, parts);
55
+ });
56
+ }
57
+
58
+ function localeCorpus(content: unknown): string {
59
+ if (!content || typeof content !== 'object') return '';
60
+
61
+ const record = content as Record<string, unknown>;
62
+ const parts: string[] = [];
63
+ collectText(record.title, 'title', parts);
64
+ collectText(record.description, 'description', parts);
65
+ collectText(record.faqTitle, 'faqTitle', parts);
66
+ collectText(record.faq, 'faq', parts);
67
+ collectText(record.seo, 'seo', parts);
68
+ collectText(record.schemas, 'schemas', parts);
69
+ return parts.join(' ');
70
+ }
71
+
72
+ function tokenCounts(text: string): Map<string, number> {
73
+ const counts = new Map<string, number>();
74
+ for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
75
+ counts.set(token, (counts.get(token) ?? 0) + 1);
76
+ }
77
+ return counts;
78
+ }
79
+
80
+ function copySimilarity(left: string, right: string): number {
81
+ const leftCounts = tokenCounts(left);
82
+ const rightCounts = tokenCounts(right);
83
+ const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
84
+ const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
85
+ if (leftTotal === 0 || rightTotal === 0) return 0;
86
+
87
+ let shared = 0;
88
+ for (const [token, count] of leftCounts) {
89
+ shared += Math.min(count, rightCounts.get(token) ?? 0);
90
+ }
91
+ return (2 * shared) / (leftTotal + rightTotal);
92
+ }
93
+
94
+ describe('Locales must not copy another locale wholesale', () => {
95
+ ALL_ENTRIES.forEach((entry) => {
96
+ it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
97
+ const corpora = new Map<string, string>();
98
+
99
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
100
+ if (!loader) continue;
101
+ corpora.set(locale, localeCorpus(await loader()));
102
+ }
103
+
104
+ const locales = [...corpora.keys()];
105
+ const violations: string[] = [];
106
+
107
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
+ const left = locales[leftIndex];
110
+ const right = locales[rightIndex];
111
+ const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
112
+
113
+ if (similarity >= COPY_THRESHOLD) {
114
+ violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
115
+ }
116
+ }
117
+ }
118
+
119
+ expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
120
+ });
121
+ });
122
+ });
123
+
124
+