@jjlmoya/utils-nautical 1.17.0 → 1.19.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 +5 -3
- package/src/category/index.ts +2 -1
- package/src/data.ts +0 -6
- package/src/entries.ts +5 -1
- package/src/index.ts +9 -7
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/script_density.test.ts +1 -1
- package/src/tests/title_quality.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/anchorScope/anchorScope.css +507 -0
- package/src/tool/anchorScope/bibliography.astro +6 -0
- package/src/tool/anchorScope/bibliography.ts +14 -0
- package/src/tool/anchorScope/component.astro +219 -0
- package/src/tool/anchorScope/constants.ts +51 -0
- package/src/tool/anchorScope/controller.ts +251 -0
- package/src/tool/anchorScope/dom-views.ts +106 -0
- package/src/tool/anchorScope/entry.ts +31 -0
- package/src/tool/anchorScope/evaluator.ts +61 -0
- package/src/tool/anchorScope/i18n/de.ts +201 -0
- package/src/tool/anchorScope/i18n/en.ts +223 -0
- package/src/tool/anchorScope/i18n/es.ts +223 -0
- package/src/tool/anchorScope/i18n/fr.ts +201 -0
- package/src/tool/anchorScope/i18n/id.ts +201 -0
- package/src/tool/anchorScope/i18n/it.ts +201 -0
- package/src/tool/anchorScope/i18n/ja.ts +201 -0
- package/src/tool/anchorScope/i18n/ko.ts +201 -0
- package/src/tool/anchorScope/i18n/nl.ts +201 -0
- package/src/tool/anchorScope/i18n/pl.ts +201 -0
- package/src/tool/anchorScope/i18n/pt.ts +201 -0
- package/src/tool/anchorScope/i18n/ru.ts +201 -0
- package/src/tool/anchorScope/i18n/sv.ts +201 -0
- package/src/tool/anchorScope/i18n/tr.ts +201 -0
- package/src/tool/anchorScope/i18n/zh.ts +201 -0
- package/src/tool/anchorScope/index.ts +10 -0
- package/src/tool/anchorScope/logic.test.ts +100 -0
- package/src/tool/anchorScope/logic.ts +84 -0
- package/src/tool/anchorScope/presets.ts +12 -0
- package/src/tool/anchorScope/seo.astro +14 -0
- package/src/tool/anchorScope/storage.ts +25 -0
- package/src/tool/anchorScope/types.ts +33 -0
- package/src/tool/anchorScope/ui.ts +63 -0
- package/src/tool/endurance/component.astro +10 -10
- package/src/tool/endurance/index.ts +2 -1
- package/src/tool/nauticalConverter/component.astro +15 -15
- package/src/tool/sailArea/component.astro +13 -13
- package/src/tool/tideCalculator/component.astro +5 -5
- package/src/tool/underKeel/component.astro +7 -7
- package/src/tools.ts +3 -0
- package/src/types.ts +3 -1
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-nautical",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.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",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
"check": "astro check",
|
|
27
29
|
"type-check": "astro check",
|
|
28
30
|
"test": "vitest run",
|
|
29
|
-
"preversion": "npm run lint && npm run test",
|
|
31
|
+
"preversion": "npm run lint && npm run test && npm run build",
|
|
30
32
|
"postversion": "git push && git push --tags",
|
|
31
33
|
"patch": "npm version patch",
|
|
32
34
|
"minor": "npm version minor",
|
package/src/category/index.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { nauticalConverter } from '../tool/nauticalConverter/entry';
|
|
|
5
5
|
import { sailArea } from '../tool/sailArea/entry';
|
|
6
6
|
import { speedConverter } from '../tool/speedConverter/entry';
|
|
7
7
|
import { endurance } from '../tool/endurance/entry';
|
|
8
|
+
import { anchorScope } from '../tool/anchorScope/entry';
|
|
8
9
|
|
|
9
10
|
export type { CategoryLocaleContent };
|
|
10
11
|
|
|
11
12
|
export const nauticalCategory: NauticalCategoryEntry = {
|
|
12
13
|
icon: 'mdi:sail-boat',
|
|
13
|
-
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance],
|
|
14
|
+
tools: [tideCalculator, underKeel, nauticalConverter, sailArea, speedConverter, endurance, anchorScope],
|
|
14
15
|
i18n: {
|
|
15
16
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
16
17
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/data.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
export { nauticalCategory } from './category';
|
|
2
2
|
export type { CategoryLocaleContent } from './category';
|
|
3
3
|
|
|
4
|
-
export { tideCalculator } from './tool/tideCalculator';
|
|
5
|
-
export { underKeel } from './tool/underKeel';
|
|
6
|
-
export { nauticalConverter } from './tool/nauticalConverter';
|
|
7
|
-
export { sailArea } from './tool/sailArea';
|
|
8
|
-
export { speedConverter } from './tool/speedConverter';
|
|
9
|
-
export { endurance } from './tool/endurance';
|
|
10
4
|
|
|
11
5
|
export type { TideCalculatorUI, TideCalculatorLocaleContent } from './tool/tideCalculator';
|
|
12
6
|
export type { UnderKeelUI, UnderKeelLocaleContent } from './tool/underKeel';
|
package/src/entries.ts
CHANGED
|
@@ -10,6 +10,9 @@ export { tideCalculator } from './tool/tideCalculator/entry';
|
|
|
10
10
|
export type { TideCalculatorUI, TideCalculatorLocaleContent } from './tool/tideCalculator/entry';
|
|
11
11
|
export { underKeel } from './tool/underKeel/entry';
|
|
12
12
|
export type { UnderKeelUI, UnderKeelLocaleContent } from './tool/underKeel/entry';
|
|
13
|
+
export { anchorScope } from './tool/anchorScope/entry';
|
|
14
|
+
export type { AnchorScopeLocaleContent } from './tool/anchorScope/entry';
|
|
15
|
+
export type { AnchorScopeUI } from './tool/anchorScope/ui';
|
|
13
16
|
export { nauticalCategory } from './category';
|
|
14
17
|
import { endurance } from './tool/endurance/entry';
|
|
15
18
|
import { nauticalConverter } from './tool/nauticalConverter/entry';
|
|
@@ -17,4 +20,5 @@ import { sailArea } from './tool/sailArea/entry';
|
|
|
17
20
|
import { speedConverter } from './tool/speedConverter/entry';
|
|
18
21
|
import { tideCalculator } from './tool/tideCalculator/entry';
|
|
19
22
|
import { underKeel } from './tool/underKeel/entry';
|
|
20
|
-
|
|
23
|
+
import { anchorScope } from './tool/anchorScope/entry';
|
|
24
|
+
export const ALL_ENTRIES = [endurance, nauticalConverter, sailArea, speedConverter, tideCalculator, underKeel, anchorScope];
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
export * from './tool/underKeel';
|
|
3
|
-
export * from './tool/nauticalConverter';
|
|
4
|
-
export * from './tool/sailArea';
|
|
5
|
-
export * from './tool/speedConverter';
|
|
6
|
-
export * from './tool/endurance';
|
|
7
|
-
export * from './category';
|
|
1
|
+
export { nauticalCategory } from './category';
|
|
8
2
|
export const NauticalCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
|
|
9
3
|
|
|
10
4
|
export type {
|
|
@@ -18,6 +12,14 @@ export type {
|
|
|
18
12
|
LocaleMap,
|
|
19
13
|
NauticalToolEntry,
|
|
20
14
|
NauticalCategoryEntry,
|
|
15
|
+
ToolDefinition,
|
|
21
16
|
} from './types';
|
|
22
17
|
|
|
23
18
|
export { ALL_ENTRIES, ALL_TOOLS } from './tools';
|
|
19
|
+
export { tideCalculator, TIDE_CALCULATOR_TOOL } from './tool/tideCalculator';
|
|
20
|
+
export { underKeel, UNDER_KEEL_TOOL } from './tool/underKeel';
|
|
21
|
+
export { nauticalConverter, NAUTICAL_CONVERTER_TOOL } from './tool/nauticalConverter';
|
|
22
|
+
export { sailArea, SAIL_AREA_TOOL } from './tool/sailArea';
|
|
23
|
+
export { speedConverter, SPEED_CONVERTER_TOOL } from './tool/speedConverter';
|
|
24
|
+
export { endurance, ENDURANCE_TOOL } from './tool/endurance';
|
|
25
|
+
export { anchorScope, ANCHOR_SCOPE_TOOL } from './tool/anchorScope';
|
|
@@ -96,7 +96,7 @@ describe('Diacritics density validation', () => {
|
|
|
96
96
|
if (!loader) return;
|
|
97
97
|
|
|
98
98
|
const content = await loader();
|
|
99
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
99
|
+
const text = normalizeText(translatableContent((content as unknown) as Record<string, unknown>));
|
|
100
100
|
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
101
|
const letters = letterCount(text);
|
|
102
102
|
const matches = diacriticCount(text, typedLocale);
|
|
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
|
|
|
60
60
|
|
|
61
61
|
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
62
|
const content = await loader();
|
|
63
|
-
const strings = translatableStrings(content as Record<string, unknown>);
|
|
63
|
+
const strings = translatableStrings((content as unknown) as Record<string, unknown>);
|
|
64
64
|
const missingQuestions = strings.flatMap((text) =>
|
|
65
65
|
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
66
|
);
|
|
@@ -5,8 +5,8 @@ import { ALL_TOOLS } from '../tools';
|
|
|
5
5
|
describe('Locale Completeness Validation', () => {
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
it('all
|
|
9
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
it('all 7 tools registered', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|
|
@@ -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
|
+
});
|
|
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
|
|
|
73
73
|
|
|
74
74
|
const content = await loader();
|
|
75
75
|
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
-
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
76
|
+
const text = normalizeText(translatableContent((content as unknown) as Record<string, unknown>));
|
|
77
77
|
const letters = letterCount(text);
|
|
78
78
|
const matches = scriptCount(text, typedLocale);
|
|
79
79
|
const ratio = scriptRatio(text, typedLocale);
|
|
@@ -40,7 +40,7 @@ describe('Project Titles - Separator Validation', () => {
|
|
|
40
40
|
for (const pattern of titlePatterns) {
|
|
41
41
|
let match;
|
|
42
42
|
while ((match = pattern.exec(content)) !== null) {
|
|
43
|
-
const title = match[1];
|
|
43
|
+
const title = match[1] ?? '';
|
|
44
44
|
if (title.includes('|') || title.includes('-')) {
|
|
45
45
|
findings.push(title);
|
|
46
46
|
}
|
|
@@ -5,8 +5,8 @@ const TOOL_ID_REGEX = /^[a-z0-9]+-?[a-z0-9]*$/;
|
|
|
5
5
|
|
|
6
6
|
describe('Tool Validation Suite', () => {
|
|
7
7
|
describe('Library Registration', () => {
|
|
8
|
-
it('should have
|
|
9
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
it('should have 7 tools in ALL_TOOLS', () => {
|
|
9
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|