@jjlmoya/utils-pets 1.12.0 → 1.14.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 +4 -2
- package/src/category/i18n/fr.ts +4 -4
- package/src/category/i18n/ru.ts +4 -4
- package/src/category/i18n/zh.ts +1 -1
- package/src/data.ts +1 -1
- package/src/layouts/PreviewLayout.astro +7 -1
- package/src/pages/[locale]/[slug].astro +29 -13
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +4 -14
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/petAge/bibliography.astro +2 -10
- package/src/tool/petAge/bibliography.ts +6 -0
- package/src/tool/petAge/component.astro +1 -0
- package/src/tool/petAge/entry.ts +2 -0
- package/src/tool/petAge/i18n/de.ts +2 -7
- package/src/tool/petAge/i18n/en.ts +2 -7
- package/src/tool/petAge/i18n/es.ts +2 -7
- package/src/tool/petAge/i18n/fr.ts +4 -9
- package/src/tool/petAge/i18n/id.ts +2 -7
- package/src/tool/petAge/i18n/it.ts +2 -7
- package/src/tool/petAge/i18n/ja.ts +2 -7
- package/src/tool/petAge/i18n/ko.ts +2 -7
- package/src/tool/petAge/i18n/nl.ts +2 -7
- package/src/tool/petAge/i18n/pl.ts +2 -7
- package/src/tool/petAge/i18n/pt.ts +2 -7
- package/src/tool/petAge/i18n/ru.ts +9 -14
- package/src/tool/petAge/i18n/sv.ts +2 -7
- package/src/tool/petAge/i18n/tr.ts +2 -7
- package/src/tool/petAge/i18n/zh.ts +8 -13
- package/src/tool/petAge/seo.astro +2 -48
- package/src/tool/petRation/bibliography.astro +2 -10
- package/src/tool/petRation/bibliography.ts +6 -0
- package/src/tool/petRation/component.astro +2 -2
- package/src/tool/petRation/entry.ts +2 -0
- package/src/tool/petRation/i18n/de.ts +2 -7
- package/src/tool/petRation/i18n/en.ts +2 -7
- package/src/tool/petRation/i18n/es.ts +2 -7
- package/src/tool/petRation/i18n/fr.ts +8 -13
- package/src/tool/petRation/i18n/id.ts +2 -7
- package/src/tool/petRation/i18n/it.ts +2 -7
- package/src/tool/petRation/i18n/ja.ts +2 -7
- package/src/tool/petRation/i18n/ko.ts +3 -8
- package/src/tool/petRation/i18n/nl.ts +2 -7
- package/src/tool/petRation/i18n/pl.ts +2 -7
- package/src/tool/petRation/i18n/pt.ts +2 -7
- package/src/tool/petRation/i18n/ru.ts +8 -13
- package/src/tool/petRation/i18n/sv.ts +2 -7
- package/src/tool/petRation/i18n/tr.ts +5 -10
- package/src/tool/petRation/i18n/zh.ts +5 -10
- package/src/tool/petRation/seo.astro +2 -48
- package/src/types.ts +0 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface ToolExportValidationResult {
|
|
4
|
+
passed: boolean;
|
|
5
|
+
failures: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function validateComponentType(
|
|
9
|
+
toolId: string,
|
|
10
|
+
componentName: string,
|
|
11
|
+
component: unknown,
|
|
12
|
+
failures: string[],
|
|
13
|
+
): void {
|
|
14
|
+
if (typeof component !== 'function') {
|
|
15
|
+
failures.push(`${toolId}: ${componentName} is not a function (${typeof component})`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function validateComponentExecution(
|
|
20
|
+
toolId: string,
|
|
21
|
+
componentName: string,
|
|
22
|
+
fn: () => Promise<unknown>,
|
|
23
|
+
failures: string[],
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
const result = await fn();
|
|
27
|
+
if (!result || typeof result !== 'object') {
|
|
28
|
+
failures.push(`${toolId}: ${componentName} import returned invalid result`);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failures.push(`${toolId}: ${componentName} execution error - ${error instanceof Error ? error.message : 'unknown'}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function validateToolExports(tools: ToolDefinition[]): Promise<ToolExportValidationResult> {
|
|
36
|
+
const failures: string[] = [];
|
|
37
|
+
|
|
38
|
+
for (const tool of tools) {
|
|
39
|
+
validateComponentType(tool.entry.id, 'Component', tool.Component, failures);
|
|
40
|
+
validateComponentType(tool.entry.id, 'SEOComponent', tool.SEOComponent, failures);
|
|
41
|
+
validateComponentType(tool.entry.id, 'BibliographyComponent', tool.BibliographyComponent, failures);
|
|
42
|
+
|
|
43
|
+
const componentFn = tool.Component as () => Promise<unknown>;
|
|
44
|
+
const seoFn = tool.SEOComponent as () => Promise<unknown>;
|
|
45
|
+
const bibFn = tool.BibliographyComponent as () => Promise<unknown>;
|
|
46
|
+
|
|
47
|
+
await validateComponentExecution(tool.entry.id, 'Component', componentFn, failures);
|
|
48
|
+
await validateComponentExecution(tool.entry.id, 'SEOComponent', seoFn, failures);
|
|
49
|
+
await validateComponentExecution(tool.entry.id, 'BibliographyComponent', bibFn, failures);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
passed: failures.length === 0,
|
|
54
|
+
failures,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import { validateToolExports } from './shared-test-helpers';
|
|
4
|
+
|
|
5
|
+
describe('Tool Exports Pattern Validation', () => {
|
|
6
|
+
describe('Component Exports Format', () => {
|
|
7
|
+
ALL_TOOLS.forEach((tool) => {
|
|
8
|
+
it(`${tool.entry.id}: Component should be a lazy-loaded function`, () => {
|
|
9
|
+
expect(typeof tool.Component).toBe('function');
|
|
10
|
+
expect(tool.Component).toBeInstanceOf(Function);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it(`${tool.entry.id}: SEOComponent should be a lazy-loaded function`, () => {
|
|
14
|
+
expect(typeof tool.SEOComponent).toBe('function');
|
|
15
|
+
expect(tool.SEOComponent).toBeInstanceOf(Function);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it(`${tool.entry.id}: BibliographyComponent should be a lazy-loaded function`, () => {
|
|
19
|
+
expect(typeof tool.BibliographyComponent).toBe('function');
|
|
20
|
+
expect(tool.BibliographyComponent).toBeInstanceOf(Function);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Dynamic Import Validation', () => {
|
|
26
|
+
it('all tools must have functional dynamic imports', async () => {
|
|
27
|
+
const result = await validateToolExports(ALL_TOOLS);
|
|
28
|
+
if (!result.passed) {
|
|
29
|
+
throw new Error(`Tool export validation failed:\n${result.failures.join('\n')}`);
|
|
30
|
+
}
|
|
31
|
+
expect(result.passed).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -131,8 +131,8 @@ describe('Tool Validation Suite', () => {
|
|
|
131
131
|
const content = fs.readFileSync(componentPath, 'utf-8');
|
|
132
132
|
|
|
133
133
|
expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
|
|
134
|
-
expect(content).toContain(
|
|
135
|
-
expect(content).toContain('<SharedBibliography links={
|
|
134
|
+
expect(content).toContain("import { bibliography } from './bibliography'");
|
|
135
|
+
expect(content).toContain('<SharedBibliography links={bibliography} />');
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
});
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
-
import {
|
|
4
|
-
import type { KnownLocale } from '../../types';
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
locale?: KnownLocale;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const { locale = 'es' } = Astro.props;
|
|
11
|
-
const content = await petAge.i18n[locale]?.();
|
|
3
|
+
import { bibliography } from './bibliography';
|
|
12
4
|
---
|
|
13
5
|
|
|
14
|
-
|
|
6
|
+
<SharedBibliography links={bibliography} />
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'AAHA Senior Care Guidelines for Dogs and Cats', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
5
|
+
{ name: 'AKC: How to Calculate Dog Years to Human Years', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
6
|
+
];
|
package/src/tool/petAge/entry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Lebensphase analysieren', text: 'Überprüfen Sie das Ergebnis, um zu wissen, ob Ihr Haustier im Vergleich zum Menschen im Säuglings-, Jugend-, Erwachsenen- oder Seniorenstadium ist.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'AAHA Senioren-Pflege-Richtlinien für Hunde und Katzen', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC: Wie man Hundejahre in Menschenjahre umrechnet', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliographie',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Analyze life stage', text: 'Check the result to know if your pet is in the infant, young, adult, or senior stage compared to a human and understand their current needs.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'AAHA Senior Care Guidelines for Dogs and Cats', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC: How to Calculate Dog Years to Human Years', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliography',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Analiza su etapa de vida', text: 'Revisa el resultado para conocer si tu mascota está en etapa de cachorro, joven, adulto o senior frente a un humano y comprende sus necesidades actuales.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'Pautas de la AAHA sobre el cuidado de personas mayores para perros y gatos', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC: Cómo calcular los años de perro en años humanos', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliografía',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,20 +67,15 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Analysez l\'étape de vie', text: 'Vérifiez le résultat pour savoir si votre animal est au stade de chiot, jeune, adulte ou senior par rapport à un humain et comprenez ses besoins actuels.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'Lignes directrices de l\'AAHA sur les soins aux seniors pour chiens et chats', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC : Comment calculer les années de chien en années humaines', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
77
73
|
title: 'Ce que vous devez savoir sur le vieillissement des animaux',
|
|
78
74
|
items: [
|
|
79
|
-
'La "règle de 7" est un mythe
|
|
75
|
+
'La "règle de 7" est un mythe: les chiens mûrissent les 2 premières années.',
|
|
80
76
|
'Un chien de 1 an équivaut à 15 ans humains ; un chien de 2 ans équivaut à 24 ans humains.',
|
|
81
77
|
'Les grands chiens vieillissent plus vite que les petits après 2 ans.',
|
|
82
|
-
'Les chats suivent une courbe plus linéaire
|
|
78
|
+
'Les chats suivent une courbe plus linéaire: l\'année de chat équivaut à 4 ans humains après 2 ans.',
|
|
83
79
|
'L\'âge biologique est un meilleur prédicteur des besoins de santé que l\'âge chronologique.',
|
|
84
80
|
'Des dépistages réguliers peuvent aider à détecter les problèmes liés à l\'âge.',
|
|
85
81
|
],
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliographie',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Analisis tahap kehidupan', text: 'Periksa hasilnya untuk mengetahui apakah hewan peliharaan Anda berada dalam tahap bayi, muda, dewasa, atau senior dibandingkan dengan manusia dan pahami kebutuhan mereka saat ini.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'Pedoman Perawatan Senior AAHA untuk Anjing dan Kucing', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC: Cara Menghitung Tahun Anjing ke Tahun Manusia', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliografi',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'Analizza la fase di vita', text: 'Verifica il risultato per sapere se il tuo animale è al stadio di cucciolo, giovane, adulto o senior rispetto a un umano e comprendi le sue necessità attuali.' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'Linee guida dell\'AAHA sulla cura dei senior per cani e gatti', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC: Come calcolare gli anni di cane in anni umani', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: 'Bibliografia',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
1
2
|
import type { WithContext, SoftwareApplication } from 'schema-dts';
|
|
2
3
|
import type { PetAgeUI, PetAgeLocaleContent } from '../index';
|
|
3
4
|
|
|
@@ -66,11 +67,6 @@ const howTo: PetAgeLocaleContent['howTo'] = [
|
|
|
66
67
|
{ name: 'ライフステージを確認', text: '計算 結果 から、 人間 と 比較 して 現在 の ペット が 幼少期 、 青年期 、 成人期 、 シニア期 の どの 段階 に ある か を 確認 します。' },
|
|
67
68
|
];
|
|
68
69
|
|
|
69
|
-
const bibliography: PetAgeLocaleContent['bibliography'] = [
|
|
70
|
-
{ name: 'AAHA 犬と猫のシニアケアガイドライン', url: 'https://www.aaha.org/wp-content/uploads/globalassets/02-guidelines/2023-aaha-senior-care-guidelines-for-dogs-and-cats/resources/2023-aaha-senior-care-guidelines-for-dogs-and-cats.pdf' },
|
|
71
|
-
{ name: 'AKC:犬の年齢を人間の年齢に換算する方法', url: 'https://www.akc.org/expert-advice/health/how-to-calculate-dog-years-to-human-years/' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
70
|
const seo: PetAgeLocaleContent['seo'] = [
|
|
75
71
|
{
|
|
76
72
|
type: 'summary',
|
|
@@ -180,9 +176,8 @@ export const content: PetAgeLocaleContent = {
|
|
|
180
176
|
description,
|
|
181
177
|
ui,
|
|
182
178
|
seo,
|
|
183
|
-
|
|
179
|
+
|
|
184
180
|
faq,
|
|
185
|
-
bibliographyTitle: '参考文献',
|
|
186
181
|
bibliography,
|
|
187
182
|
howTo,
|
|
188
183
|
schemas,
|