@jjlmoya/utils-astronomy 1.19.0 → 1.21.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-astronomy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.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,123 @@
|
|
|
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
|
+
|
|
@@ -74,11 +74,7 @@ const seo: BortleVisualizerLocaleContent['seo'] = [
|
|
|
74
74
|
{ type: 'title', text: 'Contaminación Lumínica: Una Crisis Global Silenciosa', level: 2 },
|
|
75
75
|
{
|
|
76
76
|
type: 'paragraph',
|
|
77
|
-
html: 'El crecimiento de la contaminación lumínica es uno de los problemas medioambientales menos discutidos pero más extendidos. Según estudios recientes, más del <strong>80% de la población mundial</strong> vive bajo cielos contaminados lumínicamente
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
type: 'paragraph',
|
|
81
|
-
html: 'Las consecuencias van más allá de la astronomía. La <strong>luz artificial nocturna</strong> altera los ritmos circadianos de humanos y animales, desorienta a aves migratorias, afecta a la reproducción de tortugas marinas y suprime la producción de melatonina en mamíferos. La IDA (International Dark-Sky Association) trabaja para designar Reservas de Cielo Oscuro en todo el mundo, y en España el programa Starlight ha certificado varias zonas de excelencia astronómica.',
|
|
77
|
+
html: 'El crecimiento de la contaminación lumínica es uno de los problemas medioambientales menos discutidos pero más extendidos. Según estudios recientes, más del <strong>80% de la población mundial</strong> vive bajo cielos contaminados lumínicamente. Las consecuencias van más allá de la astronomía: la luz artificial nocturna altera los ritmos circadianos y la biodiversidad.',
|
|
82
78
|
},
|
|
83
79
|
{
|
|
84
80
|
type: 'table',
|
|
@@ -73,6 +73,11 @@ const seo: StarExposureCalculatorLocaleContent['seo'] = [
|
|
|
73
73
|
type: 'paragraph',
|
|
74
74
|
html: 'La <strong>Règle des 500</strong> est une formule empirique simplifiée qui a été la norme en astrophotographie de paysage depuis des décennies. Sa formule est: <code>Temps = 500 / (Longueur Focale × Facteur de Recadrage)</code>. Elle est rapide, mentale et suffisamment précise pour les capteurs basse résolution et la visualisation en petits formats.',
|
|
75
75
|
},
|
|
76
|
+
{ type: 'title', text: 'Le Facteur de Recadrage et son Impact', level: 2 },
|
|
77
|
+
{
|
|
78
|
+
type: 'paragraph',
|
|
79
|
+
html: 'Beaucoup de débutants oublient que la Règle des 500 est basée sur le standard 35mm (Plein Format). Si votre appareil photo a un capteur plus petit, le champ de vision est plus étroit et le mouvement des étoiles est amplifié. Un objectif de 14mm sur un APS-C (recadrage 1,5x) se comporte comme un 21mm équivalent, réduisant le temps maximum de 35.7s à seulement 23.8s.',
|
|
80
|
+
},
|
|
76
81
|
{ type: 'title', text: 'Le Modèle NPF: Précision pour Capteurs Modernes', level: 2 },
|
|
77
82
|
{
|
|
78
83
|
type: 'paragraph',
|
|
@@ -106,6 +106,11 @@ const seo: TelescopeResolutionLocaleContent['seo'] = [
|
|
|
106
106
|
type: 'paragraph',
|
|
107
107
|
html: 'Un telescopio mal colimado (con los espejos desalineados) nunca rendirá al límite de Dawes, independientemente de la apertura. La <strong>colimación</strong> es el proceso de alinear perfectamente los espejos secundario y primario con el portaocular. Para telescopios reflectores (Newtonianos, Dobsonianos), la colimación es una tarea periódica imprescindible. Practica la colimación en la estrella artificial de Polaris o en una estrella brillante usando la técnica del "star test".',
|
|
108
108
|
},
|
|
109
|
+
{ type: 'title', text: 'Consejos Prácticos para Maximizar la Resolución', level: 2 },
|
|
110
|
+
{
|
|
111
|
+
type: 'paragraph',
|
|
112
|
+
html: 'Más allá de la apertura y la colimación, varios hábitos de observación mejoran drásticamente la resolución práctica. Espera siempre a que el telescopio alcance el equilibrio térmico con el aire exterior (30-60 minutos). Evita observar sobre tejados, aparcamientos o superficies que irradien calor. Elige noches tras el paso de frentes fríos con capas atmosféricas estables. Usa el aumento más alto que mantenga una imagen estable y permite 20 minutos de adaptación a la oscuridad.',
|
|
113
|
+
},
|
|
109
114
|
{
|
|
110
115
|
type: 'stats',
|
|
111
116
|
columns: 2,
|