@jjlmoya/utils-social 1.16.0 → 1.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-social",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
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",
34
34
  "minor": "npm version minor",
@@ -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
+ ).toBe(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 15 locales should have identical SEO section counts and types as English', 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,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const COPY_THRESHOLD = 0.9;
5
+
6
+ const STRUCTURAL_KEYS = new Set([
7
+ '@context',
8
+ '@type',
9
+ 'applicationCategory',
10
+ 'columns',
11
+ 'highlight',
12
+ 'icon',
13
+ 'level',
14
+ 'operatingSystem',
15
+ 'position',
16
+ 'positive',
17
+ 'price',
18
+ 'priceCurrency',
19
+ 'slug',
20
+ 'trend',
21
+ 'type',
22
+ 'url',
23
+ 'value',
24
+ 'variant',
25
+ ]);
26
+
27
+ function normalizeText(value: string): string {
28
+ return value
29
+ .replace(/<[^>]*>/g, ' ')
30
+ .replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
31
+ .replace(/[\u2018\u2019]/g, "'")
32
+ .replace(/[\u201c\u201d]/g, '"')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .toLocaleLowerCase();
36
+ }
37
+
38
+ function collectText(value: unknown, path: string, parts: string[]): void {
39
+ if (typeof value === 'string') {
40
+ const normalized = normalizeText(value);
41
+ if (normalized.length >= 2) parts.push(normalized);
42
+ return;
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
47
+ return;
48
+ }
49
+
50
+ if (!value || typeof value !== 'object') return;
51
+
52
+ Object.entries(value).forEach(([key, child]) => {
53
+ if (STRUCTURAL_KEYS.has(key)) return;
54
+ collectText(child, `${path}.${key}`, parts);
55
+ });
56
+ }
57
+
58
+ function localeCorpus(content: unknown): string {
59
+ if (!content || typeof content !== 'object') return '';
60
+
61
+ const record = content as Record<string, unknown>;
62
+ const parts: string[] = [];
63
+ collectText(record.title, 'title', parts);
64
+ collectText(record.description, 'description', parts);
65
+ collectText(record.faqTitle, 'faqTitle', parts);
66
+ collectText(record.faq, 'faq', parts);
67
+ collectText(record.seo, 'seo', parts);
68
+ collectText(record.schemas, 'schemas', parts);
69
+ return parts.join(' ');
70
+ }
71
+
72
+ function tokenCounts(text: string): Map<string, number> {
73
+ const counts = new Map<string, number>();
74
+ for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
75
+ counts.set(token, (counts.get(token) ?? 0) + 1);
76
+ }
77
+ return counts;
78
+ }
79
+
80
+ function copySimilarity(left: string, right: string): number {
81
+ const leftCounts = tokenCounts(left);
82
+ const rightCounts = tokenCounts(right);
83
+ const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
84
+ const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
85
+ if (leftTotal === 0 || rightTotal === 0) return 0;
86
+
87
+ let shared = 0;
88
+ for (const [token, count] of leftCounts) {
89
+ shared += Math.min(count, rightCounts.get(token) ?? 0);
90
+ }
91
+ return (2 * shared) / (leftTotal + rightTotal);
92
+ }
93
+
94
+ describe('Locales must not copy another locale wholesale', () => {
95
+ ALL_ENTRIES.forEach((entry) => {
96
+ it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
97
+ const corpora = new Map<string, string>();
98
+
99
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
100
+ if (!loader) continue;
101
+ corpora.set(locale, localeCorpus(await loader()));
102
+ }
103
+
104
+ const locales = [...corpora.keys()];
105
+ const violations: string[] = [];
106
+
107
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
+ const left = locales[leftIndex];
110
+ const right = locales[rightIndex];
111
+ const leftCorpus = corpora.get(left) ?? '';
112
+ const rightCorpus = corpora.get(right) ?? '';
113
+ const similarity = copySimilarity(leftCorpus, rightCorpus);
114
+
115
+ if (similarity >= COPY_THRESHOLD) {
116
+ violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
117
+ }
118
+ }
119
+ }
120
+
121
+ expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
122
+ });
123
+ });
124
+ });
125
+
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Hinweis für Creator',
258
258
  html: '<p>Dieses Tool liefert Schätzwerte. Passen Sie den Preis immer an die Komplexität des angeforderten Inhalts an.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Der berechnete Wert ist eine Orientierung, kein garantierter Verkaufspreis. Vergleiche mehrere Szenarien und ergänze aktuelle Zielgruppe, Engagement und Einnahmen, bevor du eine Geschäftsentscheidung triffst.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Audit-Parameter',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Catatan untuk Kreator',
258
258
  html: '<p>Alat ini adalah perkiraan. Selalu sesuaikan harga berdasarkan kompleksitas konten yang diminta oleh merek.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Nilai akun adalah perkiraan, bukan harga jual yang dijamin. Bandingkan beberapa skenario dan pertimbangkan audiens, engagement, serta pendapatan terbaru sebelum mengambil keputusan.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Parameter Audit',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Nota per i Creatori',
258
258
  html: '<p>Questo strumento è una stima. Regola sempre il prezzo in base alla complessità dei contenuti richiesti dal brand.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Il valore dell account è una stima, non un prezzo di vendita garantito. Confronta più scenari e considera pubblico, interazioni e ricavi aggiornati prima di decidere.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Parametri Audit',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'クリエイターへの注意',
258
258
  html: '<p>このツールは推定値です。常にブランドから依頼されたコンテンツの複雑さに合わせて価格を調整してください。</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'アカウント価値は売却価格を保証するものではなく、あくまで目安です。決定する前に複数のシナリオを比べ、フォロワー層、反応率、最新の収益も確認してください。' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: '監査パラメータ',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: '크리에이터를 위한 참고 사항',
258
258
  html: '<p>이 도구는 추정치입니다. 브랜드가 요청하는 콘텐츠의 복잡도에 따라 가격을 항상 조정하십시오.</p>',
259
259
  },
260
+ { type: 'paragraph', html: '계산된 계정 가치는 보장된 판매 가격이 아니라 참고용 추정치입니다. 결정을 내리기 전에 여러 시나리오를 비교하고 잠재고객, 참여도, 최신 수익을 함께 확인하세요.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: '검토 파라미터',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Opmerking voor Creators',
258
258
  html: '<p>Deze tool is een schatting. Pas de prijs altijd aan op basis van de complexiteit van de door het merk gevraagde content.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'De berekende accountwaarde is een indicatie en geen gegarandeerde verkoopprijs. Vergelijk scenario\'s en controleer doelgroep, betrokkenheid en recente inkomsten voordat je beslist.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Audit Parameters',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Notka dla twórców',
258
258
  html: '<p>To narzędzie podaje szacunki. Zawsze dostosowuj cenę do poziomu trudności materiału, o który prosi marka.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Wyliczona wartość konta jest wskazówką, a nie gwarantowaną ceną sprzedaży. Przed decyzją porównaj scenariusze oraz sprawdź odbiorców, zaangażowanie i aktualne przychody.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Parametry audytu',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Nota para Criadores',
258
258
  html: '<p>Esta ferramenta é uma estimativa. Ajuste sempre o preço com base na complexidade do conteúdo solicitado pela marca.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'O valor calculado da conta é uma orientação, não um preço de venda garantido. Compare vários cenários e confirme público, envolvimento e receitas recentes antes de decidir.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Parâmetros de Auditoria',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Примечание для авторов',
258
258
  html: '<p>Этот инструмент дает оценку. Всегда корректируйте цену в зависимости от сложности контента, запрашиваемого брендом.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Расчётная стоимость аккаунта является ориентиром, а не гарантированной ценой продажи. Перед решением сравните сценарии и проверьте аудиторию, вовлечённость и последние доходы.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Параметры аудита',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Notering för skapare',
258
258
  html: '<p>Detta verktyg är en uppskattning. Justera alltid priset baserat på komplexiteten i det innehåll som varumärket efterfrågat.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Det beräknade kontovärdet är en uppskattning, inte ett garanterat försäljningspris. Jämför scenarier och kontrollera målgrupp, engagemang och aktuella intäkter innan du beslutar.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Granskningsparametrar',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: 'Üreticiler için Not',
258
258
  html: '<p>Bu araç bir tahmindir. Fiyatı her zaman marka tarafından talep edilen içeriğin karmaşıklığına göre ayarlayın.</p>',
259
259
  },
260
+ { type: 'paragraph', html: 'Hesaplanan hesap değeri garanti edilmiş satış fiyatı değil, bir tahmindir. Karar vermeden önce senaryoları karşılaştırın ve kitleyi, etkileşimi ve güncel geliri değerlendirin.' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: 'Denetim Parametreleri',
@@ -257,6 +257,7 @@ export const content: ToolLocaleContent<SocialValueCalculatorUI> = {
257
257
  title: '创作者注',
258
258
  html: '<p>此工具仅为估算值。请务必根据品牌方要求的内容复杂程度调整价格。</p>',
259
259
  },
260
+ { type: 'paragraph', html: '计算出的账号价值只是参考估算,并不保证实际售价。做决定前请比较不同情景,并结合受众、互动率和最新收入进行判断。' },
260
261
  ],
261
262
  ui: {
262
263
  sectionTag: '审计参数',