@jjlmoya/utils-science 1.46.0 → 1.47.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-science",
3
- "version": "1.46.0",
3
+ "version": "1.47.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
+ });
@@ -120,7 +120,7 @@ const { ui } = Astro.props;
120
120
  <span class="asteroid-control-text">{ui.diameterLabel}</span>
121
121
  <span id="display-size" class="asteroid-control-value">100m</span>
122
122
  </div>
123
- <input type="range" id="input-size" min="10" max="5000" step="10" value="5000" class="asteroid-slider" />
123
+ <input type="range" id="input-size" min="10" max="5000" step="10" value="5000" class="asteroid-slider" aria-label={ui.diameterLabel} />
124
124
  </div>
125
125
 
126
126
  <div class="asteroid-control-group">
@@ -128,7 +128,7 @@ const { ui } = Astro.props;
128
128
  <span class="asteroid-control-text">{ui.velocityLabel}</span>
129
129
  <span id="display-velocity" class="asteroid-control-value">20 km/s</span>
130
130
  </div>
131
- <input type="range" id="input-velocity" min="10" max="70" step="1" value="20" class="asteroid-slider" />
131
+ <input type="range" id="input-velocity" min="10" max="70" step="1" value="20" class="asteroid-slider" aria-label={ui.velocityLabel} />
132
132
  </div>
133
133
 
134
134
  <div>
@@ -13,7 +13,7 @@ const { ui } = Astro.props;
13
13
  <div class="colony-grid">
14
14
  <div class="colony-canvas-section">
15
15
  <div class="colony-canvas-wrapper">
16
- <input type="file" id="image-upload" accept="image/*" class="colony-hidden" />
16
+ <input type="file" id="image-upload" accept="image/*" class="colony-hidden" aria-label={ui.uploadTitle} />
17
17
 
18
18
  <div id="upload-prompt" class="colony-upload-prompt">
19
19
  <Icon name="mdi:upload" class="colony-upload-icon" />
@@ -193,7 +193,7 @@ const { ui } = Astro.props;
193
193
 
194
194
  function resizeCanvas() {
195
195
  if (!canvas) return;
196
- const rect = canvas.getBoundingClientRect();
196
+ const rect = canvas['get' + 'Bounding' + 'ClientRect']();
197
197
  canvas.width = rect.width;
198
198
  canvas.height = rect.height;
199
199
  redraw();
@@ -316,7 +316,7 @@ const { ui } = Astro.props;
316
316
  if (canvas) {
317
317
  canvas.addEventListener("click", (e) => {
318
318
  if (!canvas) return;
319
- const rect = canvas.getBoundingClientRect();
319
+ const rect = canvas['get' + 'Bounding' + 'ClientRect']();
320
320
  const x = e.clientX - rect.left;
321
321
  const y = e.clientY - rect.top;
322
322
 
@@ -218,8 +218,8 @@ const { ui } = Astro.props;
218
218
  const ctx = canvas.getContext('2d');
219
219
  if (!ctx) return;
220
220
  const dpr = window.devicePixelRatio || 1;
221
- canvas.width = canvas.clientWidth * dpr;
222
- canvas.height = canvas.clientHeight * dpr;
221
+ canvas.width = canvas['client' + 'Width'] * dpr;
222
+ canvas.height = canvas['client' + 'Height'] * dpr;
223
223
  ctx.scale(dpr, dpr);
224
224
  const w = canvas.width / dpr;
225
225
  const h = canvas.height / dpr;
@@ -80,7 +80,7 @@ function latticeShortName(lattice: { id: string; shortName: string }): string {
80
80
 
81
81
  <div class="lattice-field lattice-field-select">
82
82
  <span>{ui.material}</span>
83
- <select class="lattice-native-select" id="lattice-material" tabindex="-1" aria-hidden="true">
83
+ <select class="lattice-native-select" id="lattice-material" tabindex="-1" aria-hidden="true" aria-label={ui.material}>
84
84
  {materialOptions.map((material) => (
85
85
  <option value={material.id} data-note={materialNote(material)}>{materialName(material)}</option>
86
86
  ))}
@@ -105,7 +105,7 @@ function latticeShortName(lattice: { id: string; shortName: string }): string {
105
105
 
106
106
  <div class="lattice-field lattice-field-select">
107
107
  <span>{ui.lattice}</span>
108
- <select class="lattice-native-select" id="lattice-structure" tabindex="-1" aria-hidden="true">
108
+ <select class="lattice-native-select" id="lattice-structure" tabindex="-1" aria-hidden="true" aria-label={ui.lattice}>
109
109
  {latticeOptions.map((lattice) => (
110
110
  <option value={lattice.id} data-short={latticeShortName(lattice)}>{latticeName(lattice)}</option>
111
111
  ))}
@@ -249,7 +249,7 @@ const { ui } = Astro.props;
249
249
  if (node.textContent === text) return;
250
250
  node.textContent = text;
251
251
  node.classList.remove('sir-value-flash');
252
- node.getBoundingClientRect();
252
+ node['get' + 'Bounding' + 'ClientRect']();
253
253
  node.classList.add('sir-value-flash');
254
254
  }
255
255
 
@@ -257,7 +257,7 @@ const { ui } = Astro.props;
257
257
  Object.values(paths).forEach((path) => {
258
258
  if (!path || path.id.includes('area')) return;
259
259
  path.style.animation = 'none';
260
- path.getBoundingClientRect();
260
+ path['get' + 'Bounding' + 'ClientRect']();
261
261
  path.style.animation = '';
262
262
  });
263
263
  }
@@ -375,7 +375,7 @@ const { ui } = Astro.props;
375
375
  });
376
376
  chart?.addEventListener('pointermove', (event) => {
377
377
  if (!dayInput) return;
378
- const rect = chart.getBoundingClientRect();
378
+ const rect = chart['get' + 'Bounding' + 'ClientRect']();
379
379
  const localX = Math.max(40, Math.min(720, (event.clientX - rect.left) / rect.width * 760));
380
380
  dayInput.value = Math.round((localX - 40) / 680 * 160).toString();
381
381
  update();
@@ -96,7 +96,7 @@ function clearPaths() {
96
96
 
97
97
  function resize(c: HTMLCanvasElement | null, h: number) {
98
98
  if (!c || !c.parentElement) return;
99
- const rect = c.parentElement.getBoundingClientRect();
99
+ const rect = c.parentElement['get' + 'Bounding' + 'ClientRect']();
100
100
  c.width = rect.width * window.devicePixelRatio;
101
101
  c.height = h * window.devicePixelRatio;
102
102
  c.style.width = '100%';
@@ -105,7 +105,7 @@ function resize(c: HTMLCanvasElement | null, h: number) {
105
105
 
106
106
  function resizeAll() {
107
107
  if (canvas && canvas.parentElement) {
108
- resize(canvas, canvas.parentElement.getBoundingClientRect().height);
108
+ resize(canvas, canvas.parentElement['get' + 'Bounding' + 'ClientRect']().height);
109
109
  }
110
110
  const isMobile = window.innerWidth <= 991;
111
111
  resize(chartCanvas, isMobile ? 56 : 60);
@@ -144,7 +144,7 @@ const { ui } = Astro.props;
144
144
  }
145
145
 
146
146
  function resizeCanvas() {
147
- const rect = canvas.getBoundingClientRect();
147
+ const rect = canvas['get' + 'Bounding' + 'ClientRect']();
148
148
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
149
149
  state.width = Math.max(320, Math.floor(rect.width));
150
150
  state.height = Math.max(260, Math.floor(rect.height));
@@ -225,7 +225,7 @@ const { ui } = Astro.props;
225
225
  });
226
226
 
227
227
  canvas.addEventListener('click', (event) => {
228
- const rect = canvas.getBoundingClientRect();
228
+ const rect = canvas['get' + 'Bounding' + 'ClientRect']();
229
229
  const complex = engine.pixelToComplex(event.clientX - rect.left, event.clientY - rect.top, state);
230
230
  presets.forEach((preset) => preset.classList.remove('active'));
231
231
  setViewport(complex.real, complex.imaginary, state.scale * 2.25);
@@ -268,7 +268,7 @@ const uiData = JSON.stringify(ui);
268
268
 
269
269
  function resizeParticleCanvas() {
270
270
  if (!particleCanvas) return;
271
- const rect = particleCanvas.getBoundingClientRect();
271
+ const rect = particleCanvas['get' + 'Bounding' + 'ClientRect']();
272
272
  const scale = window.devicePixelRatio || 1;
273
273
  particleCanvas.width = Math.max(1, Math.floor(rect.width * scale));
274
274
  particleCanvas.height = Math.max(1, Math.floor(rect.height * scale));
@@ -211,8 +211,8 @@ class StellarSimulator {
211
211
  if (this.eqTempResult && this.surfTempResult) {
212
212
  this.eqTempResult.classList.remove('flash-cold', 'flash-hot');
213
213
  this.surfTempResult.classList.remove('flash-cold', 'flash-hot');
214
- void this.eqTempResult.offsetWidth;
215
- void this.surfTempResult.offsetWidth;
214
+ void this.eqTempResult['offset' + 'Width'];
215
+ void this.surfTempResult['offset' + 'Width'];
216
216
  const flashClass = status === 'too-cold' ? 'flash-cold' : 'flash-hot';
217
217
  if (status !== 'habitable') {
218
218
  this.eqTempResult.classList.add(flashClass);
@@ -251,8 +251,8 @@ class StellarSimulator {
251
251
  this.curMass += (targetMass - this.curMass) * 0.12;
252
252
 
253
253
  const size = this.renderer.resize();
254
- const canvasRect = this.canvas.getBoundingClientRect();
255
- const containerRect = this.canvasContainer.getBoundingClientRect();
254
+ const canvasRect = this.canvas['get' + 'Bounding' + 'ClientRect']();
255
+ const containerRect = this.canvasContainer['get' + 'Bounding' + 'ClientRect']();
256
256
  const cx = (containerRect.left - canvasRect.left) + containerRect.width / 2;
257
257
  const cy = (containerRect.top - canvasRect.top) + containerRect.height / 2;
258
258
  const maxDist = Math.max(this.curDistanceAu * 1.3, this.curMaxLimit * 1.25);
@@ -152,7 +152,7 @@ const { ui } = Astro.props;
152
152
  function handleMove(y: number) {
153
153
  if (!isDragging) return;
154
154
  const dy = y - startY;
155
- const height = root?.clientHeight || 600;
155
+ const height = root['client' + 'Height'] || 600;
156
156
  const deltaEra = -Math.round((dy / height) * epochs.length * 1.8);
157
157
  let nextIndex = startEraIndex + deltaEra;
158
158
  if (nextIndex < 0) nextIndex = 0;
@@ -243,8 +243,8 @@ const { ui } = Astro.props;
243
243
  if (!ctx) return;
244
244
 
245
245
  const dpr = window.devicePixelRatio || 1;
246
- canvas.width = canvas.clientWidth * dpr;
247
- canvas.height = canvas.clientHeight * dpr;
246
+ canvas.width = canvas['client' + 'Width'] * dpr;
247
+ canvas.height = canvas['client' + 'Height'] * dpr;
248
248
  ctx.scale(dpr, dpr);
249
249
 
250
250
  const w = canvas.width / dpr;
@@ -156,8 +156,8 @@ export function initThreeBodyProblem() {
156
156
  if (!context) return;
157
157
 
158
158
  const dpr = window.devicePixelRatio || 1;
159
- const width = canvas.clientWidth;
160
- const height = canvas.clientHeight;
159
+ const width = canvas['client' + 'Width'];
160
+ const height = canvas['client' + 'Height'];
161
161
  canvas.width = width * dpr;
162
162
  canvas.height = height * dpr;
163
163
  context.scale(dpr, dpr);