@jjlmoya/utils-nature 1.15.0 → 1.16.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,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-nature",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Fläche × Niederschlag × Abflussbeiwert × Filtereffizienz</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Fläche × Niederschlag × Abflussbeiwert × Filtereffizienz</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Rainfall × Runoff Coefficient × Filter Efficiency</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Rainfall × Runoff Coefficient × Filter Efficiency</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Área × Precipitación × Coeficiente de Escorrentía × Eficiencia del Filtro</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Área × Precipitación × Coeficiente de Escorrentía × Eficiencia del Filtro</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Surface × Précipitations × Coefficient de Ruissellement × Efficacité du Filtre</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Surface × Précipitations × Coefficient de Ruissellement × Efficacité du Filtre</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Luas × Curah Hujan × Koefisien Limpasan × Efisiensi Filter</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Luas × Curah Hujan × Koefisien Limpasan × Efisiensi Filter</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Precipitazioni × Coefficiente di Deflusso × Efficienza Filtro</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Precipitazioni × Coefficiente di Deflusso × Efficienza Filtro</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面積 × 降水量 × 流出係数 × フィルター効率</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面積 × 降水量 × 流出係数 × フィルター効率</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">용량 = 면적 × 강수량 × 유출 계수 × 필터 효율</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">용량 = 면적 × 강수량 × 유출 계수 × 필터 효율</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Oppervlak × Neerslag × Afvloeiingscoëfficiënt × Filterefficiëntie</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Oppervlak × Neerslag × Afvloeiingscoëfficiënt × Filterefficiëntie</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Objętość = Powierzchnia × Opady × Współczynnik Spływu × Wydajność Filtrów</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Objętość = Powierzchnia × Opady × Współczynnik Spływu × Wydajność Filtrów</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Área × Pluviosidade × Coeficiente de Escoamento × Eficiência do Filtro</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Área × Pluviosidade × Coeficiente de Escoamento × Eficiência do Filtro</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Объем = Площадь × Осадки × Коэффициент стока × Эффективность фильтра</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Объем = Площадь × Осадки × Коэффициент стока × Эффективность фильтра</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volym = Area × Nederbörd × Avrinningskoefficient × Filtereffektivitet</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volym = Area × Nederbörd × Avrinningskoefficient × Filtereffektivitet</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Hacim = Alan × Yağış × Akış Katsayısı × Filtre Verimliliği</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Hacim = Alan × Yağış × Akış Katsayısı × Filtre Verimliliği</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
126
126
  },
127
127
  {
128
128
  type: 'paragraph',
129
- html: '<code style="display:block;padding:1rem;background:var(--bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面积 × 降雨量 × 径流系数 × 过滤效率</code>',
129
+ html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面积 × 降雨量 × 径流系数 × 过滤效率</code>',
130
130
  },
131
131
  {
132
132
  type: 'list',
@@ -103,7 +103,7 @@ const cropNote: Record<string, string> = {
103
103
 
104
104
  <div class="sc-params-card">
105
105
  <div class="sc-slider-group">
106
- <label class="sc-slider-label">
106
+ <label class="sc-slider-label" for="inPop">
107
107
  {ui.labelPopulation}
108
108
  <span class="sc-label-unit">{ui.unitSeedsHa}</span>
109
109
  </label>
@@ -121,7 +121,7 @@ const cropNote: Record<string, string> = {
121
121
  </div>
122
122
 
123
123
  <div class="sc-slider-group">
124
- <label class="sc-slider-label">
124
+ <label class="sc-slider-label" for="inRow">
125
125
  {ui.labelRowWidth}
126
126
  <span class="sc-label-unit">{ui.unitCm}</span>
127
127
  </label>