@jjlmoya/utils-streaming 1.22.1 → 1.23.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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/de.ts +0 -8
  3. package/src/category/i18n/en.ts +1 -9
  4. package/src/category/i18n/es.ts +1 -9
  5. package/src/category/i18n/fr.ts +0 -8
  6. package/src/category/i18n/id.ts +0 -8
  7. package/src/category/i18n/it.ts +0 -8
  8. package/src/category/i18n/ja.ts +0 -8
  9. package/src/category/i18n/ko.ts +0 -8
  10. package/src/category/i18n/nl.ts +0 -8
  11. package/src/category/i18n/pl.ts +0 -8
  12. package/src/category/i18n/pt.ts +1 -9
  13. package/src/category/i18n/ru.ts +0 -8
  14. package/src/category/i18n/sv.ts +0 -8
  15. package/src/category/i18n/tr.ts +0 -8
  16. package/src/category/i18n/zh.ts +0 -8
  17. package/src/components/PreviewNavSidebar.astro +1 -1
  18. package/src/components/PreviewToolbar.astro +1 -1
  19. package/src/data.ts +1 -1
  20. package/src/env.d.ts +1 -1
  21. package/src/pages/[locale]/[slug].astro +3 -2
  22. package/src/pages/index.astro +1 -1
  23. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  24. package/src/tests/category_seo_quality.test.ts +76 -0
  25. package/src/tests/diacritics_density.test.ts +1 -1
  26. package/src/tests/faq_count.test.ts +19 -19
  27. package/src/tests/inverted_punctuation.test.ts +1 -1
  28. package/src/tests/mocks/astro_mock.js +1 -1
  29. package/src/tests/no_h1_in_components.test.ts +1 -1
  30. package/src/tests/script_density.test.ts +1 -1
  31. package/src/tests/seo_length.test.ts +23 -23
  32. package/src/tests/seo_wellformed_export.test.ts +65 -0
  33. package/src/tests/spanish_leakage.test.ts +175 -175
  34. package/src/tests/translation_copy.test.ts +2 -3
  35. package/src/tool/sorteo/seo.astro +15 -15
  36. package/src/tool/sorteo/ui-manager.ts +1 -1
  37. package/src/tool/tebasCheck/seo.astro +15 -15
  38. package/src/tool/tebasCheck/ui-manager.ts +3 -1
  39. package/src/types.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-streaming",
3
- "version": "1.22.1",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Gamification ist der Schlüssel zur Zuschauerbindung. Unser <strong>Gewinnspiel- und Wettbewerbs-Tool</strong> ermöglicht die Auswahl zufälliger Gewinner auf völlig transparente Weise. Durch die lokale Verarbeitung auf Ihrem Endgerät stellen Sie sicher, dass keine externe Manipulation möglich ist, was das Vertrauen der Follower bei Live-Auslosungen stärkt.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Netzwerkaudit und Neutralität: Tebas Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'In der digitalen Welt von 2026 können gerichtliche Sperren und ISP-Einschränkungen die Stabilität Ihres Streams oder den Zugriff Ihrer Community auf Ihre Ressourcen beeinträchtigen. <strong>Tebas Check</strong> ist ein technisches Diagnosetool, das erkennt, ob Ihr Anbieter Zensur oder präventive Sperren auf Infrastrukturen wie Cloudflare oder anderen Content-Anbietern anwendet, sodass Sie Ihre Netzneutralität überprüfen können.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Gamification is key to audience retention. Our <strong>giveaway and contest tool</strong> allows for choosing random winners in a fully transparent way. By processing locally on your terminal, you ensure no external manipulation, fostering follower trust during live draws.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Network Audit and Neutrality: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'In the digital environment of 2026, judicial blocks and ISP restrictions can affect your stream stability or your community\'s access to your resources. <strong>Tebas-Check</strong> is a technical diagnostic tool that detects if your operator is applying censorship or preventive blocks on infrastructures like Cloudflare or other content providers, allowing you to audit your network neutrality.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -70,7 +62,7 @@ export const content: CategoryLocaleContent = {
70
62
  },
71
63
  {
72
64
  type: 'paragraph',
73
- html: 'This is especially important when distributing real or exclusive prizes. Perceived injustice in a giveaway can destroy follower relationships for months. That\'s why using a transparent system that processes locally on your computer is the best guarantee of impartiality. Your community sees the code running, trusts the result, and keeps coming back.',
65
+ html: 'This is especially important when distributing real or exclusive prizes. Perceived injustice in a giveaway can destroy follower relationships for months. That\'s why using a transparent system that processes locally on your computer can support a more impartial draw. Your community sees the code running, understands the process, and can come back with confidence.',
74
66
  },
75
67
  {
76
68
  type: 'title',
@@ -26,11 +26,7 @@ export const content: CategoryLocaleContent = {
26
26
  },
27
27
  {
28
28
  type: 'paragraph',
29
- html: 'La gamificación es clave para retener a la audiencia. Nuestra herramienta de <strong>sorteo y concursos</strong> permite elegir ganadores aleatorios de forma totalmente transparente. Al procesarse localmente en tu terminal, garantizas que no hay manipulación externa, fomentando la confianza de tus seguidores durante los sorteos en directo.',
30
- },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
29
+ html: 'La gamificación ayuda a retener a la audiencia. Nuestra herramienta de <strong>sorteo y concursos</strong> permite elegir ganadores aleatorios de forma transparente. Al procesarse localmente en tu terminal, facilita revisar el proceso y reduce el riesgo de manipulación externa durante los sorteos en directo.',
34
30
  },
35
31
  {
36
32
  type: 'title',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'En el entorno digital de 2026, los bloqueos judiciales y las restricciones de las operadoras (ISP) pueden afectar la estabilidad de tus directos o el acceso de tu comunidad a tus recursos. <strong>Tebas-Check</strong> es una herramienta de diagnóstico técnico que detecta si tu operadora está aplicando censura o bloqueos preventivos sobre infraestructuras como las de Cloudflare u otros proveedores de contenido, permitiéndote auditar la neutralidad de tu red.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'La gamification est la clé de la rétention d\'audience. Notre outil de <strong>tirage au sort et concours</strong> permet de choisir des gagnants aléatoires de manière totalement transparente. En étant traité localement sur votre terminal, vous garantissez l\'absence de manipulation externe, renforçant la confiance de vos abonnés pendant les directs.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'tirage-au-sort',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Audit Réseau et Neutralité: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'Dans l\'environnement numérique de 2026, les blocages judiciaires et les restrictions des opérateurs (FAI) peuvent affecter la stabilité de votre flux ou l\'accès de votre communauté à vos ressources. <strong>Tebas-Check</strong> est un outil de diagnostic technique qui détecte si votre opérateur applique une censure ou des blocages préventifs sur des infrastructures comme Cloudflare ou d\'autres fournisseurs de contenu, vous permettant d\'auditer la neutralité de votre réseau.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Gamifikasi adalah kunci untuk retensi audiens. <strong>Alat giveaway dan kontes</strong> kami memungkinkan untuk memilih pemenang acak dengan cara yang sepenuhnya transparan. Dengan memproses secara lokal di terminal Anda, Anda memastikan tidak ada manipulasi eksternal, menumbuhkan kepercayaan pengikut selama pengundian langsung.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Audit Jaringan dan Netralitas: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'Di lingkungan digital tahun 2026, pemblokiran yudisial dan pembatasan ISP dapat memengaruhi stabilitas aliran Anda atau akses komunitas Anda ke sumber daya Anda. <strong>Tebas-Check</strong> adalah alat diagnosis teknis yang mendeteksi apakah operator Anda menerapkan sensor atau pemblokiran preventif pada infrastruktur seperti Cloudflare atau penyedia konten lainnya, memungkinkan Anda untuk mengaudit netralitas jaringan Anda.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'La gamification è la chiave per la fidelizzazione del pubblico. Il nostro <strong>strumento per giveaway e contest</strong> consente di scegliere vincitori casuali in modo completamente trasparente. Eseguendo l\'elaborazione localmente sul tuo terminale, garantisci l\'assenza di manipolazioni esterne, promuovendo la fiducia dei follower durante i sorteggi dal vivo.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Audit di Rete e Neutralità: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'Nell\'ambiente digitale del 2026, i blocchi giudiziari e le restrizioni degli ISP possono influire sulla stabilità del tuo stream o sull\'accesso della tua community alle tue risorse. <strong>Tebas-Check</strong> è uno strumento di diagnosi tecnica che rileva se il tuo operatore sta applicando censura o blocchi preventivi su infrastrutture come Cloudflare o altri fornitori di contenuti, permettendoti di verificare la neutralità della tua rete.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'ゲーミフィケーションは視聴者維持の鍵です。当社の<strong>抽選・コンテストツール</strong>を使用すると、完全に透明な方法でランダムに当選者を選ぶことができます。お使いの末端でローカルに処理を行うことで、外部からの操作を排除し、ライブ抽選中のフォロワーの信頼を育みます。',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'ネットワーク監査と中立性:Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: '2026年のデジタル環境では、司法的ブロックやISPの制限が配信の安定性やコミュニティのリソースへのアクセスに影響を与える可能性があります。<strong>Tebas-Check</strong>は、プロバイダーがCloudflareなどのインフラや他のコンテンツプロバイダーに対して検閲や予防的ブロックを適用しているかどうかを検出する技術的な診断ツールであり、ネットワークの中立性を監査することができます。',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: '게이미피케이션은 청중 유지의 핵심입니다. 당사의 <strong>추첨 및 컨테스트 도구</strong>를 사용하면 완전히 투명한 방식으로 무작위 당첨자를 선택할 수 있습니다. 사용자의 단말기에서 로컬로 처리를 수행함으로써 외부 조작이 없음을 보장하고 라이브 추첨 중 팔로워의 신뢰를 구축합니다.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: '네트워크 감사 및 중립성: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: '2026년의 디지털 환경에서 사법적 차단 및 ISP 제한은 스트림 안정성이나 커뮤니티의 리소스 액세스에 영향을 미칠 수 있습니다. <strong>Tebas-Check</strong>는 통신사가 Cloudflare와 같은 인프라나 다른 콘텐츠 제공업체에 대해 검열 또는 예방적 차단을 적용하고 있는지 감지하는 기술 진단 도구로, 네트워크 중립성을 감사할 수 있게 해줍니다.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Gamification is de sleutel tot kijkersretentie. Onze <strong>giveaway- en wedstrijdtool</strong> maakt het mogelijk om op een volledig transparante manier willekeurige winnaars te kiezen. Door lokaal op je terminal te verwerken, garandeer je dat er geen externe manipulatie plaatsvindt, wat het vertrouwen van volgers tijdens live lotingen bevordert.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Netwerkaudit en Neutraliteit: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'In de digitale omgeving van 2026 kunnen gerechtelijke blokkades en ISP-beperkingen de stabiliteit van je stream of de toegang van je community tot je middelen beïnvloeden. <strong>Tebas-Check</strong> is een technisch diagnostisch hulpmiddel dat detecteert of je provider censuur of preventieve blokkades toepast op infrastructuren zoals Cloudflare of andere contentproviders, zodat je je netwerkneutraliteit kunt controleren.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Grywalizacja jest kluczem do utrzymania uwagi widzów. Nasze <strong>narzędzie do losowań i konkursów</strong> pozwala na wybór losowych zwycięzców w całkowicie przejrzysty sposób. Przetwarzanie odbywa się lokalnie na Twoim urządzeniu, co gwarantuje brak zewnętrznych manipulacji i buduje zaufanie obserwujących podczas losowań na żywo.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Audyt sieci i neutralność: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'W środowisku cyfrowym roku 2026 blokady sądowe i ograniczenia nakładane przez dostawców Internetu mogą wpływać na stabilność Twojej transmisji lub dostęp społeczności do Twoich zasobów. <strong>Tebas-Check</strong> to techniczne narzędzie diagnostyczne, które wykrywa, czy Twój operator stosuje cenzurę lub blokady prewencyjne na infrastrukturach takich jak Cloudflare lub u innych dostawców treści, umożliwiając audyt neutralności Twojej sieci.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -4,7 +4,7 @@ export const content: CategoryLocaleContent = {
4
4
  slug: 'streaming',
5
5
  title: 'Ferramentas para Streaming e Criadores de Conteúdo',
6
6
  description:
7
- 'Melhore a interação e a segurança das suas transmissões ao vivo com ferramentas online gratuitas. Seletores de sorteios aleatórios e detetores de bloqueios de rede ISP (Tebas-Check).',
7
+ 'Ferramentas online gratuitas para transmissões ao vivo: sorteios aleatórios e verificação de bloqueios de rede ISP para streamers.',
8
8
  seo: [
9
9
  {
10
10
  type: 'title',
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'A gamificação é fundamental para a retenção de audiência. A nossa <strong>ferramenta de sorteios e concursos</strong> permite escolher vencedores aleatórios de forma totalmente transparente. Ao processar localmente no seu terminal, garante que não há manipulação externa, promovendo a confiança dos seguidores durante os sorteios em direto.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Auditoria de Rede e Neutralidade: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'No ambiente digital de 2026, os bloqueios judiciais e as restrições dos ISPs podem afetar a estabilidade da sua transmissão ou o acesso da sua comunidade aos seus recursos. O <strong>Tebas-Check</strong> é uma ferramenta de diagnóstico técnico que deteta se o seu operador está a aplicar censura ou bloqueios preventivos em infraestruturas como a Cloudflare ou outros fornecedores de conteúdo, permitindo-lhe auditar a neutralidade da sua rede.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Геймификация - ключ к удержанию аудитории. Наш <strong>инструмент для розыгрышей и конкурсов</strong> позволяет выбирать случайных победителей полностью прозрачным способом. Благодаря локальной обработке на вашем терминале вы гарантируете отсутствие внешних манипуляций, что способствует доверию подписчиков во время живых розыгрышей.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Сетевой аудит и нейтральность: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'В цифровой среде 2026 года судебные блокировки и ограничения провайдеров могут повлиять на стабильность вашего стрима или доступ вашего сообщества к вашим ресурсам. <strong>Tebas-Check</strong> - это инструмент технической диагностики, который определяет, применяет ли ваш оператор цензуру или превентивные блокировки на инфраструктурах, таких как Cloudflare или других контент-провайдерах, позволяя вам проводить аудит нейтральности вашей сети.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Gamification är nyckeln till att behålla publiken. Vårt <strong>utlottnings- och tävlingsverktyg</strong> gör det möjligt att välja slumpmässiga vinnare på ett helt transparent sätt. Genom att bearbeta lokalt på din terminal säkerställer du att ingen extern manipulation sker, vilket främjar följarnas förtroende under live-dragningar.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Nätverksrevision och neutralitet: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: 'I den digitala miljön 2026 kan rättsliga blockeringar och ISP-restriktioner påverka din streams stabilitet eller din communitys tillgång till dina resurser. <strong>Tebas-Check</strong> är ett tekniskt diagnostikverktyg som upptäcker om din operatör tillämpar censur eller förebyggande blockeringar av infrastruktur som Cloudflare eller andra innehållsleverantörer, vilket gör att du kan granska din nätverksneutralitet.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: 'Oyunlaştırma, izleyici tutmanın anahtarıdır. <strong>Çekiliş ve yarışma aracımız</strong>, tamamen şeffaf bir şekilde rastgele kazananlar seçilmesine olanak tanır. İşlemleri cihazınızda yerel olarak gerçekleştirerek dışarıdan müdahale olmamasını sağlar, canlı çekilişler sırasında takipçilerinizin güvenini kazanırsınız.',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: 'Ağ Denetimi ve Tarafsızlık: Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: '2026\'nın dijital ortamında, adli engellemeler ve ISP kısıtlamaları yayınınızın istikrarını veya topluluğunuzun kaynaklarınıza erişimini etkileyebilir. <strong>Tebas-Check</strong>, operatörünüzün Cloudflare veya diğer içerik sağlayıcılar gibi altyapılar üzerinde sansür veya önleyici engellemeler uygulayıp uygulamadığını tespit eden, ağ tarafsızlığınızı denetlemenize olanak tanıyan teknik bir teşhis aracıdır.',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -28,10 +28,6 @@ export const content: CategoryLocaleContent = {
28
28
  type: 'paragraph',
29
29
  html: '游戏化是留住观众的关键。我们的<strong>抽奖和竞赛工具</strong>允许以完全透明的方式选择随机获胜者。通过在您的终端本地进行处理,您可以确保没有外部干预,从而在现场抽奖期间培养追随者的信任。',
30
30
  },
31
- {
32
- type: 'tool',
33
- tool: 'sorteo',
34
- },
35
31
  {
36
32
  type: 'title',
37
33
  text: '网络审计与中立性:Tebas-Check',
@@ -41,10 +37,6 @@ export const content: CategoryLocaleContent = {
41
37
  type: 'paragraph',
42
38
  html: '在 2026 年的数字环境中,司法封锁和 ISP 限制可能会影响直播的稳定性或社区对资源的访问。<strong>Tebas-Check</strong> 是一款技术诊断工具,可检测您的运营商是否对 Cloudflare 等基础设施或其他内容提供商实施审查或预防性封锁,让您可以审计自己的网络中立性。',
43
39
  },
44
- {
45
- type: 'tool',
46
- tool: 'tebas-check',
47
- },
48
40
  {
49
41
  type: 'list',
50
42
  items: [
@@ -113,4 +113,4 @@ const { categoryTitle, tools = [] } = Astro.props;
113
113
  white-space: nowrap;
114
114
  }
115
115
  </style>
116
-
116
+
@@ -140,4 +140,4 @@ const otherLocales = Object.entries(localeUrls).filter(([l]) => l !== currentLoc
140
140
  box-shadow: inset -4px -2px 0 #1e293b;
141
141
  }
142
142
  </style>
143
-
143
+
package/src/data.ts CHANGED
@@ -8,4 +8,4 @@ export type {
8
8
  StreamingToolEntry,
9
9
  StreamingCategoryEntry,
10
10
  } from './types';
11
-
11
+
package/src/env.d.ts CHANGED
@@ -2,4 +2,4 @@ declare module '*.astro' {
2
2
  const Component: (_props: Record<string, unknown>) => unknown;
3
3
  export default Component;
4
4
  }
5
-
5
+
@@ -9,6 +9,7 @@ import {
9
9
  SEORenderer,
10
10
  } from "@jjlmoya/utils-shared";
11
11
  import type { KnownLocale, ToolLocaleContent } from "../../types";
12
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
12
13
  import type { UtilitySEOContent } from "@jjlmoya/utils-shared";
13
14
 
14
15
  export async function getStaticPaths() {
@@ -69,7 +70,7 @@ interface NavItem {
69
70
  }
70
71
 
71
72
  interface Props {
72
- Component: unknown;
73
+ Component: AstroComponentFactory;
73
74
  locale: KnownLocale;
74
75
  content: ToolLocaleContent;
75
76
  localeUrls: Partial<Record<KnownLocale, string>>;
@@ -127,7 +128,7 @@ const titleBase = words.slice(1).join(" ") || "";
127
128
  </section>
128
129
 
129
130
  <section class="section-faq">
130
- <FAQSection items={content.faq} inLanguage={locale} />
131
+ <FAQSection items={content.faq} />
131
132
  </section>
132
133
 
133
134
  <section class="section-bibliography">
@@ -1,4 +1,4 @@
1
1
  ---
2
2
  ---
3
3
  <meta http-equiv="refresh" content="0;url=/es" />
4
-
4
+
@@ -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,76 @@
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
+
38
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
39
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
40
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
41
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
42
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
43
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
44
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
45
+
46
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
47
+ return failures;
48
+ }
49
+
50
+ describe('Category SEO quality contract', () => {
51
+ it('has one content file for every configured locale', () => {
52
+ const contents = getContents();
53
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
54
+ });
55
+
56
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
57
+ const contents = getContents();
58
+ const english = contents.get('en');
59
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
60
+
61
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
62
+ const content = contents.get(locale);
63
+ return content ? validateContent(locale, content) : [];
64
+ });
65
+
66
+ const englishShape = english?.seo.map((section) => section.type);
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
70
+ failures.push(`${locale}: SEO section types must match English`);
71
+ }
72
+ }
73
+
74
+ expect(failures, 'category SEO quality failures').toEqual([]);
75
+ }, 30000);
76
+ });
@@ -96,7 +96,7 @@ describe('Diacritics density validation', () => {
96
96
  if (!loader) return;
97
97
 
98
98
  const content = await loader();
99
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
99
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
100
100
  const rule = DIACRITIC_RULES[typedLocale];
101
101
  const letters = letterCount(text);
102
102
  const matches = diacriticCount(text, typedLocale);
@@ -1,19 +1,19 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type * as DATA from '../data';
3
-
4
- const TOOLS: typeof DATA.streamingCategory[] = [];
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.streamingCategory[] = [];
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
+
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
60
60
 
61
61
  const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
62
  const content = await loader();
63
- const strings = translatableStrings(content as Record<string, unknown>);
63
+ const strings = translatableStrings(content as unknown as Record<string, unknown>);
64
64
  const missingQuestions = strings.flatMap((text) =>
65
65
  findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
66
  );
@@ -1,2 +1,2 @@
1
1
  export default function () { return null; }
2
-
2
+
@@ -45,4 +45,4 @@ describe('No H1 in Components', () => {
45
45
  });
46
46
  });
47
47
  });
48
-
48
+
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
73
73
 
74
74
  const content = await loader();
75
75
  const rule = SCRIPT_RULES[typedLocale];
76
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
76
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
77
77
  const letters = letterCount(text);
78
78
  const matches = scriptCount(text, typedLocale);
79
79
  const ratio = scriptRatio(text, typedLocale);
@@ -1,23 +1,23 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
4
- const ENTRIES = [
5
- { id: 'streamingCategory', i18n: DATA.streamingCategory.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
- });
23
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'streamingCategory', i18n: DATA.streamingCategory.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
+ });
23
+
@@ -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(/&nbsp;/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(/&nbsp;/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
+ });
@@ -106,8 +106,8 @@ describe('Locales must not copy another locale wholesale', () => {
106
106
 
107
107
  for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
108
  for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
- const left = locales[leftIndex];
110
- const right = locales[rightIndex];
109
+ const left = locales[leftIndex]!;
110
+ const right = locales[rightIndex]!;
111
111
  const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
112
112
 
113
113
  if (similarity >= COPY_THRESHOLD) {
@@ -121,4 +121,3 @@ describe('Locales must not copy another locale wholesale', () => {
121
121
  });
122
122
  });
123
123
 
124
-
@@ -1,15 +1,15 @@
1
- ---
2
- import { SEORenderer } from '@jjlmoya/utils-shared';
3
- import { SORTEO_TOOL } 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 SORTEO_TOOL.entry.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 { SORTEO_TOOL } 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 SORTEO_TOOL.entry.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -27,7 +27,7 @@ export async function runCountdownSequence(
27
27
  for (let i = 3; i > 0; i--) {
28
28
  els.countdownNumber.textContent = i.toString();
29
29
  els.countdownNumber.classList.remove('animate-ping-slow');
30
- void els.countdownNumber['offset' + 'Width'];
30
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
31
31
  els.countdownNumber.classList.add('animate-ping-slow');
32
32
  beepFn(audioContext, 600 + i * 100, 0.1, 'square');
33
33
  await new Promise((r) => setTimeout(r, 1000));
@@ -1,15 +1,15 @@
1
- ---
2
- import { SEORenderer } from '@jjlmoya/utils-shared';
3
- import { TEBAS_CHECK_TOOL } 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 TEBAS_CHECK_TOOL.entry.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 { TEBAS_CHECK_TOOL } 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 TEBAS_CHECK_TOOL.entry.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -22,7 +22,9 @@ export class TebasUIManager {
22
22
  div.innerHTML = `<span class="tebas-log-time">[${time}]</span> ${msg}`;
23
23
 
24
24
  this.output.appendChild(div);
25
- this.output['scroll' + 'Top'] = this.output['scroll' + 'Height'];
25
+ requestAnimationFrame(() => {
26
+ this.output?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: 'auto' });
27
+ });
26
28
  }
27
29
 
28
30
  clearLog() {
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SEOSection } from '@jjlmoya/utils-shared';
2
2
  import type { WithContext, Thing } from 'schema-dts';
3
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
3
4
 
4
5
  export type { SEOSection };
5
6
 
@@ -63,8 +64,8 @@ export interface StreamingCategoryEntry {
63
64
 
64
65
  export interface ToolDefinition {
65
66
  entry: StreamingToolEntry;
66
- Component: unknown;
67
- SEOComponent: unknown;
68
- BibliographyComponent: unknown;
67
+ Component: () => Promise<{ default: AstroComponentFactory }>;
68
+ SEOComponent: () => Promise<{ default: AstroComponentFactory }>;
69
+ BibliographyComponent: () => Promise<{ default: AstroComponentFactory }>;
69
70
  css?: string;
70
71
  }