@jjlmoya/utils-performing-arts 1.1.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/.github/workflows/npm-publish.yml +40 -0
- package/.gitignore +6 -0
- package/.stylelintrc.json +98 -0
- package/astro.config.mjs +19 -0
- package/eslint.config.js +201 -0
- package/package.json +79 -0
- package/prompts/create_tool.md +98 -0
- package/prompts/i18n/de.md +16 -0
- package/prompts/i18n/en.md +16 -0
- package/prompts/i18n/es.md +16 -0
- package/prompts/i18n/fr.md +16 -0
- package/prompts/i18n/id.md +16 -0
- package/prompts/i18n/it.md +16 -0
- package/prompts/i18n/ja.md +16 -0
- package/prompts/i18n/ko.md +16 -0
- package/prompts/i18n/nl.md +16 -0
- package/prompts/i18n/pl.md +16 -0
- package/prompts/i18n/pt.md +16 -0
- package/prompts/i18n/ru.md +16 -0
- package/prompts/i18n/sv.md +16 -0
- package/prompts/i18n/tr.md +16 -0
- package/prompts/i18n/zh.md +16 -0
- package/prompts/seo.md +58 -0
- package/prompts/translations/french.md +33 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/PerformingArtsCategorySEO.astro +9 -0
- package/src/category/i18n/de.ts +27 -0
- package/src/category/i18n/en.ts +27 -0
- package/src/category/i18n/es.ts +27 -0
- package/src/category/i18n/fr.ts +27 -0
- package/src/category/i18n/id.ts +27 -0
- package/src/category/i18n/it.ts +27 -0
- package/src/category/i18n/ja.ts +27 -0
- package/src/category/i18n/ko.ts +27 -0
- package/src/category/i18n/nl.ts +27 -0
- package/src/category/i18n/pl.ts +27 -0
- package/src/category/i18n/pt.ts +27 -0
- package/src/category/i18n/ru.ts +27 -0
- package/src/category/i18n/sv.ts +27 -0
- package/src/category/i18n/tr.ts +27 -0
- package/src/category/i18n/zh.ts +27 -0
- package/src/category/index.ts +10 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +10 -0
- package/src/entries.ts +3 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +20 -0
- package/src/layouts/PreviewLayout.astro +118 -0
- package/src/pages/[locale]/[slug].astro +165 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/faq_count.test.ts +18 -0
- package/src/tests/i18n_coverage.test.ts +34 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +23 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_em_dash.test.ts +47 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +43 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +100 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/seo_length.test.ts +22 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/spanish_leakage.test.ts +175 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +16 -0
- package/src/tests/translation_copy.test.ts +115 -0
- package/src/tool/rehearsal-call-sheet-planner/bibliography.astro +6 -0
- package/src/tool/rehearsal-call-sheet-planner/bibliography.ts +12 -0
- package/src/tool/rehearsal-call-sheet-planner/component.astro +74 -0
- package/src/tool/rehearsal-call-sheet-planner/controller.ts +98 -0
- package/src/tool/rehearsal-call-sheet-planner/dom-views.ts +86 -0
- package/src/tool/rehearsal-call-sheet-planner/entry.ts +27 -0
- package/src/tool/rehearsal-call-sheet-planner/evaluator.ts +27 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/de.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/en.ts +158 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/es.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/fr.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/id.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/it.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ja.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ko.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/nl.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/pl.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/pt.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ru.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/sv.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/tr.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/zh.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/index.ts +11 -0
- package/src/tool/rehearsal-call-sheet-planner/logic.test.ts +68 -0
- package/src/tool/rehearsal-call-sheet-planner/logic.ts +229 -0
- package/src/tool/rehearsal-call-sheet-planner/rehearsal-call-sheet-planner.css +593 -0
- package/src/tool/rehearsal-call-sheet-planner/seo.astro +16 -0
- package/src/tool/rehearsal-call-sheet-planner/storage.ts +33 -0
- package/src/tool/rehearsal-call-sheet-planner/types.ts +84 -0
- package/src/tool/rehearsal-call-sheet-planner/ui.ts +70 -0
- package/src/tools.ts +8 -0
- package/src/types.ts +68 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
function hasDedicatedLogicTest(toolDirectory: string): boolean {
|
|
13
|
+
const localCandidates = [
|
|
14
|
+
join(toolDirectory, 'logic.test.ts'),
|
|
15
|
+
join(toolDirectory, 'logic.spec.ts'),
|
|
16
|
+
join(toolDirectory, '__tests__', 'logic.test.ts'),
|
|
17
|
+
join(toolDirectory, '__tests__', 'logic.spec.ts'),
|
|
18
|
+
];
|
|
19
|
+
if (localCandidates.some(existsSync)) return true;
|
|
20
|
+
|
|
21
|
+
const toolName = basename(toolDirectory);
|
|
22
|
+
const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
|
|
23
|
+
return centralTests.some((testPath) => {
|
|
24
|
+
const source = read(testPath).replace(/\\/g, '/');
|
|
25
|
+
return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('QA: calculation logic has reference tests', () => {
|
|
30
|
+
it('gives every public calculator logic module its own behavioral test suite', () => {
|
|
31
|
+
const failures = listToolDirectories()
|
|
32
|
+
.filter((directory) => existsSync(join(directory, 'logic.ts')))
|
|
33
|
+
.filter((directory) => !hasDedicatedLogicTest(directory))
|
|
34
|
+
.map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
failures,
|
|
38
|
+
[
|
|
39
|
+
'Add behavioral tests through each logic module public API.',
|
|
40
|
+
'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
|
|
41
|
+
...failures,
|
|
42
|
+
].join('\n'),
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
interface VisibleLiteral {
|
|
13
|
+
line: number;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
|
|
18
|
+
const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
|
|
19
|
+
|
|
20
|
+
function visibleWords(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/\$\{[^}]*\}/g, ' ')
|
|
23
|
+
.replace(/<[^>]+>/g, ' ')
|
|
24
|
+
.replace(/&[a-z]+;/gi, ' ')
|
|
25
|
+
.replace(/[\d\p{P}\p{S}_]+/gu, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isTechnicalLiteral(value: string): boolean {
|
|
31
|
+
return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
|
|
32
|
+
|| /^data-[a-z0-9-]+$/i.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
|
|
36
|
+
const failures: VisibleLiteral[] = [];
|
|
37
|
+
|
|
38
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
39
|
+
if (!domTextAssignment.test(line)) return;
|
|
40
|
+
|
|
41
|
+
const assignment = line.slice(line.search(domTextAssignment));
|
|
42
|
+
for (const match of assignment.matchAll(quotedLiteral)) {
|
|
43
|
+
const literal = match[2];
|
|
44
|
+
if (!literal || isTechnicalLiteral(literal)) continue;
|
|
45
|
+
const words = visibleWords(literal);
|
|
46
|
+
if (words && /\p{L}/u.test(words)) {
|
|
47
|
+
failures.push({ line: index + 1, value: literal });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return failures;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runtimeSources(toolDirectory: string): string {
|
|
56
|
+
return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
|
|
57
|
+
.filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
|
|
58
|
+
.filter((path) => basename(path) !== 'ui.ts')
|
|
59
|
+
.map(read)
|
|
60
|
+
.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('QA: runtime copy is localized', () => {
|
|
64
|
+
const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
|
|
65
|
+
|
|
66
|
+
it('does not write user-facing string literals directly into the DOM', () => {
|
|
67
|
+
const failures = componentFiles.flatMap((path) =>
|
|
68
|
+
hardcodedVisibleLiterals(read(path)).map(
|
|
69
|
+
({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
failures,
|
|
75
|
+
`Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
|
|
76
|
+
).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('uses every string declared by each UI locale contract', () => {
|
|
80
|
+
const failures: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const directory of listToolDirectories()) {
|
|
83
|
+
const uiPath = join(directory, 'ui.ts');
|
|
84
|
+
if (!existsSync(uiPath)) continue;
|
|
85
|
+
|
|
86
|
+
const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
|
|
87
|
+
const runtime = runtimeSources(directory);
|
|
88
|
+
const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
|
|
89
|
+
|
|
90
|
+
if (unused.length > 0) {
|
|
91
|
+
failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(
|
|
96
|
+
failures,
|
|
97
|
+
`Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
|
|
98
|
+
).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('Schemas Fulfillment Validation', () => {
|
|
6
|
+
ALL_TOOLS.forEach((tool) => {
|
|
7
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
+
Object.keys(tool.entry.i18n).forEach((locale) => {
|
|
9
|
+
it(`Locale: ${locale} should have faqSchema, appSchema and howToSchema`, async () => {
|
|
10
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
11
|
+
if (!loader) return;
|
|
12
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
13
|
+
|
|
14
|
+
const schemaTypes = content.schemas.map((s: any) => s['@type']);
|
|
15
|
+
|
|
16
|
+
expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing FAQPage schema`).toContain('FAQPage');
|
|
17
|
+
expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing SoftwareApplication schema`).toContain('SoftwareApplication');
|
|
18
|
+
expect(schemaTypes, `Tool "${tool.entry.id}" locale "${locale}" is missing HowTo schema`).toContain('HowTo');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -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 unknown 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,22 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const ENTRIES = [
|
|
5
|
+
{ id: 'performingArtsCategory', i18n: DATA.performingArtsCategory.i18n },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
describe('SEO Content Length Validation', () => {
|
|
9
|
+
ENTRIES.forEach((entry) => {
|
|
10
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
11
|
+
Object.keys(entry.i18n).forEach((locale) => {
|
|
12
|
+
it(`${locale}: SEO section should exist`, async () => {
|
|
13
|
+
const loader = (entry.i18n as Record<string, () => Promise<{ seo?: unknown[] }>>)[locale];
|
|
14
|
+
if (!loader) return;
|
|
15
|
+
const content = await loader();
|
|
16
|
+
if (!content.seo) return;
|
|
17
|
+
expect(Array.isArray(content.seo)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
import type { KnownLocale } from '../types';
|
|
4
|
+
|
|
5
|
+
interface ExpectedCounts {
|
|
6
|
+
seo: number;
|
|
7
|
+
faq: number;
|
|
8
|
+
howTo: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function countItems(arr: unknown[] | undefined): number {
|
|
12
|
+
return arr?.length ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function verifyLocaleParity(
|
|
16
|
+
entry: typeof ALL_ENTRIES[number],
|
|
17
|
+
loc: KnownLocale,
|
|
18
|
+
expected: ExpectedCounts,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const locContent = await entry.i18n[loc]?.();
|
|
21
|
+
expect(locContent, `Locale ${loc} missing content`).toBeDefined();
|
|
22
|
+
|
|
23
|
+
const locSeoCount = countItems(locContent?.seo);
|
|
24
|
+
const locFaqCount = countItems(locContent?.faq);
|
|
25
|
+
const locHowToCount = countItems(locContent?.howTo);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
locSeoCount,
|
|
29
|
+
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
+
).toBeGreaterThanOrEqual(expected.seo);
|
|
31
|
+
expect(
|
|
32
|
+
locFaqCount,
|
|
33
|
+
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
34
|
+
).toBe(expected.faq);
|
|
35
|
+
expect(
|
|
36
|
+
locHowToCount,
|
|
37
|
+
`Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
|
|
38
|
+
).toBe(expected.howTo);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
|
+
ALL_ENTRIES.forEach((entry) => {
|
|
43
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
+
it('all registered locales should provide at least the complete English SEO section set', async () => {
|
|
45
|
+
const enContent = await entry.i18n.en?.();
|
|
46
|
+
expect(enContent).toBeDefined();
|
|
47
|
+
const expected: ExpectedCounts = {
|
|
48
|
+
seo: countItems(enContent?.seo),
|
|
49
|
+
faq: countItems(enContent?.faq),
|
|
50
|
+
howTo: countItems(enContent?.howTo),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const locales = Object.keys(entry.i18n) as KnownLocale[];
|
|
54
|
+
for (const loc of locales) {
|
|
55
|
+
await verifyLocaleParity(entry, loc, expected);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
|
|
5
|
+
|
|
6
|
+
function getSimpleSectionLength(section: any): number {
|
|
7
|
+
if (section.type === 'title') return section.text ? section.text.length : 0;
|
|
8
|
+
if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
|
|
9
|
+
if (section.type === 'list') return section.items ? section.items.join('').length : 0;
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getComplexSectionLength(section: any): number {
|
|
14
|
+
if (section.type === 'diagnostic' || section.type === 'tip') {
|
|
15
|
+
return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
|
|
16
|
+
}
|
|
17
|
+
if (section.type === 'table') {
|
|
18
|
+
return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getSectionLength(section: any): number {
|
|
24
|
+
return getSimpleSectionLength(section) || getComplexSectionLength(section);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function calculateSeoTextLength(seoSections: any[]): number {
|
|
28
|
+
return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
|
|
32
|
+
const isAsian = ASIAN_LOCALES.includes(locale);
|
|
33
|
+
const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
|
|
34
|
+
const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
localeLen,
|
|
38
|
+
`[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
|
|
39
|
+
).toBeGreaterThanOrEqual(minLength);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
|
|
43
|
+
if (locale === 'en' || !loader) return;
|
|
44
|
+
const content = await loader();
|
|
45
|
+
if (!content.seo || !Array.isArray(content.seo)) return;
|
|
46
|
+
|
|
47
|
+
checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('SEO Translation Completeness & Laziness Audit', () => {
|
|
51
|
+
ALL_TOOLS.forEach(({ entry }) => {
|
|
52
|
+
describe(`Tool: ${entry.id}`, () => {
|
|
53
|
+
it('should not have lazy or truncated SEO content compared to English reference', async () => {
|
|
54
|
+
const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
|
|
55
|
+
if (!enLoader) return;
|
|
56
|
+
|
|
57
|
+
const enContent = await enLoader();
|
|
58
|
+
if (!enContent.seo || !Array.isArray(enContent.seo)) return;
|
|
59
|
+
|
|
60
|
+
const enSeoLength = calculateSeoTextLength(enContent.seo);
|
|
61
|
+
|
|
62
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
63
|
+
await auditSingleLocale(entry.id, locale, loader, enSeoLength);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findSeoAstroFiles(dir: string): string[] {
|
|
6
|
+
const files: string[] = [];
|
|
7
|
+
if (!existsSync(dir)) return files;
|
|
8
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...findSeoAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'seo.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const seoFiles = findSeoAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('SEO Component Wellformed Export', () => {
|
|
26
|
+
it('found tool seo.astro components', () => {
|
|
27
|
+
expect(seoFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
seoFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSeoRenderer =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('SEORenderer') || content.includes('SEOArticle'));
|
|
39
|
+
|
|
40
|
+
const acquiresDynamicContent =
|
|
41
|
+
content.includes('.i18n') ||
|
|
42
|
+
content.includes('loader') ||
|
|
43
|
+
content.includes('await loader') ||
|
|
44
|
+
content.includes('.seo');
|
|
45
|
+
|
|
46
|
+
const isBrokenPattern =
|
|
47
|
+
/sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
usesSeoRenderer,
|
|
51
|
+
`File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
isBrokenPattern,
|
|
56
|
+
`File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
|
|
57
|
+
).toBe(false);
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
acquiresDynamicContent,
|
|
61
|
+
`File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -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,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
describe('Slug Language Code Format Validation', () => {
|
|
6
|
+
ALL_TOOLS.forEach((tool) => {
|
|
7
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
8
|
+
it('slug should not end with 2-letter language codes like -ja, -ru, -ko', async () => {
|
|
9
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
10
|
+
|
|
11
|
+
for (const locale of locales) {
|
|
12
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
13
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
14
|
+
|
|
15
|
+
expect(
|
|
16
|
+
content.slug,
|
|
17
|
+
`Tool "${tool.entry.id}" locale "${locale}" slug ("${content.slug}") cannot end with a 2-letter language code (e.g., -ja, -ru, -ko).`,
|
|
18
|
+
).not.toMatch(/-[a-z]{2}$/);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
const sharingLocales = ['ja', 'ko', 'zh'];
|
|
6
|
+
|
|
7
|
+
interface ValidateParams {
|
|
8
|
+
toolId: string;
|
|
9
|
+
locale: string;
|
|
10
|
+
content: ToolLocaleContent;
|
|
11
|
+
enSlug: string;
|
|
12
|
+
slugs: Map<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const validateLocaleSlug = ({
|
|
16
|
+
toolId,
|
|
17
|
+
locale,
|
|
18
|
+
content,
|
|
19
|
+
enSlug,
|
|
20
|
+
slugs,
|
|
21
|
+
}: ValidateParams) => {
|
|
22
|
+
expect(
|
|
23
|
+
content.slug,
|
|
24
|
+
`Tool "${toolId}" locale "${locale}" has an invalid slug ("${content.slug}"). Slugs must be transliterated (only a-z, 0-9, and -).`,
|
|
25
|
+
).toMatch(/^[a-z0-9-]+$/);
|
|
26
|
+
|
|
27
|
+
if (locale === 'en') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (sharingLocales.includes(locale)) {
|
|
32
|
+
expect(
|
|
33
|
+
content.slug,
|
|
34
|
+
`Tool "${toolId}" locale "${locale}" must use the same slug as "en" ("${enSlug}").`,
|
|
35
|
+
).toBe(enSlug);
|
|
36
|
+
} else {
|
|
37
|
+
expect(
|
|
38
|
+
content.slug,
|
|
39
|
+
`Tool "${toolId}" locale "${locale}" has the same slug as "en" ("${enSlug}"). Cada slug tiene que estar en su propia idioma`,
|
|
40
|
+
).not.toBe(enSlug);
|
|
41
|
+
|
|
42
|
+
if (slugs.has(content.slug)) {
|
|
43
|
+
const previousLocale = slugs.get(content.slug);
|
|
44
|
+
expect(
|
|
45
|
+
false,
|
|
46
|
+
`Tool "${toolId}" locales "${locale}" and "${previousLocale}" share the same slug ("${content.slug}"). Cada slug tiene que estar en su propia idioma`,
|
|
47
|
+
).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
slugs.set(content.slug, locale);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('Slug Localization and Uniqueness Validation', () => {
|
|
54
|
+
ALL_TOOLS.forEach((tool) => {
|
|
55
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
56
|
+
it('every locale should have a unique, translated slug', async () => {
|
|
57
|
+
const slugs = new Map<string, string>();
|
|
58
|
+
const locales = Object.keys(tool.entry.i18n);
|
|
59
|
+
|
|
60
|
+
let enSlug = '';
|
|
61
|
+
if (locales.includes('en')) {
|
|
62
|
+
const enLoader = tool.entry.i18n['en' as keyof typeof tool.entry.i18n];
|
|
63
|
+
const enContent = (await enLoader?.()) as ToolLocaleContent;
|
|
64
|
+
enSlug = enContent.slug;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const locale of locales) {
|
|
68
|
+
const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
|
|
69
|
+
const content = (await loader?.()) as ToolLocaleContent;
|
|
70
|
+
validateLocaleSlug({
|
|
71
|
+
toolId: tool.entry.id,
|
|
72
|
+
locale,
|
|
73
|
+
content,
|
|
74
|
+
enSlug,
|
|
75
|
+
slugs,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|