@jjlmoya/utils-social 1.19.0 → 1.20.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 +2 -2
- package/src/category/i18n/es.ts +1 -1
- package/src/category/index.ts +25 -25
- package/src/category/seo.astro +1 -1
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/data.ts +1 -1
- package/src/env.d.ts +1 -1
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/faq_count.test.ts +19 -19
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/seo_length.test.ts +22 -22
- package/src/tests/seo_parity.test.ts +2 -2
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/spanish_leakage.test.ts +175 -175
- package/src/tests/tool_validation.test.ts +17 -17
- package/src/tool/redditFormatter/seo.astro +15 -15
- package/src/tool/safeZones/seo.astro +15 -15
- package/src/tool/socialImageResizer/seo.astro +15 -15
- package/src/tool/socialValueCalculator/seo.astro +15 -15
- package/src/tool/tinderPhotoOptimizer/seo.astro +15 -15
- package/src/tool/youtubeThumbnail/seo.astro +15 -15
- package/src/tool/youtubeThumbnailPreviewer/seo.astro +15 -15
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-social",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
|
-
"test": "vitest run",
|
|
30
|
+
"test": "vitest run --reporter=verbose --testTimeout=30000",
|
|
31
31
|
"preversion": "npm run lint && npm run test && npm run build",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
package/src/category/i18n/es.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const content: CategoryLocaleContent = {
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
type: 'paragraph',
|
|
16
|
-
html: 'El contenido vertical ha revolucionado el consumo digital. TikTok, Instagram Reels y YouTube Shorts compiten por los mismos segundos de atención y cada
|
|
16
|
+
html: 'El contenido vertical ha revolucionado el consumo digital. TikTok, Instagram Reels y YouTube Shorts compiten por los mismos segundos de atención y cada píxel cuenta. Esta colección de utilidades nace para optimizar y revisar el contenido antes de publicarlo, comprobando cómo se verá en cada formato.',
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
type: 'paragraph',
|
package/src/category/index.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import type { SocialCategoryEntry } from '../types';
|
|
2
|
-
import { safeZones } from '../tool/safeZones/entry';
|
|
3
|
-
|
|
4
|
-
export const socialCategory: SocialCategoryEntry = {
|
|
5
|
-
icon: 'mdi:account-group',
|
|
6
|
-
tools: [safeZones],
|
|
7
|
-
i18n: {
|
|
8
|
-
es: () => import('./i18n/es').then((m) => m.content),
|
|
9
|
-
en: () => import('./i18n/en').then((m) => m.content),
|
|
10
|
-
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
11
|
-
de: () => import('./i18n/de').then((m) => m.content),
|
|
12
|
-
id: () => import('./i18n/id').then((m) => m.content),
|
|
13
|
-
it: () => import('./i18n/it').then((m) => m.content),
|
|
14
|
-
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
15
|
-
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
16
|
-
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
17
|
-
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
18
|
-
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
19
|
-
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
20
|
-
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
21
|
-
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
22
|
-
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
|
|
1
|
+
import type { SocialCategoryEntry } from '../types';
|
|
2
|
+
import { safeZones } from '../tool/safeZones/entry';
|
|
3
|
+
|
|
4
|
+
export const socialCategory: SocialCategoryEntry = {
|
|
5
|
+
icon: 'mdi:account-group',
|
|
6
|
+
tools: [safeZones],
|
|
7
|
+
i18n: {
|
|
8
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
9
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
10
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
11
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
12
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
13
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
14
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
15
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
16
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
17
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
18
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
19
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
20
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
21
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
22
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
package/src/category/seo.astro
CHANGED
package/src/data.ts
CHANGED
package/src/env.d.ts
CHANGED
package/src/pages/index.astro
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findBibliographyAstroFiles(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(...findBibliographyAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'bibliography.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const bibliographyFiles = findBibliographyAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('Bibliography Component Wellformed Export', () => {
|
|
26
|
+
it('found tool bibliography.astro components', () => {
|
|
27
|
+
expect(bibliographyFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
bibliographyFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSharedComponent =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('Bibliography') || content.includes('SharedBibliography'));
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
usesSharedComponent,
|
|
42
|
+
`File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CategoryLocaleContent } from '../types';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_LOCALES = [
|
|
5
|
+
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
|
|
6
|
+
] as const;
|
|
7
|
+
const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
|
|
8
|
+
|
|
9
|
+
const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
|
|
10
|
+
string,
|
|
11
|
+
{ content: CategoryLocaleContent }
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
function textFrom(value: unknown): string {
|
|
15
|
+
if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
|
|
16
|
+
if (Array.isArray(value)) return value.map(textFrom).join(' ');
|
|
17
|
+
if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
|
|
22
|
+
|
|
23
|
+
function getContents(): Map<string, CategoryLocaleContent> {
|
|
24
|
+
return new Map(
|
|
25
|
+
Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateContent(locale: string, content: CategoryLocaleContent): string[] {
|
|
30
|
+
const failures: string[] = [];
|
|
31
|
+
const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
|
|
32
|
+
const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
|
|
33
|
+
const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
|
|
34
|
+
check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
|
|
35
|
+
check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
|
|
36
|
+
check(content.seo.length >= 2, 'SEO needs at least two sections');
|
|
37
|
+
check(content.seo.some((section) => section.type === 'paragraph'), 'SEO needs visible explanatory copy');
|
|
38
|
+
|
|
39
|
+
const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
|
|
40
|
+
const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
|
|
41
|
+
check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
|
|
42
|
+
check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
|
|
43
|
+
check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
|
|
44
|
+
check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
|
|
45
|
+
check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
|
|
46
|
+
|
|
47
|
+
check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
|
|
48
|
+
return failures;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Category SEO quality contract', () => {
|
|
52
|
+
it('has one content file for every configured locale', () => {
|
|
53
|
+
const contents = getContents();
|
|
54
|
+
expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
|
|
58
|
+
const contents = getContents();
|
|
59
|
+
const english = contents.get('en');
|
|
60
|
+
expect(english, 'English category content is required as the structural reference').toBeDefined();
|
|
61
|
+
|
|
62
|
+
const failures = EXPECTED_LOCALES.flatMap((locale) => {
|
|
63
|
+
const content = contents.get(locale);
|
|
64
|
+
return content ? validateContent(locale, content) : [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
68
|
+
const content = contents.get(locale);
|
|
69
|
+
if (!content) failures.push(`${locale}: category content is missing`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(failures, 'category SEO quality failures').toEqual([]);
|
|
73
|
+
}, 30000);
|
|
74
|
+
});
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import type * as DATA from '../data';
|
|
3
|
-
|
|
4
|
-
const TOOLS: typeof DATA.socialCategory[] = [];
|
|
5
|
-
|
|
6
|
-
describe('FAQ Content Validation', () => {
|
|
7
|
-
TOOLS.forEach((entry) => {
|
|
8
|
-
describe(`Tool: ${entry.icon}`, () => {
|
|
9
|
-
it('placeholder', () => {
|
|
10
|
-
expect(true).toBe(true);
|
|
11
|
-
});
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('no tools registered yet', () => {
|
|
16
|
-
expect(TOOLS.length).toBe(0);
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const TOOLS: typeof DATA.socialCategory[] = [];
|
|
5
|
+
|
|
6
|
+
describe('FAQ Content Validation', () => {
|
|
7
|
+
TOOLS.forEach((entry) => {
|
|
8
|
+
describe(`Tool: ${entry.icon}`, () => {
|
|
9
|
+
it('placeholder', () => {
|
|
10
|
+
expect(true).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('no tools registered yet', () => {
|
|
16
|
+
expect(TOOLS.length).toBe(0);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export default function () { return null; }
|
|
2
|
-
|
|
2
|
+
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import * as DATA from '../data';
|
|
3
|
-
|
|
4
|
-
const ENTRIES = [
|
|
5
|
-
{ id: 'socialCategory', i18n: DATA.socialCategory.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
|
-
const content = await loader();
|
|
15
|
-
if (!content.seo) return;
|
|
16
|
-
expect(Array.isArray(content.seo)).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as DATA from '../data';
|
|
3
|
+
|
|
4
|
+
const ENTRIES = [
|
|
5
|
+
{ id: 'socialCategory', i18n: DATA.socialCategory.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
|
+
const content = await loader();
|
|
15
|
+
if (!content.seo) return;
|
|
16
|
+
expect(Array.isArray(content.seo)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
@@ -27,7 +27,7 @@ async function verifyLocaleParity(
|
|
|
27
27
|
expect(
|
|
28
28
|
locSeoCount,
|
|
29
29
|
`Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
|
|
30
|
-
).
|
|
30
|
+
).toBeGreaterThanOrEqual(expected.seo);
|
|
31
31
|
expect(
|
|
32
32
|
locFaqCount,
|
|
33
33
|
`Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
|
|
@@ -41,7 +41,7 @@ async function verifyLocaleParity(
|
|
|
41
41
|
describe('SEO & i18n Structural Parity Suite', () => {
|
|
42
42
|
ALL_ENTRIES.forEach((entry) => {
|
|
43
43
|
describe(`Tool: ${entry.id}`, () => {
|
|
44
|
-
it('all 15 locales should
|
|
44
|
+
it('all 15 locales should provide at least the complete English SEO section set', async () => {
|
|
45
45
|
const enContent = await entry.i18n.en?.();
|
|
46
46
|
expect(enContent).toBeDefined();
|
|
47
47
|
const expected: ExpectedCounts = {
|
|
@@ -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
|
+
});
|
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { ALL_ENTRIES } from '../entries';
|
|
3
|
-
|
|
4
|
-
const STRUCTURAL_KEYS = new Set([
|
|
5
|
-
'id',
|
|
6
|
-
'slug',
|
|
7
|
-
'url',
|
|
8
|
-
'icon',
|
|
9
|
-
'image',
|
|
10
|
-
'imageUrl',
|
|
11
|
-
'keywords',
|
|
12
|
-
'category',
|
|
13
|
-
'tags',
|
|
14
|
-
'toolId',
|
|
15
|
-
'type',
|
|
16
|
-
'locale',
|
|
17
|
-
'language',
|
|
18
|
-
'direction',
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
const TRANSLATABLE_KEYS = [
|
|
22
|
-
'title',
|
|
23
|
-
'description',
|
|
24
|
-
'faqTitle',
|
|
25
|
-
'faq',
|
|
26
|
-
'howTo',
|
|
27
|
-
'seo',
|
|
28
|
-
'schemas',
|
|
29
|
-
] as const;
|
|
30
|
-
|
|
31
|
-
const COPY_THRESHOLD = 0.9;
|
|
32
|
-
|
|
33
|
-
const SPANISH_MARKERS = [
|
|
34
|
-
['sangre', /\bsangre\b/gi],
|
|
35
|
-
['molino', /\bmolino\b/gi],
|
|
36
|
-
['grano', /\bgrano\b/gi],
|
|
37
|
-
['paladar', /\bpaladar\b/gi],
|
|
38
|
-
['cuchillas', /\bcuchillas\b/gi],
|
|
39
|
-
['trozos', /\btrozos\b/gi],
|
|
40
|
-
['frescura', /\bfrescura\b/gi],
|
|
41
|
-
['ingresa', /\bingresa\b/gi],
|
|
42
|
-
['selecciona', /\bselecciona\b/gi],
|
|
43
|
-
['herramienta', /\bherramienta\b/gi],
|
|
44
|
-
['según', /\bsegún\b/gi],
|
|
45
|
-
['después', /\bdespués\b/gi],
|
|
46
|
-
['puedes', /\bpuedes\b/gi],
|
|
47
|
-
['debes', /\bdebes\b/gi],
|
|
48
|
-
['tus', /\btus\b/gi],
|
|
49
|
-
['a la vez', /\ba la vez\b/gi],
|
|
50
|
-
['los datos', /\blos datos\b/gi],
|
|
51
|
-
['las opciones', /\blas opciones\b/gi],
|
|
52
|
-
['el resultado', /\bel resultado\b/gi],
|
|
53
|
-
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
54
|
-
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
55
|
-
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
56
|
-
] as const;
|
|
57
|
-
|
|
58
|
-
type UnknownRecord = Record<string, unknown>;
|
|
59
|
-
|
|
60
|
-
function normalize(value: string): string {
|
|
61
|
-
return value
|
|
62
|
-
.replace(/<[^>]*>/g, ' ')
|
|
63
|
-
.replace(/ /gi, ' ')
|
|
64
|
-
.replace(/\s+/g, ' ')
|
|
65
|
-
.trim()
|
|
66
|
-
.toLocaleLowerCase('es');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function isTechnicalInvariant(text: string): boolean {
|
|
70
|
-
const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
|
|
71
|
-
return [
|
|
72
|
-
'data:image/svg+xml;base64',
|
|
73
|
-
'background-image: url',
|
|
74
|
-
'.layout-playground {',
|
|
75
|
-
'const samplerate =',
|
|
76
|
-
].some((pattern) => text.includes(pattern)) ||
|
|
77
|
-
(text.includes('presets') && text.includes('hz')) ||
|
|
78
|
-
(text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
|
|
79
|
-
(text.includes('led') && ledUnitMatches.length >= 3);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function collectString(value: string, output: string[]): void {
|
|
83
|
-
const text = normalize(value);
|
|
84
|
-
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
88
|
-
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
89
|
-
collectText(childValue, output, childKey);
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function collectText(value: unknown, output: string[], key?: string): void {
|
|
94
|
-
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
95
|
-
if (typeof value === 'string') return collectString(value, output);
|
|
96
|
-
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
97
|
-
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function loadText(loader: unknown): Promise<string[]> {
|
|
101
|
-
if (typeof loader !== 'function') return [];
|
|
102
|
-
const module = await (loader as () => Promise<unknown>)();
|
|
103
|
-
const output: string[] = [];
|
|
104
|
-
if (module && typeof module === 'object') {
|
|
105
|
-
const record = module as UnknownRecord;
|
|
106
|
-
for (const key of TRANSLATABLE_KEYS) {
|
|
107
|
-
collectText(record[key], output, key);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return output;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function findSpanishMarkers(text: string[]): string[] {
|
|
114
|
-
const corpus = text.join(' ');
|
|
115
|
-
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
116
|
-
pattern.test(corpus) ? [label] : [],
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function similarity(left: string, right: string): number {
|
|
121
|
-
const leftTokens = left.split(/\s+/);
|
|
122
|
-
const rightCounts = new Map<string, number>();
|
|
123
|
-
right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
|
|
124
|
-
const matches = leftTokens.reduce((total, token) => {
|
|
125
|
-
const count = rightCounts.get(token) ?? 0;
|
|
126
|
-
if (count > 0) rightCounts.set(token, count - 1);
|
|
127
|
-
return total + (count > 0 ? 1 : 0);
|
|
128
|
-
}, 0);
|
|
129
|
-
return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
133
|
-
return spanish
|
|
134
|
-
.filter((fragment) => fragment.length >= 80)
|
|
135
|
-
.filter((fragment) => translated.some((candidate) =>
|
|
136
|
-
candidate.length >= 80 &&
|
|
137
|
-
Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
|
|
138
|
-
similarity(fragment, candidate) >= COPY_THRESHOLD,
|
|
139
|
-
))
|
|
140
|
-
.sort((a, b) => b.length - a.length)
|
|
141
|
-
.slice(0, 3);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
describe('Locales must not contain copied Spanish content', () => {
|
|
145
|
-
for (const entry of ALL_ENTRIES) {
|
|
146
|
-
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
147
|
-
const spanish = await loadText(entry.i18n.es);
|
|
148
|
-
const failures: string[] = [];
|
|
149
|
-
|
|
150
|
-
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
151
|
-
if (locale === 'es') continue;
|
|
152
|
-
|
|
153
|
-
const translated = await loadText(loader);
|
|
154
|
-
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
155
|
-
const markerHits = findSpanishMarkers(translated);
|
|
156
|
-
|
|
157
|
-
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
158
|
-
const details = [
|
|
159
|
-
copiedFragments.length > 0
|
|
160
|
-
? `copied fragments: ${copiedFragments
|
|
161
|
-
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
162
|
-
.join(', ')}`
|
|
163
|
-
: '',
|
|
164
|
-
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
165
|
-
]
|
|
166
|
-
.filter(Boolean)
|
|
167
|
-
.join('; ');
|
|
168
|
-
failures.push(`${locale}: ${details}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
expect(failures, failures.join('\n')).toEqual([]);
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_ENTRIES } from '../entries';
|
|
3
|
+
|
|
4
|
+
const STRUCTURAL_KEYS = new Set([
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'url',
|
|
8
|
+
'icon',
|
|
9
|
+
'image',
|
|
10
|
+
'imageUrl',
|
|
11
|
+
'keywords',
|
|
12
|
+
'category',
|
|
13
|
+
'tags',
|
|
14
|
+
'toolId',
|
|
15
|
+
'type',
|
|
16
|
+
'locale',
|
|
17
|
+
'language',
|
|
18
|
+
'direction',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const TRANSLATABLE_KEYS = [
|
|
22
|
+
'title',
|
|
23
|
+
'description',
|
|
24
|
+
'faqTitle',
|
|
25
|
+
'faq',
|
|
26
|
+
'howTo',
|
|
27
|
+
'seo',
|
|
28
|
+
'schemas',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const COPY_THRESHOLD = 0.9;
|
|
32
|
+
|
|
33
|
+
const SPANISH_MARKERS = [
|
|
34
|
+
['sangre', /\bsangre\b/gi],
|
|
35
|
+
['molino', /\bmolino\b/gi],
|
|
36
|
+
['grano', /\bgrano\b/gi],
|
|
37
|
+
['paladar', /\bpaladar\b/gi],
|
|
38
|
+
['cuchillas', /\bcuchillas\b/gi],
|
|
39
|
+
['trozos', /\btrozos\b/gi],
|
|
40
|
+
['frescura', /\bfrescura\b/gi],
|
|
41
|
+
['ingresa', /\bingresa\b/gi],
|
|
42
|
+
['selecciona', /\bselecciona\b/gi],
|
|
43
|
+
['herramienta', /\bherramienta\b/gi],
|
|
44
|
+
['según', /\bsegún\b/gi],
|
|
45
|
+
['después', /\bdespués\b/gi],
|
|
46
|
+
['puedes', /\bpuedes\b/gi],
|
|
47
|
+
['debes', /\bdebes\b/gi],
|
|
48
|
+
['tus', /\btus\b/gi],
|
|
49
|
+
['a la vez', /\ba la vez\b/gi],
|
|
50
|
+
['los datos', /\blos datos\b/gi],
|
|
51
|
+
['las opciones', /\blas opciones\b/gi],
|
|
52
|
+
['el resultado', /\bel resultado\b/gi],
|
|
53
|
+
['método de extracción', /\bmétodo de extracción\b/gi],
|
|
54
|
+
['uniformidad del molino', /\buniformidad del molino\b/gi],
|
|
55
|
+
['café recién tostado', /\bcafé recién tostado\b/gi],
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
type UnknownRecord = Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
function normalize(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/<[^>]*>/g, ' ')
|
|
63
|
+
.replace(/ /gi, ' ')
|
|
64
|
+
.replace(/\s+/g, ' ')
|
|
65
|
+
.trim()
|
|
66
|
+
.toLocaleLowerCase('es');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isTechnicalInvariant(text: string): boolean {
|
|
70
|
+
const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
|
|
71
|
+
return [
|
|
72
|
+
'data:image/svg+xml;base64',
|
|
73
|
+
'background-image: url',
|
|
74
|
+
'.layout-playground {',
|
|
75
|
+
'const samplerate =',
|
|
76
|
+
].some((pattern) => text.includes(pattern)) ||
|
|
77
|
+
(text.includes('presets') && text.includes('hz')) ||
|
|
78
|
+
(text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
|
|
79
|
+
(text.includes('led') && ledUnitMatches.length >= 3);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectString(value: string, output: string[]): void {
|
|
83
|
+
const text = normalize(value);
|
|
84
|
+
if (!isTechnicalInvariant(text) && text.length >= 20) output.push(text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectObject(value: UnknownRecord, output: string[]): void {
|
|
88
|
+
Object.entries(value).forEach(([childKey, childValue]) => {
|
|
89
|
+
collectText(childValue, output, childKey);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectText(value: unknown, output: string[], key?: string): void {
|
|
94
|
+
if (key && STRUCTURAL_KEYS.has(key)) return;
|
|
95
|
+
if (typeof value === 'string') return collectString(value, output);
|
|
96
|
+
if (Array.isArray(value)) return value.forEach((item) => collectText(item, output));
|
|
97
|
+
if (value && typeof value === 'object') collectObject(value as UnknownRecord, output);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function loadText(loader: unknown): Promise<string[]> {
|
|
101
|
+
if (typeof loader !== 'function') return [];
|
|
102
|
+
const module = await (loader as () => Promise<unknown>)();
|
|
103
|
+
const output: string[] = [];
|
|
104
|
+
if (module && typeof module === 'object') {
|
|
105
|
+
const record = module as UnknownRecord;
|
|
106
|
+
for (const key of TRANSLATABLE_KEYS) {
|
|
107
|
+
collectText(record[key], output, key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findSpanishMarkers(text: string[]): string[] {
|
|
114
|
+
const corpus = text.join(' ');
|
|
115
|
+
return SPANISH_MARKERS.flatMap(([label, pattern]) =>
|
|
116
|
+
pattern.test(corpus) ? [label] : [],
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function similarity(left: string, right: string): number {
|
|
121
|
+
const leftTokens = left.split(/\s+/);
|
|
122
|
+
const rightCounts = new Map<string, number>();
|
|
123
|
+
right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
|
|
124
|
+
const matches = leftTokens.reduce((total, token) => {
|
|
125
|
+
const count = rightCounts.get(token) ?? 0;
|
|
126
|
+
if (count > 0) rightCounts.set(token, count - 1);
|
|
127
|
+
return total + (count > 0 ? 1 : 0);
|
|
128
|
+
}, 0);
|
|
129
|
+
return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
133
|
+
return spanish
|
|
134
|
+
.filter((fragment) => fragment.length >= 80)
|
|
135
|
+
.filter((fragment) => translated.some((candidate) =>
|
|
136
|
+
candidate.length >= 80 &&
|
|
137
|
+
Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
|
|
138
|
+
similarity(fragment, candidate) >= COPY_THRESHOLD,
|
|
139
|
+
))
|
|
140
|
+
.sort((a, b) => b.length - a.length)
|
|
141
|
+
.slice(0, 3);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe('Locales must not contain copied Spanish content', () => {
|
|
145
|
+
for (const entry of ALL_ENTRIES) {
|
|
146
|
+
it(`${entry.id} has no untranslated Spanish blocks`, async () => {
|
|
147
|
+
const spanish = await loadText(entry.i18n.es);
|
|
148
|
+
const failures: string[] = [];
|
|
149
|
+
|
|
150
|
+
for (const [locale, loader] of Object.entries(entry.i18n)) {
|
|
151
|
+
if (locale === 'es') continue;
|
|
152
|
+
|
|
153
|
+
const translated = await loadText(loader);
|
|
154
|
+
const copiedFragments = findCopiedFragments(spanish, translated);
|
|
155
|
+
const markerHits = findSpanishMarkers(translated);
|
|
156
|
+
|
|
157
|
+
if (copiedFragments.length > 0 || markerHits.length >= 2) {
|
|
158
|
+
const details = [
|
|
159
|
+
copiedFragments.length > 0
|
|
160
|
+
? `copied fragments: ${copiedFragments
|
|
161
|
+
.map((fragment) => JSON.stringify(fragment.slice(0, 120)))
|
|
162
|
+
.join(', ')}`
|
|
163
|
+
: '',
|
|
164
|
+
markerHits.length >= 2 ? `Spanish markers: ${markerHits.join(', ')}` : '',
|
|
165
|
+
]
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.join('; ');
|
|
168
|
+
failures.push(`${locale}: ${details}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
expect(failures, failures.join('\n')).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
import { socialCategory } from '../data';
|
|
4
|
-
|
|
5
|
-
describe('Tool Validation Suite', () => {
|
|
6
|
-
describe('Library Registration', () => {
|
|
7
|
-
it('should have 7 tools in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(7);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('socialCategory should be defined', () => {
|
|
12
|
-
expect(socialCategory).toBeDefined();
|
|
13
|
-
expect(socialCategory.i18n).toBeDefined();
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import { socialCategory } from '../data';
|
|
4
|
+
|
|
5
|
+
describe('Tool Validation Suite', () => {
|
|
6
|
+
describe('Library Registration', () => {
|
|
7
|
+
it('should have 7 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('socialCategory should be defined', () => {
|
|
12
|
+
expect(socialCategory).toBeDefined();
|
|
13
|
+
expect(socialCategory.i18n).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { redditFormatter } from './index';
|
|
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 redditFormatter.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { redditFormatter } from './index';
|
|
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 redditFormatter.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { safeZones } from './index';
|
|
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 safeZones.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { safeZones } from './index';
|
|
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 safeZones.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { socialImageResizer } from './index';
|
|
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 socialImageResizer.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { socialImageResizer } from './index';
|
|
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 socialImageResizer.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { socialValueCalculator } from './index';
|
|
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 socialValueCalculator.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { socialValueCalculator } from './index';
|
|
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 socialValueCalculator.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { tinderPhotoOptimizer } from './index';
|
|
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 tinderPhotoOptimizer.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { tinderPhotoOptimizer } from './index';
|
|
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 tinderPhotoOptimizer.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { youtubeThumbnail } from './index';
|
|
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 youtubeThumbnail.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { youtubeThumbnail } from './index';
|
|
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 youtubeThumbnail.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
-
import { youtubeThumbnailPreviewer } from './index';
|
|
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 youtubeThumbnailPreviewer.i18n[locale]?.();
|
|
12
|
-
if (!content) return null;
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { youtubeThumbnailPreviewer } from './index';
|
|
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 youtubeThumbnailPreviewer.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
package/src/types.ts
CHANGED