@jjlmoya/utils-games-development 1.66.0 → 1.68.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 +67 -67
- package/scripts/postinstall.mjs +27 -27
- package/src/category/GamesCategorySEO.astro +19 -19
- package/src/category/i18n/de.ts +15 -15
- package/src/category/i18n/en.ts +15 -15
- package/src/category/i18n/es.ts +15 -15
- package/src/category/i18n/fr.ts +15 -15
- package/src/category/i18n/id.ts +10 -10
- package/src/category/i18n/it.ts +10 -10
- package/src/category/i18n/ja.ts +10 -10
- package/src/category/i18n/ko.ts +10 -10
- package/src/category/i18n/nl.ts +10 -10
- package/src/category/i18n/pl.ts +10 -10
- package/src/category/i18n/pt.ts +10 -10
- package/src/category/i18n/ru.ts +10 -10
- package/src/category/i18n/sv.ts +10 -10
- package/src/category/i18n/tr.ts +10 -10
- package/src/category/i18n/zh.ts +10 -10
- package/src/category/seo.astro +15 -15
- package/src/components/PreviewNavSidebar.astro +116 -116
- package/src/components/PreviewToolbar.astro +143 -143
- package/src/data.ts +11 -11
- package/src/env.d.ts +5 -5
- package/src/index.ts +20 -20
- package/src/layouts/PreviewLayout.astro +122 -122
- package/src/pages/[locale]/[slug].astro +165 -165
- package/src/pages/[locale].astro +250 -250
- package/src/pages/index.astro +4 -4
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/diacritics_density.test.ts +118 -118
- package/src/tests/faq_count.test.ts +19 -19
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/inverted_punctuation.test.ts +84 -84
- package/src/tests/mocks/astro_mock.js +2 -2
- package/src/tests/no_en_dash.test.ts +70 -70
- package/src/tests/no_h1_in_components.test.ts +48 -48
- package/src/tests/pagespeed_best_practices.test.ts +198 -198
- package/src/tests/qa-test-helpers.ts +32 -32
- package/src/tests/qa_bibliography_links.test.ts +41 -41
- package/src/tests/qa_claim_evidence.test.ts +69 -69
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -46
- package/src/tests/qa_runtime_i18n.test.ts +100 -100
- package/src/tests/schemas_fulfillment.test.ts +23 -23
- package/src/tests/script_density.test.ts +94 -94
- package/src/tests/seo_length.test.ts +23 -23
- package/src/tests/seo_translation_completeness.test.ts +18 -12
- package/src/tests/slug_language_code_format.test.ts +23 -23
- package/src/tests/slug_uniqueness.test.ts +80 -80
- package/src/tests/title_quality.test.ts +56 -56
- package/src/tool/audioLoopPointFinder/audio-loop-point-finder.css +322 -322
- package/src/tool/audioLoopPointFinder/client.ts +262 -262
- package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -614
- package/src/tool/saveFileEditor/component.astro +351 -351
- package/src/tool/saveFileEditor/game-save-file-editor.css +250 -250
- package/src/tool/spriteSheetPacker/app-client.ts +248 -248
- package/src/tool/spriteSheetPacker/bibliography.ts +12 -12
- package/src/tool/spriteSheetPacker/component.astro +229 -229
- package/src/tool/spriteSheetPacker/dropzone-client.ts +55 -55
- package/src/tool/spriteSheetPacker/entry.ts +28 -28
- package/src/tool/spriteSheetPacker/exporter.ts +116 -116
- package/src/tool/spriteSheetPacker/extractor-client.ts +127 -127
- package/src/tool/spriteSheetPacker/flipbook-client.ts +60 -60
- package/src/tool/spriteSheetPacker/logic.test.ts +155 -155
- package/src/tool/spriteSheetPacker/logic.ts +32 -32
- package/src/tool/spriteSheetPacker/packer-core.ts +121 -121
- package/src/tool/spriteSheetPacker/packer-ui.ts +86 -86
- package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +578 -578
- package/src/tool/spriteSheetPacker/types.ts +89 -89
- package/src/tool/spriteSheetPacker/ui.ts +42 -42
- package/src/tool/steamCapsuleGenerator/index.ts +9 -9
- package/src/tool/steamCapsuleGenerator/validation.ts +14 -14
- package/src/types.ts +72 -72
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
|
|
5
|
-
const EXCLUDED_DIRS = ['node_modules', 'pages', 'layouts'];
|
|
6
|
-
|
|
7
|
-
function findAstroFiles(dir: string): string[] {
|
|
8
|
-
const files: string[] = [];
|
|
9
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
10
|
-
|
|
11
|
-
for (const entry of entries) {
|
|
12
|
-
const fullPath = join(dir, entry.name);
|
|
13
|
-
if (entry.isDirectory() && !EXCLUDED_DIRS.includes(entry.name)) {
|
|
14
|
-
files.push(...findAstroFiles(fullPath));
|
|
15
|
-
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
16
|
-
files.push(fullPath);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return files;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function hasH1(content: string): boolean {
|
|
24
|
-
return /<h1[\s>]/i.test(content);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const srcDir = join(process.cwd(), 'src');
|
|
28
|
-
const astroFiles = findAstroFiles(srcDir);
|
|
29
|
-
|
|
30
|
-
describe('No H1 in Components', () => {
|
|
31
|
-
if (astroFiles.length === 0) {
|
|
32
|
-
it('no astro components found', () => {
|
|
33
|
-
expect(true).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
astroFiles.forEach((file) => {
|
|
38
|
-
const relativePath = file.replace(process.cwd(), '');
|
|
39
|
-
it(`${relativePath} should not contain <h1>`, () => {
|
|
40
|
-
const content = readFileSync(file, 'utf-8');
|
|
41
|
-
expect(
|
|
42
|
-
hasH1(content),
|
|
43
|
-
`File "${relativePath}" contains a <h1> element. Use <h2> or lower inside components — h1 belongs to the page layout.`,
|
|
44
|
-
).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const EXCLUDED_DIRS = ['node_modules', 'pages', 'layouts'];
|
|
6
|
+
|
|
7
|
+
function findAstroFiles(dir: string): string[] {
|
|
8
|
+
const files: string[] = [];
|
|
9
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const fullPath = join(dir, entry.name);
|
|
13
|
+
if (entry.isDirectory() && !EXCLUDED_DIRS.includes(entry.name)) {
|
|
14
|
+
files.push(...findAstroFiles(fullPath));
|
|
15
|
+
} else if (entry.isFile() && entry.name.endsWith('.astro')) {
|
|
16
|
+
files.push(fullPath);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return files;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasH1(content: string): boolean {
|
|
24
|
+
return /<h1[\s>]/i.test(content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const srcDir = join(process.cwd(), 'src');
|
|
28
|
+
const astroFiles = findAstroFiles(srcDir);
|
|
29
|
+
|
|
30
|
+
describe('No H1 in Components', () => {
|
|
31
|
+
if (astroFiles.length === 0) {
|
|
32
|
+
it('no astro components found', () => {
|
|
33
|
+
expect(true).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
astroFiles.forEach((file) => {
|
|
38
|
+
const relativePath = file.replace(process.cwd(), '');
|
|
39
|
+
it(`${relativePath} should not contain <h1>`, () => {
|
|
40
|
+
const content = readFileSync(file, 'utf-8');
|
|
41
|
+
expect(
|
|
42
|
+
hasH1(content),
|
|
43
|
+
`File "${relativePath}" contains a <h1> element. Use <h2> or lower inside components — h1 belongs to the page layout.`,
|
|
44
|
+
).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
@@ -1,198 +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
|
-
});
|
|
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
|
+
});
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
3
|
-
|
|
4
|
-
export const repositoryRoot = process.cwd();
|
|
5
|
-
export const toolRoot = join(repositoryRoot, 'src', 'tool');
|
|
6
|
-
|
|
7
|
-
export function listToolDirectories(): string[] {
|
|
8
|
-
return readdirSync(toolRoot)
|
|
9
|
-
.map((name) => join(toolRoot, name))
|
|
10
|
-
.filter((path) => statSync(path).isDirectory());
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
|
-
const files: string[] = [];
|
|
15
|
-
|
|
16
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
17
|
-
const path = join(directory, entry.name);
|
|
18
|
-
if (entry.isDirectory()) files.push(...findFiles(path, extensions));
|
|
19
|
-
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return files;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function read(path: string): string {
|
|
26
|
-
return readFileSync(path, 'utf8');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function displayPath(path: string): string {
|
|
30
|
-
return relative(repositoryRoot, path).replace(/\\/g, '/');
|
|
31
|
-
}
|
|
32
|
-
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const repositoryRoot = process.cwd();
|
|
5
|
+
export const toolRoot = join(repositoryRoot, 'src', 'tool');
|
|
6
|
+
|
|
7
|
+
export function listToolDirectories(): string[] {
|
|
8
|
+
return readdirSync(toolRoot)
|
|
9
|
+
.map((name) => join(toolRoot, name))
|
|
10
|
+
.filter((path) => statSync(path).isDirectory());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
17
|
+
const path = join(directory, entry.name);
|
|
18
|
+
if (entry.isDirectory()) files.push(...findFiles(path, extensions));
|
|
19
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function read(path: string): string {
|
|
26
|
+
return readFileSync(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function displayPath(path: string): string {
|
|
30
|
+
return relative(repositoryRoot, path).replace(/\\/g, '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
import type { ToolLocaleContent } from '../types';
|
|
4
|
-
|
|
5
|
-
interface LinkFailure {
|
|
6
|
-
tool: string;
|
|
7
|
-
message: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function inspectEntry(tool: string, entry: { url: string }, seen: Set<string>): LinkFailure[] {
|
|
11
|
-
let url: URL;
|
|
12
|
-
try {
|
|
13
|
-
url = new URL(entry.url);
|
|
14
|
-
} catch {
|
|
15
|
-
return [{ tool, message: `invalid URL: ${entry.url}` }];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const failures: LinkFailure[] = [];
|
|
19
|
-
if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${entry.url}` });
|
|
20
|
-
if (url.pathname === '/' && !url.search && !url.hash) failures.push({ tool, message: `generic homepage, cite the exact document: ${entry.url}` });
|
|
21
|
-
if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${entry.url}` });
|
|
22
|
-
seen.add(url.href);
|
|
23
|
-
return failures;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function inspectTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
|
|
27
|
-
const loader = tool.entry.i18n.en;
|
|
28
|
-
if (!loader) return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
|
|
29
|
-
const content = await loader() as ToolLocaleContent;
|
|
30
|
-
const seen = new Set<string>();
|
|
31
|
-
return (content.bibliography ?? []).flatMap((entry) => inspectEntry(tool.entry.id, entry, seen));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe('QA: bibliography links are specific and usable', () => {
|
|
35
|
-
it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
|
|
36
|
-
const failures = (await Promise.all(ALL_TOOLS.map(inspectTool))).flat();
|
|
37
|
-
|
|
38
|
-
const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
|
|
39
|
-
expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
interface LinkFailure {
|
|
6
|
+
tool: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function inspectEntry(tool: string, entry: { url: string }, seen: Set<string>): LinkFailure[] {
|
|
11
|
+
let url: URL;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(entry.url);
|
|
14
|
+
} catch {
|
|
15
|
+
return [{ tool, message: `invalid URL: ${entry.url}` }];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const failures: LinkFailure[] = [];
|
|
19
|
+
if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${entry.url}` });
|
|
20
|
+
if (url.pathname === '/' && !url.search && !url.hash) failures.push({ tool, message: `generic homepage, cite the exact document: ${entry.url}` });
|
|
21
|
+
if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${entry.url}` });
|
|
22
|
+
seen.add(url.href);
|
|
23
|
+
return failures;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function inspectTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
|
|
27
|
+
const loader = tool.entry.i18n.en;
|
|
28
|
+
if (!loader) return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
|
|
29
|
+
const content = await loader() as ToolLocaleContent;
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
return (content.bibliography ?? []).flatMap((entry) => inspectEntry(tool.entry.id, entry, seen));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('QA: bibliography links are specific and usable', () => {
|
|
35
|
+
it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
|
|
36
|
+
const failures = (await Promise.all(ALL_TOOLS.map(inspectTool))).flat();
|
|
37
|
+
|
|
38
|
+
const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
|
|
39
|
+
expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
});
|