From de8d3874421f624036e9b214684ffc0a73d11f74 Mon Sep 17 00:00:00 2001 From: Pan YANG Date: Sun, 2 Aug 2026 19:29:54 +0800 Subject: [PATCH 1/4] feat(catalog): refresh coding product coverage --- cspell.json | 3 + data/vendor-company-stages.json | 15 +-- manifests/clis/goose.json | 132 +++++++++++++++++++ manifests/desktops/goose.json | 132 +++++++++++++++++++ manifests/extensions/rovo-dev.json | 118 ----------------- manifests/vendors/agentic-ai-foundation.json | 68 ++++++++++ manifests/vendors/atlassian.json | 97 -------------- src/lib/generated/clis.ts | 2 + src/lib/generated/desktops.ts | 2 + src/lib/generated/extensions.ts | 2 - src/lib/generated/metadata.ts | 6 +- src/lib/generated/vendors.ts | 6 +- 12 files changed, 351 insertions(+), 232 deletions(-) create mode 100644 manifests/clis/goose.json create mode 100644 manifests/desktops/goose.json delete mode 100644 manifests/extensions/rovo-dev.json create mode 100644 manifests/vendors/agentic-ai-foundation.json delete mode 100644 manifests/vendors/atlassian.json diff --git a/cspell.json b/cspell.json index b1cfa5f7..5a3dd004 100644 --- a/cspell.json +++ b/cspell.json @@ -52,6 +52,7 @@ "продакшену", "confiabilidade", "gözlemlenebilirliğini", + "governança", "modelnya", "observabilitas", "observabilidad", @@ -76,6 +77,7 @@ "ccstatusline", "API'lerle", "acli", + "aaif", "anomalyco", "aracidir", "glab", @@ -142,6 +144,7 @@ "Junie", "Kimi", "Kiro", + "kirodotdev", "Kode", "lmstudio", "multiherramienta", diff --git a/data/vendor-company-stages.json b/data/vendor-company-stages.json index 9f3aa86c..1e92f4da 100644 --- a/data/vendor-company-stages.json +++ b/data/vendor-company-stages.json @@ -1,6 +1,6 @@ { "$schema": "./$schemas/vendor-company-stages.schema.json", - "asOf": "2026-07-31", + "asOf": "2026-08-01", "criteria": { "publicCompanyIncludesListedParent": true, "superUnicornMinimumUsd": 10000000000, @@ -34,14 +34,6 @@ "title": "Alibaba Group investor relations" } }, - { - "vendorId": "atlassian", - "stage": "public-company", - "source": { - "url": "https://investors.atlassian.com/", - "title": "Atlassian investor relations" - } - }, { "vendorId": "aws", "stage": "public-company", @@ -266,6 +258,11 @@ "title": "Together AI Series C announcement" } }, + { + "vendorId": "agentic-ai-foundation", + "stage": "startup", + "source": null + }, { "vendorId": "anomaly", "stage": "startup", diff --git a/manifests/clis/goose.json b/manifests/clis/goose.json new file mode 100644 index 00000000..4b6995ad --- /dev/null +++ b/manifests/clis/goose.json @@ -0,0 +1,132 @@ +{ + "$schema": "../$schemas/cli.schema.json", + "id": "goose", + "name": "Goose CLI", + "familyId": "goose", + "description": "Goose CLI is the terminal surface of Goose, an open source local AI agent for coding, research, automation, and other workflows with multiple model providers.", + "translations": { + "de": { + "description": "Goose CLI ist die Terminaloberfläche von Goose, einem quelloffenen lokalen KI-Agenten für Programmierung, Recherche, Automatisierung und weitere Arbeitsabläufe mit mehreren Modellanbietern." + }, + "es": { + "description": "Goose CLI es la interfaz de terminal de Goose, un agente de IA local y de código abierto para programación, investigación, automatización y otros flujos con varios proveedores de modelos." + }, + "fr": { + "description": "Goose CLI est l’interface terminal de Goose, un agent IA local open source destiné au codage, à la recherche, à l’automatisation et à d’autres flux avec plusieurs fournisseurs de modèles." + }, + "id": { + "description": "Goose CLI adalah antarmuka terminal Goose, agen AI lokal sumber terbuka untuk coding, riset, otomatisasi, dan alur kerja lain dengan berbagai penyedia model." + }, + "ja": { + "description": "Goose CLI は、複数のモデルプロバイダーを利用し、コーディング、調査、自動化などのワークフローに対応するオープンソースのローカル AI エージェント Goose のターミナル版です。" + }, + "ko": { + "description": "Goose CLI는 여러 모델 제공자를 활용해 코딩, 조사, 자동화 등의 워크플로를 수행하는 오픈 소스 로컬 AI 에이전트 Goose의 터미널 인터페이스입니다." + }, + "pt": { + "description": "Goose CLI é a interface de terminal do Goose, um agente de IA local e de código aberto para programação, pesquisa, automação e outros fluxos com vários provedores de modelos." + }, + "ru": { + "description": "Goose CLI — терминальный интерфейс Goose, локального ИИ-агента с открытым исходным кодом для программирования, исследований, автоматизации и других процессов с разными поставщиками моделей." + }, + "tr": { + "description": "Goose CLI; kodlama, araştırma, otomasyon ve diğer iş akışları için birden fazla model sağlayıcısını destekleyen açık kaynaklı yerel AI aracısı Goose'un terminal arayüzüdür." + }, + "zh-Hans": { + "description": "Goose CLI 是开源本地 AI Agent Goose 的终端形态,支持多家模型提供商,可用于编码、研究、自动化及其他工作流。" + }, + "zh-Hant": { + "description": "Goose CLI 是開放原始碼本機 AI Agent Goose 的終端機形態,支援多家模型供應商,可用於程式設計、研究、自動化及其他工作流程。" + } + }, + "verified": true, + "lastVerifiedAt": "2026-08-01", + "verifiedBy": "codex-agent", + "confidence": "high", + "websiteUrl": "https://goose-docs.ai", + "docsUrl": "https://goose-docs.ai/docs/getting-started/installation", + "vendor": "Agentic AI Foundation", + "latestVersion": "1.45.0", + "releaseTracking": { + "provider": "github-release", + "identifier": "aaif-goose/goose" + }, + "githubUrl": "https://github.com/aaif-goose/goose", + "license": "Apache-2.0", + "pricing": [ + { + "name": "Open Source", + "value": 0, + "currency": null, + "per": null, + "category": "Individual" + } + ], + "resourceUrls": { + "download": "https://goose-docs.ai/docs/getting-started/installation", + "changelog": "https://github.com/aaif-goose/goose/releases", + "pricing": null, + "issue": "https://github.com/aaif-goose/goose/issues" + }, + "communityUrls": { + "linkedin": null, + "twitter": null, + "github": "https://github.com/aaif-goose/goose", + "youtube": null, + "discord": "https://discord.gg/goose-1268368495196131379", + "reddit": null, + "blog": null + }, + "relatedProducts": [ + { + "type": "desktop", + "productId": "goose" + } + ], + "platforms": [ + { + "os": "macOS", + "installCommand": "curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash", + "launchCommand": "goose", + "installPath": null + }, + { + "os": "Windows", + "installCommand": "powershell -ExecutionPolicy Bypass -c \"irm https://github.com/aaif-goose/goose/releases/download/stable/download_cli.ps1 | iex\"", + "launchCommand": "goose", + "installPath": null + }, + { + "os": "Linux", + "installCommand": "curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash", + "launchCommand": "goose", + "installPath": null + } + ], + "sources": [ + { + "url": "https://github.com/aaif-goose/goose", + "title": "Goose official repository", + "fields": [ + "name", + "description", + "websiteUrl", + "githubUrl", + "license", + "platforms", + "communityUrls.github", + "communityUrls.discord" + ] + }, + { + "url": "https://goose-docs.ai/docs/getting-started/installation", + "title": "Goose installation documentation", + "fields": ["docsUrl", "resourceUrls.download", "platforms"] + }, + { + "url": "https://github.com/aaif-goose/goose/releases/tag/v1.45.0", + "title": "Goose v1.45.0 release", + "fields": ["latestVersion", "releaseTracking", "resourceUrls.changelog"] + } + ] +} diff --git a/manifests/desktops/goose.json b/manifests/desktops/goose.json new file mode 100644 index 00000000..0cffccb5 --- /dev/null +++ b/manifests/desktops/goose.json @@ -0,0 +1,132 @@ +{ + "$schema": "../$schemas/desktop.schema.json", + "id": "goose", + "name": "Goose Desktop", + "familyId": "goose", + "description": "Goose Desktop is the native graphical surface of Goose, an open source local AI agent for coding, research, automation, and other workflows with multiple model providers.", + "translations": { + "de": { + "description": "Goose Desktop ist die native Oberfläche des lokalen Open-Source-KI-Agenten Goose für Programmierung, Recherche, Automatisierung und weitere Abläufe mit mehreren Modellen." + }, + "es": { + "description": "Goose Desktop es la interfaz gráfica nativa de Goose, un agente de IA local y de código abierto para programación, investigación, automatización y otros flujos con varios proveedores de modelos." + }, + "fr": { + "description": "Goose Desktop est l’interface graphique native de Goose, un agent IA local open source destiné au codage, à la recherche, à l’automatisation et à d’autres flux avec plusieurs fournisseurs de modèles." + }, + "id": { + "description": "Goose Desktop adalah antarmuka grafis native Goose, agen AI lokal sumber terbuka untuk coding, riset, otomatisasi, dan alur kerja lain dengan berbagai penyedia model." + }, + "ja": { + "description": "Goose Desktop は、複数のモデルプロバイダーを利用し、コーディング、調査、自動化などのワークフローに対応するオープンソースのローカル AI エージェント Goose のネイティブ GUI 版です。" + }, + "ko": { + "description": "Goose Desktop은 여러 모델 제공자를 활용해 코딩, 조사, 자동화 등의 워크플로를 수행하는 오픈 소스 로컬 AI 에이전트 Goose의 네이티브 그래픽 인터페이스입니다." + }, + "pt": { + "description": "Goose Desktop é a interface gráfica nativa do Goose, um agente de IA local e de código aberto para programação, pesquisa, automação e outros fluxos com vários provedores de modelos." + }, + "ru": { + "description": "Goose Desktop — графический интерфейс локального ИИ-агента Goose с открытым кодом для программирования, исследований и автоматизации с разными моделями." + }, + "tr": { + "description": "Goose Desktop; kodlama, araştırma, otomasyon ve diğer iş akışları için birden fazla model sağlayıcısını destekleyen açık kaynaklı yerel AI aracısı Goose'un yerel grafik arayüzüdür." + }, + "zh-Hans": { + "description": "Goose Desktop 是开源本地 AI Agent Goose 的原生图形界面,支持多家模型提供商,可用于编码、研究、自动化及其他工作流。" + }, + "zh-Hant": { + "description": "Goose Desktop 是開放原始碼本機 AI Agent Goose 的原生圖形介面,支援多家模型供應商,可用於程式設計、研究、自動化及其他工作流程。" + } + }, + "verified": true, + "lastVerifiedAt": "2026-08-01", + "verifiedBy": "codex-agent", + "confidence": "high", + "websiteUrl": "https://goose-docs.ai", + "docsUrl": "https://goose-docs.ai/docs/getting-started/installation", + "vendor": "Agentic AI Foundation", + "latestVersion": "1.45.0", + "releaseTracking": { + "provider": "github-release", + "identifier": "aaif-goose/goose" + }, + "githubUrl": "https://github.com/aaif-goose/goose", + "license": "Apache-2.0", + "pricing": [ + { + "name": "Open Source", + "value": 0, + "currency": null, + "per": null, + "category": "Individual" + } + ], + "resourceUrls": { + "download": "https://goose-docs.ai/docs/getting-started/installation", + "changelog": "https://github.com/aaif-goose/goose/releases", + "pricing": null, + "issue": "https://github.com/aaif-goose/goose/issues" + }, + "communityUrls": { + "linkedin": null, + "twitter": null, + "github": "https://github.com/aaif-goose/goose", + "youtube": null, + "discord": "https://discord.gg/goose-1268368495196131379", + "reddit": null, + "blog": null + }, + "relatedProducts": [ + { + "type": "cli", + "productId": "goose" + } + ], + "platforms": [ + { + "os": "macOS", + "installPath": "/Applications/Goose.app", + "installCommand": null, + "launchCommand": null + }, + { + "os": "Windows", + "installPath": null, + "installCommand": null, + "launchCommand": null + }, + { + "os": "Linux", + "installPath": null, + "installCommand": null, + "launchCommand": null + } + ], + "sources": [ + { + "url": "https://github.com/aaif-goose/goose", + "title": "Goose official repository", + "fields": [ + "name", + "description", + "websiteUrl", + "githubUrl", + "license", + "platforms", + "communityUrls.github", + "communityUrls.discord" + ] + }, + { + "url": "https://goose-docs.ai/docs/getting-started/installation", + "title": "Goose installation documentation", + "fields": ["docsUrl", "resourceUrls.download", "platforms"] + }, + { + "url": "https://github.com/aaif-goose/goose/releases/tag/v1.45.0", + "title": "Goose v1.45.0 release", + "fields": ["latestVersion", "releaseTracking", "resourceUrls.changelog"] + } + ] +} diff --git a/manifests/extensions/rovo-dev.json b/manifests/extensions/rovo-dev.json deleted file mode 100644 index 1d8f8e36..00000000 --- a/manifests/extensions/rovo-dev.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "$schema": "../$schemas/extension.schema.json", - "id": "rovo-dev", - "name": "Rovo Dev for VS Code", - "familyId": "rovo-dev", - "description": "Rovo Dev for VS Code is Atlassian's editor coding agent for code-aware chat, planning, edits, tests, change review, and access to Jira and Confluence context.", - "translations": { - "de": { - "description": "Rovo Dev für VS Code ist der Editor-Coding-Agent von Atlassian für codebezogenen Chat, Planung, Bearbeitungen, Tests, Änderungsprüfung und Zugriff auf Kontext aus Jira und Confluence." - }, - "es": { - "description": "Rovo Dev para VS Code es el agente de editor de Atlassian, con chat basado en el código, planificación, ediciones, pruebas, revisión de cambios y acceso al contexto de Jira y Confluence." - }, - "fr": { - "description": "Rovo Dev pour VS Code est l’agent de codage pour éditeur d’Atlassian, avec chat contextuel au code, planification, modifications, tests, examen des changements et accès au contexte Jira et Confluence." - }, - "id": { - "description": "Rovo Dev untuk VS Code adalah agen coding editor dari Atlassian untuk chat berbasis kode, perencanaan, pengeditan, pengujian, peninjauan perubahan, serta akses ke konteks Jira dan Confluence." - }, - "ja": { - "description": "Rovo Dev for VS Code は、コードを考慮したチャット、計画、編集、テスト、変更レビュー、Jira と Confluence のコンテキストへのアクセスに対応する Atlassian のエディター向けコーディングエージェントです。" - }, - "ko": { - "description": "Rovo Dev for VS Code는 코드 인식 채팅, 계획, 편집, 테스트, 변경 사항 검토, Jira 및 Confluence 컨텍스트 접근을 위한 Atlassian의 편집기 코딩 에이전트입니다." - }, - "pt": { - "description": "O Rovo Dev para VS Code é o agente da Atlassian para o editor, com chat baseado no código, planejamento, edições, testes, revisão de mudanças e acesso ao contexto do Jira e do Confluence." - }, - "ru": { - "description": "Rovo Dev для VS Code — агент программирования Atlassian для редактора с чатом по коду, планированием, редактированием, тестами, просмотром изменений и доступом к контексту Jira и Confluence." - }, - "tr": { - "description": "Rovo Dev for VS Code, kod odaklı sohbet, planlama, düzenleme, test, değişiklik inceleme ve Jira ile Confluence bağlamına erişim sunan Atlassian editör kodlama aracısıdır." - }, - "zh-Hans": { - "description": "Rovo Dev for VS Code 是 Atlassian 的编辑器编码代理,支持代码感知聊天、规划、编辑、测试、变更审查,以及访问 Jira 和 Confluence 上下文。" - }, - "zh-Hant": { - "description": "Rovo Dev for VS Code 是 Atlassian 的編輯器程式設計代理,支援程式碼感知聊天、規劃、編輯、測試、變更檢查,以及存取 Jira 和 Confluence 內容。" - } - }, - "verified": true, - "sources": [ - { - "url": "https://www.atlassian.com/blog/announcements/rovo-dev-now-generally-available-in-vs-code", - "title": "Rovo Dev is generally available in VS Code" - }, - { - "url": "https://marketplace.visualstudio.com/items?itemName=Atlassian.atlascode", - "title": "Atlassian VS Code extension" - }, - { - "url": "https://www.atlassian.com/software/rovo-dev/pricing", - "title": "Rovo Dev pricing", - "fields": ["pricing", "resourceUrls.pricing"] - }, - { - "url": "https://github.com/atlassian/atlascode", - "title": "Atlassian for VS Code official repository", - "fields": ["githubUrl", "license", "resourceUrls.issue"] - } - ], - "lastVerifiedAt": "2026-07-30", - "verifiedBy": "codex-agent", - "confidence": "high", - "websiteUrl": "https://marketplace.visualstudio.com/items?itemName=Atlassian.atlascode", - "docsUrl": "https://support.atlassian.com/rovo/docs/work-with-rovo-dev-agents/", - "vendor": "Atlassian", - "latestVersion": "4.1.191", - "releaseTracking": { - "provider": "vscode-marketplace", - "identifier": "Atlassian.atlascode" - }, - "githubUrl": "https://github.com/atlassian/atlascode", - "license": "Apache-2.0", - "pricing": [ - { - "name": "Rovo Dev Standard", - "value": 20, - "currency": "USD", - "per": "user/month", - "category": "Business" - } - ], - "resourceUrls": { - "download": "https://marketplace.visualstudio.com/items?itemName=Atlassian.atlascode", - "changelog": "https://marketplace.visualstudio.com/items/Atlassian.atlascode/changelog", - "pricing": "https://www.atlassian.com/software/rovo-dev/pricing", - "issue": "https://github.com/atlassian/atlascode/issues" - }, - "communityUrls": { - "linkedin": null, - "twitter": null, - "github": null, - "youtube": null, - "discord": null, - "reddit": null, - "blog": null - }, - "relatedProducts": [], - "supportedIdes": [ - { - "ideId": "vscode", - "marketplaceUrl": "https://marketplace.visualstudio.com/items?itemName=Atlassian.atlascode", - "installUri": "vscode:extension/Atlassian.atlascode" - }, - { - "ideId": "cursor", - "marketplaceUrl": null, - "installUri": "cursor:extension/Atlassian.atlascode" - }, - { - "ideId": "windsurf", - "marketplaceUrl": null, - "installUri": "windsurf:extension/Atlassian.atlascode" - } - ] -} diff --git a/manifests/vendors/agentic-ai-foundation.json b/manifests/vendors/agentic-ai-foundation.json new file mode 100644 index 00000000..548d3c19 --- /dev/null +++ b/manifests/vendors/agentic-ai-foundation.json @@ -0,0 +1,68 @@ +{ + "$schema": "../$schemas/vendor.schema.json", + "id": "agentic-ai-foundation", + "name": "Agentic AI Foundation", + "aliases": ["AAIF"], + "description": "The Agentic AI Foundation, hosted by the Linux Foundation, provides vendor-neutral governance for open source agentic AI projects including Goose.", + "translations": { + "de": { + "description": "Die bei der Linux Foundation angesiedelte Agentic AI Foundation bietet anbieterneutrale Governance für quelloffene agentische KI-Projekte wie Goose." + }, + "es": { + "description": "La Agentic AI Foundation, alojada por la Linux Foundation, ofrece gobernanza neutral para proyectos de IA agéntica de código abierto como Goose." + }, + "fr": { + "description": "L’Agentic AI Foundation, hébergée par la Linux Foundation, assure une gouvernance neutre pour des projets d’IA agentique open source tels que Goose." + }, + "id": { + "description": "Agentic AI Foundation, yang dinaungi Linux Foundation, menyediakan tata kelola netral vendor untuk proyek AI agentik sumber terbuka termasuk Goose." + }, + "ja": { + "description": "Linux Foundation がホストする Agentic AI Foundation は、Goose を含むオープンソースのエージェント型 AI プロジェクトにベンダー中立のガバナンスを提供します。" + }, + "ko": { + "description": "Linux Foundation이 주관하는 Agentic AI Foundation은 Goose를 포함한 오픈 소스 에이전틱 AI 프로젝트에 공급자 중립적 거버넌스를 제공합니다." + }, + "pt": { + "description": "A Agentic AI Foundation, hospedada pela Linux Foundation, oferece governança neutra para projetos de IA agêntica de código aberto, incluindo o Goose." + }, + "ru": { + "description": "Agentic AI Foundation под эгидой Linux Foundation обеспечивает нейтральное управление проектами агентного ИИ с открытым исходным кодом, включая Goose." + }, + "tr": { + "description": "Linux Foundation bünyesindeki Agentic AI Foundation, Goose dahil açık kaynaklı aracı AI projeleri için sağlayıcıdan bağımsız yönetişim sunar." + }, + "zh-Hans": { + "description": "Agentic AI Foundation 由 Linux Foundation 托管,为包括 Goose 在内的开源智能体 AI 项目提供厂商中立的治理。" + }, + "zh-Hant": { + "description": "Agentic AI Foundation 由 Linux Foundation 託管,為包括 Goose 在內的開放原始碼智慧代理 AI 專案提供廠商中立的治理。" + } + }, + "verified": true, + "lastVerifiedAt": "2026-08-01", + "verifiedBy": "codex-agent", + "confidence": "high", + "websiteUrl": "https://aaif.io", + "communityUrls": { + "linkedin": null, + "twitter": null, + "github": "https://github.com/aaif-goose", + "youtube": null, + "discord": null, + "reddit": null, + "blog": null + }, + "sources": [ + { + "url": "https://aaif.io", + "title": "Agentic AI Foundation official site", + "fields": ["name", "description", "websiteUrl"] + }, + { + "url": "https://github.com/aaif-goose/goose", + "title": "Goose official repository", + "fields": ["description", "communityUrls.github"] + } + ] +} diff --git a/manifests/vendors/atlassian.json b/manifests/vendors/atlassian.json deleted file mode 100644 index a67c5b02..00000000 --- a/manifests/vendors/atlassian.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "$schema": "../$schemas/vendor.schema.json", - "id": "atlassian", - "name": "Atlassian", - "description": "Atlassian develops collaboration and DevOps products including Jira, Confluence, Bitbucket, and the Rovo Dev coding agent.", - "translations": { - "de": { - "description": "Atlassian entwickelt Kollaborations- und DevOps-Produkte, darunter Jira, Confluence, Bitbucket und den Coding-Agenten Rovo Dev." - }, - "es": { - "description": "Atlassian desarrolla productos de colaboración y DevOps, entre ellos Jira, Confluence, Bitbucket y el agente de programación Rovo Dev." - }, - "fr": { - "description": "Atlassian développe des produits de collaboration et de DevOps, notamment Jira, Confluence, Bitbucket et l’agent de codage Rovo Dev." - }, - "id": { - "description": "Atlassian mengembangkan produk kolaborasi dan DevOps, termasuk Jira, Confluence, Bitbucket, dan agen coding Rovo Dev." - }, - "ja": { - "description": "Atlassian は、Jira、Confluence、Bitbucket、コーディングエージェント Rovo Dev などのコラボレーションおよび DevOps 製品を開発しています。" - }, - "ko": { - "description": "Atlassian은 Jira, Confluence, Bitbucket, Rovo Dev 코딩 에이전트를 비롯한 협업 및 DevOps 제품을 개발합니다." - }, - "pt": { - "description": "A Atlassian desenvolve produtos de colaboração e DevOps, incluindo Jira, Confluence, Bitbucket e o agente de programação Rovo Dev." - }, - "ru": { - "description": "Atlassian разрабатывает продукты для совместной работы и DevOps, включая Jira, Confluence, Bitbucket и агент программирования Rovo Dev." - }, - "tr": { - "description": "Atlassian; Jira, Confluence, Bitbucket ve Rovo Dev kodlama aracısı dahil olmak üzere iş birliği ve DevOps ürünleri geliştirir." - }, - "zh-Hans": { - "description": "Atlassian 开发协作和 DevOps 产品,包括 Jira、Confluence、Bitbucket 以及 Rovo Dev 编码代理。" - }, - "zh-Hant": { - "description": "Atlassian 開發協作和 DevOps 產品,包括 Jira、Confluence、Bitbucket 以及 Rovo Dev 程式設計代理。" - } - }, - "verified": true, - "sources": [ - { - "url": "https://www.atlassian.com/company", - "title": "About Atlassian", - "fields": ["name", "description", "websiteUrl"] - }, - { - "url": "https://www.atlassian.com/software/rovo-dev", - "title": "Rovo Dev", - "fields": ["description"] - }, - { - "url": "https://www.linkedin.com/company/atlassian", - "title": "Atlassian linkedin community", - "fields": ["communityUrls.linkedin"] - }, - { - "url": "https://x.com/Atlassian", - "title": "Atlassian twitter community", - "fields": ["communityUrls.twitter"] - }, - { - "url": "https://github.com/atlassian", - "title": "Atlassian github community", - "fields": ["communityUrls.github"] - }, - { - "url": "https://www.youtube.com/@Atlassian", - "title": "Atlassian youtube community", - "fields": ["communityUrls.youtube"] - }, - { - "url": "https://www.reddit.com/r/atlassian", - "title": "Atlassian reddit community", - "fields": ["communityUrls.reddit"] - }, - { - "url": "https://www.atlassian.com/blog", - "title": "Atlassian blog community", - "fields": ["communityUrls.blog"] - } - ], - "lastVerifiedAt": "2026-07-28", - "verifiedBy": "codex-agent", - "confidence": "high", - "websiteUrl": "https://www.atlassian.com", - "communityUrls": { - "linkedin": "https://www.linkedin.com/company/atlassian", - "twitter": "https://x.com/Atlassian", - "github": "https://github.com/atlassian", - "youtube": "https://www.youtube.com/@Atlassian", - "discord": null, - "reddit": "https://www.reddit.com/r/atlassian", - "blog": "https://www.atlassian.com/blog" - } -} diff --git a/src/lib/generated/clis.ts b/src/lib/generated/clis.ts index 85dc097c..00c1b8dd 100644 --- a/src/lib/generated/clis.ts +++ b/src/lib/generated/clis.ts @@ -18,6 +18,7 @@ import DroidCli from '../../../manifests/clis/droid-cli.json' import GeminiCli from '../../../manifests/clis/gemini-cli.json' import GithubCopilotCli from '../../../manifests/clis/github-copilot-cli.json' import GitlabDuoCli from '../../../manifests/clis/gitlab-duo-cli.json' +import Goose from '../../../manifests/clis/goose.json' import GrokBuild from '../../../manifests/clis/grok-build.json' import JunieCli from '../../../manifests/clis/junie-cli.json' import KiloCodeCli from '../../../manifests/clis/kilo-code-cli.json' @@ -46,6 +47,7 @@ export const clisData = [ GeminiCli, GithubCopilotCli, GitlabDuoCli, + Goose, GrokBuild, JunieCli, KiloCodeCli, diff --git a/src/lib/generated/desktops.ts b/src/lib/generated/desktops.ts index 3ecf95c0..82297a60 100644 --- a/src/lib/generated/desktops.ts +++ b/src/lib/generated/desktops.ts @@ -9,6 +9,7 @@ import ClaudeCodeDesktop from '../../../manifests/desktops/claude-code-desktop.j import Codebuddy from '../../../manifests/desktops/codebuddy.json' import CodexApp from '../../../manifests/desktops/codex-app.json' import FactoryDesktop from '../../../manifests/desktops/factory-desktop.json' +import Goose from '../../../manifests/desktops/goose.json' import MinimaxCode from '../../../manifests/desktops/minimax-code.json' import OpencodeDesktop from '../../../manifests/desktops/opencode-desktop.json' import Qoder from '../../../manifests/desktops/qoder.json' @@ -23,6 +24,7 @@ export const desktopsData = [ Codebuddy, CodexApp, FactoryDesktop, + Goose, MinimaxCode, OpencodeDesktop, Qoder, diff --git a/src/lib/generated/extensions.ts b/src/lib/generated/extensions.ts index 0bc2f40c..6c889a74 100644 --- a/src/lib/generated/extensions.ts +++ b/src/lib/generated/extensions.ts @@ -20,7 +20,6 @@ import MistralVibe from '../../../manifests/extensions/mistral-vibe.json' import OpencodeExtension from '../../../manifests/extensions/opencode-extension.json' import Qoder from '../../../manifests/extensions/qoder.json' import RooCode from '../../../manifests/extensions/roo-code.json' -import RovoDev from '../../../manifests/extensions/rovo-dev.json' import Tabnine from '../../../manifests/extensions/tabnine.json' import Verdent from '../../../manifests/extensions/verdent.json' import type { ManifestExtension } from '../../types/manifests' @@ -42,7 +41,6 @@ export const extensionsData = [ OpencodeExtension, Qoder, RooCode, - RovoDev, Tabnine, Verdent, ] as unknown as ManifestExtension[] diff --git a/src/lib/generated/metadata.ts b/src/lib/generated/metadata.ts index 001f7ca4..6932a8c6 100644 --- a/src/lib/generated/metadata.ts +++ b/src/lib/generated/metadata.ts @@ -3566,9 +3566,9 @@ export const faqMetadata: Record = { export const stackCounts: Record = { ides: 8, - clis: 25, - desktops: 11, - extensions: 19, + clis: 26, + desktops: 12, + extensions: 18, models: 123, 'model-providers': 17, vendors: 45, diff --git a/src/lib/generated/vendors.ts b/src/lib/generated/vendors.ts index 72f49309..1ceb88a3 100644 --- a/src/lib/generated/vendors.ts +++ b/src/lib/generated/vendors.ts @@ -4,12 +4,12 @@ * Do not edit manually - run the script to regenerate */ +import AgenticAiFoundation from '../../../manifests/vendors/agentic-ai-foundation.json' import Alibaba from '../../../manifests/vendors/alibaba.json' import Anomaly from '../../../manifests/vendors/anomaly.json' import AntGroup from '../../../manifests/vendors/ant-group.json' import Anthropic from '../../../manifests/vendors/anthropic.json' import Anysphere from '../../../manifests/vendors/anysphere.json' -import Atlassian from '../../../manifests/vendors/atlassian.json' import Augment from '../../../manifests/vendors/augment.json' import Aws from '../../../manifests/vendors/aws.json' import Baseten from '../../../manifests/vendors/baseten.json' @@ -52,12 +52,12 @@ import ZedIndustries from '../../../manifests/vendors/zed-industries.json' import type { ManifestVendor } from '../../types/manifests' export const vendorsData = [ + AgenticAiFoundation, Alibaba, Anomaly, AntGroup, Anthropic, Anysphere, - Atlassian, Augment, Aws, Baseten, @@ -99,6 +99,6 @@ export const vendorsData = [ ZedIndustries, ] as unknown as ManifestVendor[] -export type Vendor = typeof Alibaba +export type Vendor = typeof AgenticAiFoundation export default vendorsData From b631b0497ff184476e7896ba373f24300ddeb643 Mon Sep 17 00:00:00 2001 From: Pan YANG Date: Sun, 2 Aug 2026 19:30:38 +0800 Subject: [PATCH 2/4] refactor(data): key GitHub stars by repository --- .agents/skills/manifest-automation/SKILL.md | 2 +- .../scripts/lib/github-stars-updater.mjs | 207 ++--- data/github-stars.json | 102 +-- docs/FETCH_GITHUB_STARS.md | 15 +- manifests/$schemas/github-stars.schema.json | 80 +- manifests/$schemas/ref/product.schema.json | 26 + manifests/clis/codex-cli.json | 6 +- manifests/desktops/codex-app.json | 6 +- manifests/desktops/verdent-deck.json | 9 + manifests/extensions/codex.json | 6 +- manifests/extensions/verdent.json | 9 + manifests/ides/intellij-idea.json | 7 +- manifests/ides/vscode.json | 6 +- scripts/fetch/fetch-github-stars.ts | 310 +------- scripts/generate/generate-manifest-indexes.ts | 22 +- src/app/[locale]/clis/[slug]/page.tsx | 2 +- .../[locale]/clis/comparison/page.client.tsx | 3 +- src/app/[locale]/desktops/[slug]/page.tsx | 2 +- src/app/[locale]/extensions/[slug]/page.tsx | 2 +- .../extensions/comparison/page.client.tsx | 3 +- src/app/[locale]/ides/[slug]/page.tsx | 2 +- .../[locale]/ides/comparison/page.client.tsx | 3 +- .../[locale]/open-source-rank/page.client.tsx | 718 ++++++++---------- src/lib/generated/github-stars.ts | 18 +- src/lib/landscape-data.ts | 8 +- src/types/manifests.ts | 17 +- .../validate/githubStars.consistency.test.ts | 178 ++--- translations/de/pages/open-source-rank.json | 5 + translations/en/pages/open-source-rank.json | 23 +- translations/es/pages/open-source-rank.json | 5 + translations/fr/pages/open-source-rank.json | 5 + translations/id/pages/open-source-rank.json | 5 + translations/ja/pages/open-source-rank.json | 5 + translations/ko/pages/open-source-rank.json | 5 + translations/pt/pages/open-source-rank.json | 5 + translations/ru/pages/open-source-rank.json | 5 + translations/tr/pages/open-source-rank.json | 5 + .../zh-Hans/pages/open-source-rank.json | 23 +- .../zh-Hant/pages/open-source-rank.json | 5 + 39 files changed, 787 insertions(+), 1078 deletions(-) diff --git a/.agents/skills/manifest-automation/SKILL.md b/.agents/skills/manifest-automation/SKILL.md index 66c418a9..2c30561a 100644 --- a/.agents/skills/manifest-automation/SKILL.md +++ b/.agents/skills/manifest-automation/SKILL.md @@ -42,7 +42,7 @@ The merge helper is advisory. Always inspect its proposed result before applying ## GitHub stars -`data/github-stars.json` tracks `cli`, `desktop`, `extension`, and `ide` entries. Every CLI, desktop, extension, and IDE manifest must have a corresponding entry; use `null` when no official repository or trustworthy count is available. Models, providers, and vendors are not tracked. +`data/github-stars.json` is a repository-keyed snapshot containing only the observation date and raw star counts. Product associations come from each CLI, desktop, extension, or IDE manifest's `githubUrl`; use `sourceCode` when the repository is only a partial source tree or serves as feedback or documentation rather than product source. Use `null` when a tracked repository has no trustworthy count. Models, providers, and vendors are not tracked. ## Validation diff --git a/.agents/skills/manifest-automation/scripts/lib/github-stars-updater.mjs b/.agents/skills/manifest-automation/scripts/lib/github-stars-updater.mjs index f5b62407..73db793f 100644 --- a/.agents/skills/manifest-automation/scripts/lib/github-stars-updater.mjs +++ b/.agents/skills/manifest-automation/scripts/lib/github-stars-updater.mjs @@ -2,7 +2,7 @@ /** * GitHub Stars Updater - * Updates github-stars.json with new manifest entries + * Keeps repository keys in github-stars.json aligned with product manifests. */ import fs from 'node:fs' @@ -11,122 +11,101 @@ import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const manifestDirectories = { + cli: 'clis', + desktop: 'desktops', + extension: 'extensions', + ide: 'ides', +} -/** - * Get project root directory - */ function getProjectRoot() { return path.resolve(__dirname, '../../../../..') } -/** - * Get the path to github-stars.json - */ function getGithubStarsPath() { return path.join(getProjectRoot(), 'data/github-stars.json') } -/** - * Load github-stars.json - * @returns {Object} The current github-stars data - */ -export function loadGithubStars() { - const filePath = getGithubStarsPath() +function getManifestPath(type, id) { + const directory = manifestDirectories[type] + return directory ? path.join(getProjectRoot(), 'manifests', directory, `${id}.json`) : null +} - if (!fs.existsSync(filePath)) { - throw new Error(`github-stars.json not found at: ${filePath}`) - } +function repositoryIdFromUrl(url) { + const match = url + ?.replace(/\/$/, '') + .replace(/\.git$/, '') + .match(/^https:\/\/github\.com\/(.+\/.+)$/) + return match?.[1] ?? null +} - const content = fs.readFileSync(filePath, 'utf-8') - return JSON.parse(content) +function loadManifestRepository(type, id) { + const manifestPath = getManifestPath(type, id) + if (!manifestPath || !fs.existsSync(manifestPath)) return null + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + return repositoryIdFromUrl(manifest.githubUrl) } -/** - * Save github-stars.json - * @param {Object} data - The github-stars data to save - */ -export function saveGithubStars(data) { - const filePath = getGithubStarsPath() - const content = `${JSON.stringify(data, null, 2)}\n` - fs.writeFileSync(filePath, content, 'utf-8') +function countRepositoryAssociations(repositoryId, excludedType, excludedId) { + let count = 0 + for (const [type, directory] of Object.entries(manifestDirectories)) { + const directoryPath = path.join(getProjectRoot(), 'manifests', directory) + for (const file of fs.readdirSync(directoryPath).filter(name => name.endsWith('.json'))) { + const id = file.replace(/\.json$/, '') + if (type === excludedType && id === excludedId) continue + const manifest = JSON.parse(fs.readFileSync(path.join(directoryPath, file), 'utf8')) + if (repositoryIdFromUrl(manifest.githubUrl) === repositoryId) count += 1 + } + } + return count } -/** - * Get the tracked category name from manifest type. - * @param {string} type - Manifest type (cli, extension, ide, model) - * @returns {string} Category name for github-stars.json - */ -function getCategoryName(type) { - const mapping = { - cli: 'clis', - extension: 'extensions', - ide: 'ides', - model: 'models', +export function loadGithubStars() { + const filePath = getGithubStarsPath() + if (!fs.existsSync(filePath)) { + throw new Error(`github-stars.json not found at: ${filePath}`) } + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} - return mapping[type] || null +export function saveGithubStars(data) { + const sortedRepositories = Object.fromEntries( + Object.entries(data.repositories).sort(([left], [right]) => left.localeCompare(right)) + ) + fs.writeFileSync( + getGithubStarsPath(), + `${JSON.stringify({ ...data, repositories: sortedRepositories }, null, 2)}\n`, + 'utf8' + ) } -/** - * Update github-stars.json with a new or updated manifest entry - * @param {string} type - Manifest type (cli, extension, ide, etc.) - * @param {string} id - Manifest id - * @param {Object} options - Options - * @param {boolean} options.isNew - Whether this is a new entry (true) or update (false) - * @returns {Object} Result with status and message - */ export function updateGithubStarsEntry(type, id, options = {}) { const { isNew = false } = options try { - // Load current data - const githubStars = loadGithubStars() - const category = getCategoryName(type) - - if (!category || !Object.hasOwn(githubStars, category)) { + const repositoryId = loadManifestRepository(type, id) + if (!repositoryId) { return { status: 'skipped', - message: `Manifest type "${type}" is not tracked by data/github-stars.json`, + message: `Manifest "${type}:${id}" has no tracked GitHub repository`, } } - // Check if entry already exists - const exists = id in githubStars[category] - + const githubStars = loadGithubStars() + const exists = Object.hasOwn(githubStars.repositories, repositoryId) if (isNew && exists) { return { status: 'skipped', - message: `Entry "${id}" already exists in github-stars.json under "${category}"`, - } - } - - if (!isNew && !exists) { - return { - status: 'skipped', - message: `Entry "${id}" does not exist in github-stars.json under "${category}"; no change made. Verify the official repository, then use the add command.`, + message: `Repository "${repositoryId}" already exists in github-stars.json`, } } - // Add or update entry with null (stars will be fetched later) - githubStars[category][id] = null - - // Sort entries alphabetically within category - const sortedCategory = Object.keys(githubStars[category]) - .sort() - .reduce((acc, key) => { - acc[key] = githubStars[category][key] - return acc - }, {}) - - githubStars[category] = sortedCategory - - // Save updated data + githubStars.repositories[repositoryId] ??= null saveGithubStars(githubStars) - return { status: 'success', - message: `Updated github-stars.json: ${category}["${id}"] = null`, - action: exists ? 'updated' : 'added', + message: `Tracked github-stars.json repository "${repositoryId}"`, + action: exists ? 'unchanged' : 'added', } } catch (error) { return { @@ -137,83 +116,59 @@ export function updateGithubStarsEntry(type, id, options = {}) { } } -/** - * Remove an entry from github-stars.json - * @param {string} type - Manifest type - * @param {string} id - Manifest id - * @returns {Object} Result with status and message - */ export function removeGithubStarsEntry(type, id) { try { - const githubStars = loadGithubStars() - const category = getCategoryName(type) + const repositoryId = loadManifestRepository(type, id) + if (!repositoryId) { + return { + status: 'skipped', + message: `Manifest "${type}:${id}" has no tracked GitHub repository`, + } + } - if (!category || !Object.hasOwn(githubStars, category)) { + if (countRepositoryAssociations(repositoryId, type, id) > 0) { return { status: 'skipped', - message: `Manifest type "${type}" is not tracked by data/github-stars.json`, + message: `Repository "${repositoryId}" is still used by another product surface`, } } - if (!githubStars[category] || !(id in githubStars[category])) { + const githubStars = loadGithubStars() + if (!Object.hasOwn(githubStars.repositories, repositoryId)) { return { status: 'skipped', - message: `Entry "${id}" not found in github-stars.json under "${category}"`, + message: `Repository "${repositoryId}" is not tracked in github-stars.json`, } } - delete githubStars[category][id] + delete githubStars.repositories[repositoryId] saveGithubStars(githubStars) - return { status: 'success', - message: `Removed "${id}" from github-stars.json under "${category}"`, + message: `Removed repository "${repositoryId}" from github-stars.json`, } } catch (error) { return { status: 'error', - message: `Failed to remove entry from github-stars.json: ${error.message}`, + message: `Failed to remove repository from github-stars.json: ${error.message}`, error, } } } -/** - * CLI entry point for testing - */ if (import.meta.url === `file://${process.argv[1]}`) { const [, , command, type, id] = process.argv - - if (!command || !['add', 'update', 'remove'].includes(command)) { + if (!command || !['add', 'update', 'remove'].includes(command) || !type || !id) { console.error('Usage:') - console.error(' node github-stars-updater.mjs add ') - console.error(' node github-stars-updater.mjs update ') - console.error(' node github-stars-updater.mjs remove ') - console.error('') - console.error('Examples:') - console.error(' node github-stars-updater.mjs add cli cursor-cli') - console.error(' node github-stars-updater.mjs update extension claude-code') - console.error(' node github-stars-updater.mjs remove ide windsurf') - process.exit(1) - } - - if (!type || !id) { - console.error('Error: type and id are required') + console.error(' node github-stars-updater.mjs ') process.exit(1) } - let result - - if (command === 'add' || command === 'update') { - result = updateGithubStarsEntry(type, id, { isNew: command === 'add' }) - } else { - result = removeGithubStarsEntry(type, id) - } - + const result = + command === 'remove' + ? removeGithubStarsEntry(type, id) + : updateGithubStarsEntry(type, id, { isNew: command === 'add' }) console.log(`Status: ${result.status}`) console.log(`Message: ${result.message}`) - - if (result.status === 'error') { - process.exit(1) - } + if (result.status === 'error') process.exit(1) } diff --git a/data/github-stars.json b/data/github-stars.json index f4a396f1..0fb37612 100644 --- a/data/github-stars.json +++ b/data/github-stars.json @@ -1,73 +1,35 @@ { - "extensions": { - "amp": null, - "augment-code": null, - "claude-code": 139.2, - "cline": 65.3, - "codex": 102.6, - "continue": 35.1, - "droid": null, - "gemini-code-assist": null, - "github-copilot": null, - "jetbrains-junie": 0.3, - "kilo-code": 26.5, - "kimi-code": 5.2, - "mistral-vibe": null, - "opencode-extension": 190, - "qoder": null, - "roo-code": 24.4, - "rovo-dev": 0.1, - "tabnine": 10.8, - "verdent": 0 - }, - "clis": { - "amazon-q-developer-cli": 2, - "amp-cli": null, - "antigravity-cli": 1.7, - "auggie-cli": 0.3, - "claude-code-cli": 139.2, - "cline-cli": 65.3, - "codebuddy-cli": null, - "codex-cli": 102.6, - "continue-cli": 35.1, - "cursor-cli": 33.1, - "droid-cli": null, - "gemini-cli": 106.2, - "github-copilot-cli": 11, - "gitlab-duo-cli": null, - "grok-build": 22.8, - "junie-cli": 0.3, - "kilo-code-cli": 26.5, - "kimi-cli": 5.2, - "kiro-cli": 4.1, - "kode": 5.2, - "vibe-cli": 4.8, - "omp": 19.9, - "opencode": 190, - "qoder-cli": null, - "qwen-code": 26.3 - }, - "desktops": { - "air": null, - "claude-code-desktop": 139.2, - "codebuddy": null, - "codex-app": 102.6, - "factory-desktop": null, - "minimax-code": 0, - "opencode-desktop": 190, - "qoder": null, - "stagewise": 6.8, - "verdent-deck": 0, - "zcode": null - }, - "ides": { - "antigravity": null, - "cursor": 33.1, - "intellij-idea": 20.4, - "kiro": 4.1, - "trae": 0.9, - "vscode": 187.9, - "windsurf": null, - "zed": 87.6 + "observedAt": "2026-08-01", + "repositories": { + "aaif-goose/goose": 52043, + "anomalyco/opencode": 190000, + "anthropics/claude-code": 139200, + "augmentcode/auggie": 300, + "aws/amazon-q-developer-cli": 2000, + "can1357/oh-my-pi": 19900, + "cline/cline": 65300, + "codota/TabNine": 10800, + "continuedev/continue": 35100, + "cursor/cursor": 33100, + "github/copilot-cli": 11000, + "google-antigravity/antigravity-cli": 1700, + "google-gemini/gemini-cli": 106200, + "JetBrains/intellij-community": 20400, + "JetBrains/junie": 300, + "Kilo-Org/kilocode": 26500, + "kirodotdev/Kiro": 4100, + "microsoft/vscode": 187900, + "MiniMax-AI/minimax-code": 0, + "mistralai/mistral-vibe": 4800, + "MoonshotAI/kimi-code": 5200, + "openai/codex": 102600, + "QwenLM/qwen-code": 26300, + "RooCodeInc/Roo-Code": 24400, + "shareAI-lab/Kode-CLI": 5200, + "stagewise-io/stagewise": 6800, + "Trae-AI/TRAE": 900, + "verdentAI/docs": 0, + "xai-org/grok-build": 22800, + "zed-industries/zed": 87600 } } diff --git a/docs/FETCH_GITHUB_STARS.md b/docs/FETCH_GITHUB_STARS.md index c4eae347..9fb8965e 100644 --- a/docs/FETCH_GITHUB_STARS.md +++ b/docs/FETCH_GITHUB_STARS.md @@ -1,6 +1,6 @@ # GitHub Stars Refresh -The stars refresh reads `githubUrl` from IDE, CLI, and extension manifests, queries the GitHub repository API, and writes the centralized cache at `data/github-stars.json`. +The stars refresh reads the repository keys already tracked in `data/github-stars.json`, queries the GitHub repository API, and updates their raw star counts. Product associations are derived separately from the `githubUrl` fields in IDE, CLI, desktop, and extension manifests. ## Run it @@ -24,13 +24,14 @@ The file follows `manifests/$schemas/github-stars.schema.json`: ```ts interface GitHubStarsData { - extensions: Record - clis: Record - ides: Record + observedAt: string + repositories: Record } ``` -Values are stored in thousands with one decimal place, matching the current UI contract; for example, `42.3` means approximately 42,300 stars. A `null` value means the manifest has no usable repository URL or no cached value exists. Transient API failures retain the previous cached value instead of replacing it with `null`. +Repository keys use GitHub's `owner/repository` form and values are raw stargazer counts. The UI converts them to compact thousands when needed. A `null` value means no trustworthy count is currently available. Transient API failures retain the previous cached value instead of replacing it with `null`. + +Product names, product surfaces, repository roles, licenses, and source-code coverage do not belong in the Stars snapshot. They come from product manifests; use the manifest `sourceCode` override when a repository contains only part of the product source or is used only for feedback or documentation. ## Automation @@ -47,7 +48,7 @@ There is only one scheduled owner for this refresh. General scheduled URL checks - `403`: provide `GITHUB_TOKEN` or wait for the API limit to reset. - `404`: check the manifest's `githubUrl` and repository visibility. -- Validation failure: ensure each IDE, CLI, and extension manifest has a matching key and no orphan key remains. +- Validation failure: ensure every non-null product `githubUrl` maps to a repository key and no repository key is orphaned. - No pull request: confirm the workflow checked `data/github-stars.json` and that the refreshed values actually differ. -Last reviewed: 2026-07-18. +Last reviewed: 2026-08-01. diff --git a/manifests/$schemas/github-stars.schema.json b/manifests/$schemas/github-stars.schema.json index c3f4a912..af4cceb6 100644 --- a/manifests/$schemas/github-stars.schema.json +++ b/manifests/$schemas/github-stars.schema.json @@ -1,84 +1,28 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://aicodingstack.io/schemas/github-stars.schema.json", - "title": "GitHub Stars Data", - "description": "Schema for the centralized GitHub stars data file", + "title": "GitHub Repository Stars Data", + "description": "Repository-keyed GitHub star snapshots. Product associations and source-code status are derived from product manifests.", "type": "object", "properties": { - "extensions": { - "type": "object", - "description": "GitHub stars for extensions", - "patternProperties": { - "^[a-z0-9-]+$": { - "oneOf": [ - { - "type": "number", - "minimum": 0, - "description": "Number of GitHub stars in thousands (e.g., 42 = 42k stars)" - }, - { - "type": "null", - "description": "No GitHub stars data available" - } - ] - } - }, - "additionalProperties": false - }, - "clis": { - "type": "object", - "description": "GitHub stars for CLI tools", - "patternProperties": { - "^[a-z0-9-]+$": { - "oneOf": [ - { - "type": "number", - "minimum": 0, - "description": "Number of GitHub stars in thousands (e.g., 42 = 42k stars)" - }, - { - "type": "null", - "description": "No GitHub stars data available" - } - ] - } - }, - "additionalProperties": false - }, - "desktops": { - "type": "object", - "description": "GitHub stars for desktop coding agents", - "patternProperties": { - "^[a-z0-9-]+$": { - "oneOf": [ - { - "type": "number", - "minimum": 0, - "description": "Number of GitHub stars in thousands (e.g., 42 = 42k stars)" - }, - { - "type": "null", - "description": "No GitHub stars data available" - } - ] - } - }, - "additionalProperties": false + "observedAt": { + "type": "string", + "format": "date", + "description": "Date when the repository star counts were observed" }, - "ides": { + "repositories": { "type": "object", - "description": "GitHub stars for IDEs", "patternProperties": { - "^[a-z0-9-]+$": { + "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$": { "oneOf": [ { - "type": "number", + "type": "integer", "minimum": 0, - "description": "Number of GitHub stars in thousands (e.g., 42 = 42k stars)" + "description": "Raw GitHub stargazer count" }, { "type": "null", - "description": "No GitHub stars data available" + "description": "No GitHub star count is currently available" } ] } @@ -86,6 +30,6 @@ "additionalProperties": false } }, - "required": ["extensions", "clis", "desktops", "ides"], + "required": ["observedAt", "repositories"], "additionalProperties": false } diff --git a/manifests/$schemas/ref/product.schema.json b/manifests/$schemas/ref/product.schema.json index 94a4758f..e15041b9 100644 --- a/manifests/$schemas/ref/product.schema.json +++ b/manifests/$schemas/ref/product.schema.json @@ -34,6 +34,10 @@ "description": "Valid SPDX License Identifier or the literal Proprietary", "examples": ["Proprietary", "MIT", "Apache-2.0", "GPL-3.0-only", "GPL-3.0-or-later"] }, + "sourceCode": { + "$ref": "#/$defs/sourceCode", + "description": "Optional override describing how the linked GitHub repository relates to this product surface. When omitted, non-proprietary products are treated as open source and proprietary products as closed source feedback repositories." + }, "pricing": { "type": "array", "description": "Pricing tiers for the product (can be empty array if not applicable, typically found on example.com/pricing or example.com/plans page)", @@ -66,6 +70,28 @@ } ], "$defs": { + "sourceCode": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["open", "partial", "closed"], + "description": "Whether the linked repository contains all, part, or none of this product surface's source code" + }, + "repositoryRole": { + "type": "string", + "enum": ["source", "feedback", "documentation"], + "description": "The linked repository's relationship to this product surface" + }, + "license": { + "type": "string", + "minLength": 1, + "description": "Repository source license when it differs from the product license" + } + }, + "required": ["status", "repositoryRole"], + "additionalProperties": false + }, "releaseTracking": { "oneOf": [ { diff --git a/manifests/clis/codex-cli.json b/manifests/clis/codex-cli.json index 084ddc4d..c61b3ca0 100644 --- a/manifests/clis/codex-cli.json +++ b/manifests/clis/codex-cli.json @@ -53,6 +53,10 @@ }, "githubUrl": "https://github.com/openai/codex", "license": "Apache-2.0", + "sourceCode": { + "status": "open", + "repositoryRole": "source" + }, "pricing": [ { "name": "ChatGPT Free", @@ -139,7 +143,7 @@ { "url": "https://github.com/openai/codex", "title": "Codex CLI github community", - "fields": ["communityUrls.github"] + "fields": ["githubUrl", "license", "sourceCode", "communityUrls.github"] }, { "url": "https://openai.com/news", diff --git a/manifests/desktops/codex-app.json b/manifests/desktops/codex-app.json index bc23394c..6d11e57e 100644 --- a/manifests/desktops/codex-app.json +++ b/manifests/desktops/codex-app.json @@ -48,7 +48,7 @@ { "url": "https://learn.chatgpt.com/docs/open-source", "title": "Codex open-source components", - "fields": ["githubUrl", "license"] + "fields": ["githubUrl", "license", "sourceCode"] }, { "url": "https://github.com/openai/codex", @@ -74,6 +74,10 @@ "latestVersion": "Latest", "githubUrl": "https://github.com/openai/codex", "license": "Proprietary", + "sourceCode": { + "status": "closed", + "repositoryRole": "feedback" + }, "pricing": [ { "name": "ChatGPT Plus", diff --git a/manifests/desktops/verdent-deck.json b/manifests/desktops/verdent-deck.json index c1066c25..f346f91a 100644 --- a/manifests/desktops/verdent-deck.json +++ b/manifests/desktops/verdent-deck.json @@ -45,6 +45,11 @@ "url": "https://www.verdent.ai/docs/verdent/getting-started/installation", "title": "Verdent installation" }, + { + "url": "https://github.com/verdentAI/docs", + "title": "Verdent documentation repository", + "fields": ["githubUrl", "sourceCode"] + }, { "url": "https://www.verdent.ai/pricing", "title": "Verdent pricing", @@ -64,6 +69,10 @@ "latestVersion": "1.5.0", "githubUrl": "https://github.com/verdentAI/docs", "license": "Proprietary", + "sourceCode": { + "status": "closed", + "repositoryRole": "documentation" + }, "pricing": [ { "name": "Lite", diff --git a/manifests/extensions/codex.json b/manifests/extensions/codex.json index f17cad77..924bac22 100644 --- a/manifests/extensions/codex.json +++ b/manifests/extensions/codex.json @@ -49,7 +49,7 @@ { "url": "https://learn.chatgpt.com/docs/open-source", "title": "Codex open-source components", - "fields": ["githubUrl", "license", "resourceUrls.issue"] + "fields": ["githubUrl", "license", "sourceCode", "resourceUrls.issue"] }, { "url": "https://github.com/openai/codex", @@ -80,6 +80,10 @@ }, "githubUrl": "https://github.com/openai/codex", "license": "Proprietary", + "sourceCode": { + "status": "closed", + "repositoryRole": "feedback" + }, "pricing": [ { "name": "ChatGPT Plus", diff --git a/manifests/extensions/verdent.json b/manifests/extensions/verdent.json index 72f221cf..62424412 100644 --- a/manifests/extensions/verdent.json +++ b/manifests/extensions/verdent.json @@ -49,6 +49,10 @@ "latestVersion": "1.5.5", "githubUrl": "https://github.com/verdentAI/docs", "license": "Proprietary", + "sourceCode": { + "status": "closed", + "repositoryRole": "documentation" + }, "pricing": [ { "name": "Lite", @@ -122,6 +126,11 @@ } ], "sources": [ + { + "url": "https://github.com/verdentAI/docs", + "title": "Verdent documentation repository", + "fields": ["githubUrl", "sourceCode"] + }, { "url": "https://www.linkedin.com/company/verdent", "title": "Verdent linkedin community", diff --git a/manifests/ides/intellij-idea.json b/manifests/ides/intellij-idea.json index e1b72326..ff2a91bc 100644 --- a/manifests/ides/intellij-idea.json +++ b/manifests/ides/intellij-idea.json @@ -48,6 +48,11 @@ "latestVersion": "2024.3", "githubUrl": "https://github.com/JetBrains/intellij-community", "license": "Proprietary", + "sourceCode": { + "status": "partial", + "repositoryRole": "source", + "license": "Apache-2.0" + }, "pricing": [ { "name": "Community", @@ -116,7 +121,7 @@ { "url": "https://github.com/JetBrains/intellij-community", "title": "IntelliJ IDEA github community", - "fields": ["communityUrls.github"] + "fields": ["githubUrl", "sourceCode", "communityUrls.github"] }, { "url": "https://www.reddit.com/r/IntelliJIDEA", diff --git a/manifests/ides/vscode.json b/manifests/ides/vscode.json index bb69d2bc..54d2d757 100644 --- a/manifests/ides/vscode.json +++ b/manifests/ides/vscode.json @@ -48,6 +48,10 @@ "latestVersion": "1.131", "githubUrl": "https://github.com/microsoft/vscode", "license": "Proprietary", + "sourceCode": { + "status": "partial", + "repositoryRole": "source" + }, "pricing": [ { "name": "Free", @@ -127,7 +131,7 @@ { "url": "https://github.com/microsoft/vscode", "title": "Visual Studio Code source repository", - "fields": ["githubUrl", "resourceUrls.issue", "communityUrls.github"] + "fields": ["githubUrl", "sourceCode", "resourceUrls.issue", "communityUrls.github"] }, { "url": "https://x.com/code", diff --git a/scripts/fetch/fetch-github-stars.ts b/scripts/fetch/fetch-github-stars.ts index 58833a6f..a855b089 100644 --- a/scripts/fetch/fetch-github-stars.ts +++ b/scripts/fetch/fetch-github-stars.ts @@ -3,308 +3,82 @@ import https from 'node:https' import path, { dirname } from 'node:path' import { fileURLToPath } from 'node:url' -// Get __dirname equivalent in ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) - -// GitHub API token (optional but recommended to avoid rate limits) -// Set via environment variable: GITHUB_TOKEN=your_token_here node fetch-github-stars.ts const GITHUB_TOKEN = process.env.GITHUB_TOKEN - -// Path to the centralized GitHub stars data file const GITHUB_STARS_FILE = path.join(__dirname, '..', '..', 'data', 'github-stars.json') -interface DirConfig { - directory: string - category: string -} - -// Directories configuration - mapping manifest directories to categories -const dirsConfig: DirConfig[] = [ - { - directory: 'manifests/extensions', - category: 'extensions', - }, - { - directory: 'manifests/ides', - category: 'ides', - }, - { - directory: 'manifests/clis', - category: 'clis', - }, - { - directory: 'manifests/desktops', - category: 'desktops', - }, -] - -interface GithubRepo { - owner: string - repo: string -} - -interface ProcessResult { - fileId: string - stars: number | null - updated: boolean - skipped: boolean - error: boolean -} - -interface DirectoryResult { - categoryData: Record - stats: { - updated: number - skipped: number - errors: number - } -} - -interface StarsData { - extensions: Record - clis: Record - desktops: Record - ides: Record - [key: string]: Record +type StarsData = { + observedAt: string + repositories: Record } -// Extract owner and repo from GitHub URL -function parseGithubUrl(url: string): GithubRepo | null { - if (!url) return null - const match = url.match(/github\.com\/([^/]+)\/([^/]+)/) - if (!match || match.length < 3) return null - return { - owner: match[1] ?? '', - repo: match[2] ?? '', - } -} - -// Fetch stars from GitHub API -function fetchStars(owner: string, repo: string): Promise { +function fetchStars(repositoryId: string): Promise { return new Promise((resolve, reject) => { const options: https.RequestOptions = { hostname: 'api.github.com', - path: `/repos/${owner}/${repo}`, + path: `/repos/${repositoryId}`, method: 'GET', headers: { - 'User-Agent': 'acs-stars-fetcher', - Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'aicodingstack-stars-fetcher', + Accept: 'application/vnd.github+json', + ...(GITHUB_TOKEN ? { Authorization: `Bearer ${GITHUB_TOKEN}` } : {}), }, } - if (GITHUB_TOKEN) { - options.headers = { - ...options.headers, - Authorization: `token ${GITHUB_TOKEN}`, - } - } - - const req = https.request(options, res => { - let data = '' - - res.on('data', chunk => { - data += chunk + const request = https.request(options, response => { + let body = '' + response.on('data', chunk => { + body += chunk }) - - res.on('end', () => { - if (res.statusCode === 200) { - try { - const json = JSON.parse(data) - const stars = json.stargazers_count - // Convert to k format (1 decimal place) - const starsInK = parseFloat((stars / 1000).toFixed(1)) - resolve(starsInK) - } catch (e) { - reject(new Error(`Failed to parse response: ${(e as Error).message}`)) + response.on('end', () => { + if (response.statusCode !== 200) { + reject(new Error(`GitHub API returned status ${String(response.statusCode)}`)) + return + } + try { + const payload = JSON.parse(body) as { stargazers_count?: unknown } + if (!Number.isInteger(payload.stargazers_count)) { + reject(new Error('GitHub response did not include an integer stargazer count')) + return } - } else if (res.statusCode === 403) { - reject(new Error('Rate limit exceeded. Please set GITHUB_TOKEN environment variable.')) - } else if (res.statusCode === 404) { - reject(new Error('Repository not found')) - } else { - reject(new Error(`GitHub API returned status ${res.statusCode}`)) + resolve(payload.stargazers_count as number) + } catch (error) { + reject(new Error(`Failed to parse GitHub response: ${(error as Error).message}`)) } }) }) - req.on('error', e => { - reject(e) - }) - - req.end() + request.on('error', reject) + request.end() }) } -// Extract file ID from filename (remove .json extension) -function getFileId(fileName: string): string { - return fileName.replace(/\.json$/, '') -} - -// Sleep function to avoid rate limiting -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -// Process a single JSON file -// Returns the file ID (from filename) and stars count (or null if no githubUrl or error) -async function processFile(filePath: string, fileName: string): Promise { - const fileId = getFileId(fileName) - const content = fs.readFileSync(filePath, 'utf8') - const item = JSON.parse(content) - - // Get githubUrl field from the item - const githubUrl = item.githubUrl - - // If githubUrl is null, set stars to null in the output - if (!githubUrl || githubUrl === null) { - console.log(` ⏭️ ${fileId}: githubUrl is null, setting stars to null`) - return { fileId, stars: null, updated: false, skipped: true, error: false } - } - - const parsed = parseGithubUrl(githubUrl) - if (!parsed) { - console.log(` ❌ ${fileId}: Failed to parse GitHub URL: ${githubUrl}`) - return { fileId, stars: null, updated: false, skipped: false, error: true } - } - - try { - console.log(` 🔍 ${fileId}: Fetching stars for ${parsed.owner}/${parsed.repo}...`) - const stars = await fetchStars(parsed.owner, parsed.repo) - console.log(` ✅ ${fileId}: ${stars}k stars`) - - // Sleep for 1 second to avoid rate limiting - await sleep(1000) - return { fileId, stars, updated: true, skipped: false, error: false } - } catch (error) { - console.log(` ❌ ${fileId}: Error fetching stars:`, (error as Error).message) - return { fileId, stars: null, updated: false, skipped: false, error: true } - } -} - -// Process all files in a directory -// Maps file names (without .json) to stars data based on githubUrl field -async function processDirectory( - dirConfig: DirConfig, - existingCategoryData: Record -): Promise { - const dirPath = path.join(__dirname, '..', '..', dirConfig.directory) - console.log(`\n📁 Processing ${dirConfig.directory}...`) - - if (!fs.existsSync(dirPath)) { - console.log(` ⚠️ Directory not found: ${dirPath}`) - return { categoryData: {}, stats: { updated: 0, skipped: 0, errors: 0 } } - } - - // Get all JSON files in the directory - const files = fs.readdirSync(dirPath).filter(file => file.endsWith('.json')) - - if (files.length === 0) { - console.log(` ⚠️ No JSON files found in ${dirConfig.directory}`) - return { categoryData: {}, stats: { updated: 0, skipped: 0, errors: 0 } } - } - - let updated = 0 - let skipped = 0 - let errors = 0 - const categoryData: Record = {} - - // Process each file and map by filename (without .json extension) - for (const file of files) { - const filePath = path.join(dirPath, file) - const result = await processFile(filePath, file) - - if (result.updated) updated++ - if (result.skipped) skipped++ - if (result.error) errors++ - - // Map file ID (filename without .json) to stars value (can be null) - categoryData[result.fileId] = result.error - ? (existingCategoryData[result.fileId] ?? null) - : result.stars - } - - console.log( - `\n✨ ${dirConfig.directory} completed: ${updated} updated, ${skipped} skipped, ${errors} errors` - ) - return { categoryData, stats: { updated, skipped, errors } } -} - -// Main function async function main(): Promise { - console.log('🚀 Starting GitHub stars fetcher...\n') - console.log('📝 Note: Updating centralized github-stars.json file\n') - - if (!GITHUB_TOKEN) { - console.log('⚠️ Warning: No GITHUB_TOKEN set. You may hit rate limits (60 requests/hour).') - console.log(' Set it with: GITHUB_TOKEN=your_token node fetch-github-stars.ts\n') - } else { - console.log('✅ Using GitHub token for authentication\n') - } - - // Load existing stars data or create new structure - let starsData: StarsData = { extensions: {}, clis: {}, desktops: {}, ides: {} } - if (fs.existsSync(GITHUB_STARS_FILE)) { - try { - const content = fs.readFileSync(GITHUB_STARS_FILE, 'utf8') - starsData = JSON.parse(content) as StarsData - console.log('📂 Loaded existing github-stars.json\n') - } catch { - console.log('⚠️ Failed to parse existing github-stars.json, creating new one\n') - } - } - - let totalUpdated = 0 - let totalSkipped = 0 - let totalErrors = 0 + const current = JSON.parse(fs.readFileSync(GITHUB_STARS_FILE, 'utf8')) as StarsData + let updated = 0 + let failed = 0 - // Process each directory and collect stars data - // Maps file names to stars based on githubUrl field in each manifest file - for (const dirConfig of dirsConfig) { + for (const repositoryId of Object.keys(current.repositories).sort()) { try { - const { categoryData, stats } = await processDirectory( - dirConfig, - starsData[dirConfig.category] ?? {} - ) - - // Sort the category data by key (alphabetically) - const sortedCategoryData = Object.keys(categoryData) - .sort() - .reduce>((acc, key) => { - acc[key] = categoryData[key] ?? null - return acc - }, {}) - - // Update the stars data for this category - // This will include all files, with null values for items without githubUrl - starsData[dirConfig.category] = sortedCategoryData - - totalUpdated += stats.updated - totalSkipped += stats.skipped - totalErrors += stats.errors + const stars = await fetchStars(repositoryId) + current.repositories[repositoryId] = stars + updated += 1 + console.log(`✓ ${repositoryId}: ${stars.toLocaleString('en-US')}`) } catch (error) { - console.error(`❌ Failed to process ${dirConfig.directory}:`, (error as Error).message) - totalErrors++ + failed += 1 + console.error(`✗ ${repositoryId}: ${(error as Error).message}`) } } - // Write the updated stars data to file - try { - fs.writeFileSync(GITHUB_STARS_FILE, `${JSON.stringify(starsData, null, 2)}\n`, 'utf8') - console.log('\n📝 Successfully updated data/github-stars.json') - } catch (error) { - console.error('\n❌ Failed to write github-stars.json:', (error as Error).message) - process.exit(1) - } + current.observedAt = new Date().toISOString().slice(0, 10) + fs.writeFileSync(GITHUB_STARS_FILE, `${JSON.stringify(current, null, 2)}\n`, 'utf8') + console.log(`Updated ${updated} repositories; ${failed} retained their previous values.`) - console.log(`\n${'='.repeat(50)}`) - console.log('🎉 All directories processed!') - console.log(`📊 Total: ${totalUpdated} updated, ${totalSkipped} skipped, ${totalErrors} errors`) - console.log('='.repeat(50)) + if (failed > 0) process.exitCode = 1 } -// Run the script main().catch(error => { - console.error('Fatal error:', error) + console.error(error) process.exit(1) }) diff --git a/scripts/generate/generate-manifest-indexes.ts b/scripts/generate/generate-manifest-indexes.ts index 10b7b36d..d3015b64 100644 --- a/scripts/generate/generate-manifest-indexes.ts +++ b/scripts/generate/generate-manifest-indexes.ts @@ -207,18 +207,26 @@ function generateGithubStarsFile(): void { import githubStarsJson from '../../../data/github-stars.json' -export type GithubStarsData = Record> +export interface GithubStarsData { + observedAt: string + repositories: Record +} export const githubStarsData = githubStarsJson as GithubStarsData /** * Get GitHub stars for a specific product - * @param category - The product category (extensions, clis, desktops, ides) - * @param id - The product ID + * @param githubUrl - The product's GitHub repository URL * @returns The number of stars (in thousands) or null if not available */ -export function getGithubStars(category: string, id: string): number | null { - return githubStarsData[category]?.[id] ?? null +export function getGithubStars(githubUrl: string | null | undefined): number | null { + if (!githubUrl) return null + const repositoryId = githubUrl + .replace(/\\/$/, '') + .replace(/\\.git$/, '') + .replace(/^https:\\/\\/github\\.com\\//, '') + const stars = githubStarsData.repositories[repositoryId] + return typeof stars === 'number' ? Math.round(stars / 100) / 10 : null } export default githubStarsData @@ -228,9 +236,7 @@ export default githubStarsData fs.writeFileSync(outputPath, content, 'utf8') // Count total entries - const totalEntries = Object.values(starsData).reduce((sum: number, category: unknown) => { - return sum + Object.keys(category as Record).length - }, 0) + const totalEntries = Object.keys(starsData.repositories as Record).length console.log(`✓ Generated github-stars.ts (${totalEntries} entries)`) } diff --git a/src/app/[locale]/clis/[slug]/page.tsx b/src/app/[locale]/clis/[slug]/page.tsx index e0b13298..95c41f0f 100644 --- a/src/app/[locale]/clis/[slug]/page.tsx +++ b/src/app/[locale]/clis/[slug]/page.tsx @@ -118,7 +118,7 @@ export default async function CLIPage({ deprecated={cli.deprecated ?? false} latestVersion={cli.latestVersion} license={cli.license} - githubStars={getGithubStars('clis', cli.id)} + githubStars={getGithubStars(cli.githubUrl)} platforms={cli.platforms?.map(p => p.os)} websiteUrl={websiteUrl} docsUrl={docsUrl} diff --git a/src/app/[locale]/clis/comparison/page.client.tsx b/src/app/[locale]/clis/comparison/page.client.tsx index 75bdf602..09c1a7db 100644 --- a/src/app/[locale]/clis/comparison/page.client.tsx +++ b/src/app/[locale]/clis/comparison/page.client.tsx @@ -98,9 +98,8 @@ export default function CLIComparisonPageClient({ locale: _locale }: Props) { key: 'githubStars', label: tShared('terms.stars'), render: (_: unknown, item: Record) => { - const id = item.id as string - const stars = getGithubStars('clis', id) const githubUrl = item.githubUrl as string | null | undefined + const stars = getGithubStars(githubUrl) if (stars === null || stars === undefined) return - diff --git a/src/app/[locale]/desktops/[slug]/page.tsx b/src/app/[locale]/desktops/[slug]/page.tsx index f2712f8b..dfcabdd2 100644 --- a/src/app/[locale]/desktops/[slug]/page.tsx +++ b/src/app/[locale]/desktops/[slug]/page.tsx @@ -97,7 +97,7 @@ export default async function DesktopPage({ verified={desktop.verified ?? false} latestVersion={desktop.latestVersion} license={desktop.license} - githubStars={getGithubStars('desktops', desktop.id)} + githubStars={getGithubStars(desktop.githubUrl)} platforms={desktop.platforms.map(platform => platform.os)} websiteUrl={websiteUrl} docsUrl={desktop.docsUrl || undefined} diff --git a/src/app/[locale]/extensions/[slug]/page.tsx b/src/app/[locale]/extensions/[slug]/page.tsx index 58f331dd..d3eacfe6 100644 --- a/src/app/[locale]/extensions/[slug]/page.tsx +++ b/src/app/[locale]/extensions/[slug]/page.tsx @@ -137,7 +137,7 @@ export default async function ExtensionPage({ verified={extension.verified ?? false} latestVersion={extension.latestVersion} license={extension.license} - githubStars={getGithubStars('extensions', extension.id)} + githubStars={getGithubStars(extension.githubUrl)} additionalInfo={additionalInfo} websiteUrl={websiteUrl} docsUrl={docsUrl} diff --git a/src/app/[locale]/extensions/comparison/page.client.tsx b/src/app/[locale]/extensions/comparison/page.client.tsx index ef51f5b4..5678bd29 100644 --- a/src/app/[locale]/extensions/comparison/page.client.tsx +++ b/src/app/[locale]/extensions/comparison/page.client.tsx @@ -84,9 +84,8 @@ export default function ExtensionComparisonPageClient({ locale: _locale }: Props key: 'githubStars', label: tShared('terms.stars'), render: (_: unknown, item: Record) => { - const id = item.id as string - const stars = getGithubStars('extensions', id) const githubUrl = item.githubUrl as string | null | undefined + const stars = getGithubStars(githubUrl) if (stars === null || stars === undefined) return - diff --git a/src/app/[locale]/ides/[slug]/page.tsx b/src/app/[locale]/ides/[slug]/page.tsx index 8d6a6bf1..9e0c1e59 100644 --- a/src/app/[locale]/ides/[slug]/page.tsx +++ b/src/app/[locale]/ides/[slug]/page.tsx @@ -117,7 +117,7 @@ export default async function IDEPage({ verified={ide.verified ?? false} latestVersion={ide.latestVersion} license={ide.license} - githubStars={getGithubStars('ides', ide.id)} + githubStars={getGithubStars(ide.githubUrl)} platforms={ide.platforms?.map(p => p.os)} websiteUrl={websiteUrl} docsUrl={docsUrl} diff --git a/src/app/[locale]/ides/comparison/page.client.tsx b/src/app/[locale]/ides/comparison/page.client.tsx index 3230524a..c5be0d91 100644 --- a/src/app/[locale]/ides/comparison/page.client.tsx +++ b/src/app/[locale]/ides/comparison/page.client.tsx @@ -98,9 +98,8 @@ export default function IDEComparisonPageClient({ locale: _locale }: Props) { key: 'githubStars', label: tShared('terms.stars'), render: (_: unknown, item: Record) => { - const id = item.id as string - const stars = getGithubStars('ides', id) const githubUrl = item.githubUrl as string | null | undefined + const stars = getGithubStars(githubUrl) if (stars === null || stars === undefined) return - diff --git a/src/app/[locale]/open-source-rank/page.client.tsx b/src/app/[locale]/open-source-rank/page.client.tsx index 3db108c1..746c7fcd 100644 --- a/src/app/[locale]/open-source-rank/page.client.tsx +++ b/src/app/[locale]/open-source-rank/page.client.tsx @@ -1,5 +1,6 @@ 'use client' +import { CircleDashed, LockKeyhole } from 'lucide-react' import { useTranslations } from 'next-intl' import { useMemo, useState } from 'react' import { DeprecatedBadge } from '@/components/controls/DeprecatedBadge' @@ -10,227 +11,207 @@ import { desktopsData } from '@/lib/generated/desktops' import { extensionsData } from '@/lib/generated/extensions' import { githubStarsData } from '@/lib/generated/github-stars' import { idesData } from '@/lib/generated/ides' +import type { ManifestBaseProduct } from '@/types/manifests' type ProductType = 'ide' | 'cli' | 'desktop' | 'extension' +type RepositoryScope = 'source' | 'partial' | 'related' -type OpenSourceProject = { +type CatalogProduct = ManifestBaseProduct & { id: string name: string - type: ProductType - license: string - stars: number - githubUrl: string | null - websiteUrl: string | null verified?: boolean deprecated?: boolean } -function getLicenseDisplayName(license: string): string { - if (license === 'Proprietary') return 'Proprietary' - if (license === 'Unknown') return 'Unknown' - return license +type RankedProduct = { + id: string + name: string + catalogSurfaces: Array<{ + type: ProductType + id: string + repositoryScope: RepositoryScope + product: CatalogProduct + }> } -function getProductTypeName(type: ProductType, t: (key: string) => string): string { - switch (type) { - case 'ide': - return t('categories.singular.ide') - case 'cli': - return t('categories.singular.cli') - case 'extension': - return t('categories.singular.extension') - case 'desktop': - return t('categories.singular.desktop') - default: - return type - } +type RepositoryProject = { + repositoryId: string + stars: number + products: RankedProduct[] + types: ProductType[] + license: string + openSource: boolean + verified: boolean + deprecated: boolean } -export function OpenSourceRankPage() { - const tPage = useTranslations('pages.openSourceRank') - const tShared = useTranslations('shared') - const [selectedType, setSelectedType] = useState('all') - - const { openSourceProjects, proprietaryProjects } = useMemo(() => { - const openSource: OpenSourceProject[] = [] - const proprietary: OpenSourceProject[] = [] - - // Process IDEs - idesData.forEach(ide => { - const stars = githubStarsData.ides?.[ide.id] ?? null - const hasStars = stars !== null && stars > 0 - const isProprietary = ide.license === 'Proprietary' - const isOpenSource = ide.license && ide.license !== 'Proprietary' - - // Only include Proprietary projects if they have stars - if (isProprietary && !hasStars) { - return // Skip Proprietary projects without stars - } - - const project = { - id: ide.id, - name: ide.name, - type: 'ide' as ProductType, - license: ide.license || 'Unknown', - stars: stars || 0, - githubUrl: ide.githubUrl || null, - websiteUrl: ide.websiteUrl || null, - verified: ide.verified ?? false, - deprecated: ide.deprecated ?? false, - } +const catalogByType: Record> = { + ide: new Map(idesData.map(product => [product.id, product as CatalogProduct])), + cli: new Map(clisData.map(product => [product.id, product as CatalogProduct])), + desktop: new Map(desktopsData.map(product => [product.id, product as CatalogProduct])), + extension: new Map(extensionsData.map(product => [product.id, product as CatalogProduct])), +} - if (isProprietary && hasStars) { - proprietary.push(project) - } else if (isOpenSource || hasStars) { - openSource.push(project) - } - }) +const typeOrder: ProductType[] = ['ide', 'cli', 'desktop', 'extension'] + +function productPath(type: ProductType, id: string): string { + const segment = + type === 'ide' + ? 'ides' + : type === 'cli' + ? 'clis' + : type === 'desktop' + ? 'desktops' + : 'extensions' + return `/${segment}/${id}` +} - // Process CLIs - clisData.forEach(cli => { - const stars = githubStarsData.clis?.[cli.id] ?? null - const hasStars = stars !== null && stars > 0 - const isProprietary = cli.license === 'Proprietary' - const isOpenSource = cli.license && cli.license !== 'Proprietary' +function getProductTypeName(type: ProductType, t: (key: string) => string): string { + return t(`categories.singular.${type}`) +} - // Only include Proprietary projects if they have stars - if (isProprietary && !hasStars) { - return // Skip Proprietary projects without stars - } +function getProductTypePluralName(type: ProductType, t: (key: string) => string): string { + const key = + type === 'ide' + ? 'ides' + : type === 'cli' + ? 'clis' + : type === 'desktop' + ? 'desktops' + : 'extensions' + return t(`categories.plural.${key}`) +} - const project = { - id: cli.id, - name: cli.name, - type: 'cli' as ProductType, - license: cli.license || 'Unknown', - stars: stars || 0, - githubUrl: cli.githubUrl || null, - websiteUrl: cli.websiteUrl || null, - verified: cli.verified ?? false, - deprecated: cli.deprecated ?? false, - } +function getLicenseDisplayName(license: string): string { + if (license === 'Proprietary') return 'Proprietary' + if (license === 'Unknown') return 'Unknown' + return license +} - if (isProprietary && hasStars) { - proprietary.push(project) - } else if (isOpenSource || hasStars) { - openSource.push(project) - } - }) +function normalizeRepositoryUrl(url: string): string { + return url.replace(/\/$/, '').replace(/\.git$/, '') +} - desktopsData.forEach(desktop => { - const stars = githubStarsData.desktops?.[desktop.id] ?? null - const hasStars = stars !== null && stars > 0 - const isProprietary = desktop.license === 'Proprietary' - if (isProprietary && !hasStars) return - - const project = { - id: desktop.id, - name: desktop.name, - type: 'desktop' as ProductType, - license: desktop.license || 'Unknown', - stars: stars || 0, - githubUrl: desktop.githubUrl || null, - websiteUrl: desktop.websiteUrl || null, - verified: desktop.verified ?? false, - deprecated: desktop.deprecated ?? false, - } +function getRepositoryScope(product: CatalogProduct): RepositoryScope { + if (product.sourceCode?.status === 'open') return 'source' + if (product.sourceCode?.status === 'partial') return 'partial' + if (product.sourceCode?.status === 'closed') return 'related' + return product.license === 'Proprietary' ? 'related' : 'source' +} - if (isProprietary && hasStars) proprietary.push(project) - else openSource.push(project) - }) +function getRepositoryRole(product: CatalogProduct): 'source' | 'feedback' | 'documentation' { + if (product.sourceCode?.repositoryRole) return product.sourceCode.repositoryRole + return getRepositoryScope(product) === 'related' ? 'feedback' : 'source' +} - // Process Extensions - extensionsData.forEach(ext => { - const stars = githubStarsData.extensions?.[ext.id] ?? null - const hasStars = stars !== null && stars > 0 - const isProprietary = ext.license === 'Proprietary' - const isOpenSource = ext.license && ext.license !== 'Proprietary' +function getFamilyName(surfaces: RankedProduct['catalogSurfaces']): string { + const suffixPattern = /(?:\s+(?:CLI|Desktop|Extension|IDE)|\s+for VS Code)$/i + const names = surfaces + .map(surface => surface.product.name.replace(suffixPattern, '')) + .sort((a, b) => a.length - b.length || a.localeCompare(b)) + return names[0] ?? surfaces[0]?.product.name ?? '' +} - // Only include Proprietary projects if they have stars - if (isProprietary && !hasStars) { - return // Skip Proprietary projects without stars - } +function buildRepositoryProjects(): RepositoryProject[] { + return Object.entries(githubStarsData.repositories) + .map(([repositoryId, stars]) => { + const expectedUrl = `https://github.com/${repositoryId}` + const matchedSurfaces = typeOrder.flatMap(type => + Array.from(catalogByType[type].values()).flatMap(product => + product.githubUrl && normalizeRepositoryUrl(product.githubUrl) === expectedUrl + ? [ + { + type, + id: product.id, + repositoryScope: getRepositoryScope(product), + product, + }, + ] + : [] + ) + ) - const project = { - id: ext.id, - name: ext.name, - type: 'extension' as ProductType, - license: ext.license || 'Unknown', - stars: stars || 0, - githubUrl: ext.githubUrl || null, - websiteUrl: ext.websiteUrl || null, - verified: ext.verified ?? false, - deprecated: ext.deprecated ?? false, + const productGroups = new Map() + for (const surface of matchedSurfaces) { + const groupId = surface.product.familyId ?? repositoryId + productGroups.set(groupId, [...(productGroups.get(groupId) ?? []), surface]) } - - if (isProprietary && hasStars) { - proprietary.push(project) - } else if (isOpenSource || hasStars) { - openSource.push(project) + const products = Array.from(productGroups, ([id, surfaces]) => ({ + id, + name: getFamilyName(surfaces), + catalogSurfaces: surfaces, + })) + const catalogSurfaces = products.flatMap(product => product.catalogSurfaces) + const types = typeOrder.filter(type => catalogSurfaces.some(surface => surface.type === type)) + const openSource = catalogSurfaces.some( + surface => + getRepositoryRole(surface.product) === 'source' && surface.repositoryScope !== 'related' + ) + const repositoryLicense = catalogSurfaces.find(surface => surface.product.sourceCode?.license) + ?.product.sourceCode?.license + const openSourceLicense = catalogSurfaces.find( + surface => + surface.repositoryScope !== 'related' && + surface.product.license && + surface.product.license !== 'Proprietary' + )?.product.license + + return { + repositoryId, + stars: stars ?? 0, + products, + types, + license: openSource ? (repositoryLicense ?? openSourceLicense ?? 'Unknown') : 'Proprietary', + openSource, + verified: catalogSurfaces.some(surface => surface.product.verified), + deprecated: + catalogSurfaces.length > 0 && + catalogSurfaces.every(surface => surface.product.deprecated === true), } }) + .sort((a, b) => b.stars - a.stars || a.repositoryId.localeCompare(b.repositoryId)) +} - // Merge projects with the same GitHub URL, keeping the one with shorter name - const mergeByGitHubUrl = (projects: OpenSourceProject[]): OpenSourceProject[] => { - const urlMap = new Map() - - projects.forEach(project => { - if (!project.githubUrl) { - // Keep projects without GitHub URL as-is - urlMap.set(`no-url-${project.id}`, project) - return - } - - const existing = urlMap.get(project.githubUrl) - if (!existing) { - urlMap.set(project.githubUrl, project) - } else { - // Keep the one with shorter name - if (project.name.length < existing.name.length) { - urlMap.set(project.githubUrl, project) - } - } - }) - - return Array.from(urlMap.values()) - } - - // Merge first, then sort by stars (descending) - const mergedOpenSource = mergeByGitHubUrl(openSource) - const mergedProprietary = mergeByGitHubUrl(proprietary) +export function OpenSourceRankPage() { + const tPage = useTranslations('pages.openSourceRank') + const tShared = useTranslations('shared') + const [selectedType, setSelectedType] = useState('all') - mergedOpenSource.sort((a, b) => b.stars - a.stars) - mergedProprietary.sort((a, b) => b.stars - a.stars) + const repositories = useMemo(() => buildRepositoryProjects(), []) + const openSourceProjects = useMemo( + () => repositories.filter(repository => repository.openSource), + [repositories] + ) + const proprietaryProjects = useMemo( + () => repositories.filter(repository => !repository.openSource), + [repositories] + ) - return { - openSourceProjects: mergedOpenSource, - proprietaryProjects: mergedProprietary, - } - }, []) + const filterProjects = (projects: RepositoryProject[]) => + selectedType === 'all' + ? projects + : projects.filter(project => project.types.includes(selectedType)) - const filteredOpenSourceProjects = useMemo(() => { - if (selectedType === 'all') return openSourceProjects - return openSourceProjects.filter(p => p.type === selectedType) - }, [openSourceProjects, selectedType]) + const filteredOpenSourceProjects = filterProjects(openSourceProjects) + const filteredProprietaryProjects = filterProjects(proprietaryProjects) + const filterOptions: Array = ['desktop', 'all', 'ide', 'cli', 'extension'] - const filteredProprietaryProjects = useMemo(() => { - if (selectedType === 'all') return proprietaryProjects - return proprietaryProjects.filter(p => p.type === selectedType) - }, [proprietaryProjects, selectedType]) + const getFilterCount = (type: ProductType | 'all') => + type === 'all' + ? repositories.length + : repositories.filter(project => project.types.includes(type)).length const stats = useMemo(() => { - const total = openSourceProjects.length + proprietaryProjects.length + const total = repositories.length const openSourcePercentage = total > 0 ? Math.round((openSourceProjects.length / total) * 100) : 0 const proprietaryPercentage = 100 - openSourcePercentage - - // Group by license const licenseGroups: Record = {} + openSourceProjects.forEach(project => { licenseGroups[project.license] = (licenseGroups[project.license] || 0) + 1 }) - // Calculate license percentages const licenseStats = Object.entries(licenseGroups) .map(([license, count]) => ({ license, @@ -247,215 +228,170 @@ export function OpenSourceRankPage() { proprietaryPercentage, licenseStats, } - }, [openSourceProjects, proprietaryProjects]) - - return ( -
- {/* Filter Section */} -
- - - - - -
- - {/* Render table function */} - {(() => { - const renderTable = (projects: OpenSourceProject[], title: string) => ( -
-

{title}

-
- - - - - - - - - - - - {projects.map((project, index) => ( - - - + + + + ))} + +
- {tPage('table.rank')} - - {tShared('labels.name')} - - {tShared('terms.type')} - - {tShared('terms.license')} - - {tShared('terms.stars')} -
- #{index + 1} - -
+ }, [repositories, openSourceProjects, proprietaryProjects]) + + const renderTable = (projects: RepositoryProject[], title: string) => ( +
+

{title}

+
+ + + + + + + + + + + + {projects.map((repository, index) => ( + + + + - - - - - ))} - -
+ {tPage('table.rank')} + + {tShared('labels.name')} + + {tShared('terms.type')} + + {tShared('terms.license')} + + {tShared('terms.stars')} +
+ #{index + 1} + +
+ {repository.products.map(product => { + const primarySurface = product.catalogSurfaces[0] + return primarySurface ? ( + + {product.name} + + ) : ( + + {product.name} + + ) + })} + {repository.verified && } + {repository.deprecated && } +
+
+
+ {repository.products.flatMap(product => + product.catalogSurfaces.map(surface => { + const scopeDescription = tPage(`repositoryScope.${surface.repositoryScope}`) + const scopeClass = + surface.repositoryScope === 'source' + ? 'border-[var(--color-border)]' + : surface.repositoryScope === 'partial' + ? 'border-dashed border-[var(--color-border-strong)]' + : 'border-dashed border-[var(--color-border)] text-[var(--color-text-secondary)]' + + return ( - {project.name} + {surface.repositoryScope === 'partial' && ( +
-
- - {getProductTypeName(project.type, tShared)} - - - {getLicenseDisplayName(project.license)} - - {project.githubUrl ? ( - - - {project.stars.toFixed(1)}k - - ) : ( -
- - {project.stars.toFixed(1)}k -
- )} -
- - {projects.length === 0 && ( -
- {tPage('noResults')} -
- )} -
+ ) + }) + )} +
+
+ {getLicenseDisplayName(repository.license)} + + product.name).join(', ')} GitHub repository - ${(repository.stars / 1000).toFixed(1)}k stars`} + > + + {(repository.stars / 1000).toFixed(1)}k + +
+ {projects.length === 0 && ( +
+ {tPage('noResults')}
- ) + )} +
+
+ ) - return ( - <> - {/* Open Source Projects Table */} - {renderTable( - filteredOpenSourceProjects, - tPage('table.openSourceTitle', { count: filteredOpenSourceProjects.length }) || - `Open Source Projects (${filteredOpenSourceProjects.length})` - )} - - {/* Proprietary Projects Table */} - {renderTable( - filteredProprietaryProjects, - tPage('table.proprietaryTitle', { count: filteredProprietaryProjects.length }) || - `Proprietary Projects (${filteredProprietaryProjects.length})` - )} - - ) - })()} + return ( +
+
+ {filterOptions.map(type => ( + + ))} +
+ + {renderTable( + filteredOpenSourceProjects, + tPage('table.openSourceTitle', { count: filteredOpenSourceProjects.length }) + )} + {renderTable( + filteredProprietaryProjects, + tPage('table.proprietaryTitle', { count: filteredProprietaryProjects.length }) + )} - {/* Note Section */}
{tPage('note')}
- {/* Statistics Section with Pie Chart */}

{tPage('statistics.title')}

- {/* Multi-color Pie Chart */}
{(() => { const colors = [ - '#10b981', // MIT - green - '#3b82f6', // Apache-2.0 - blue - '#8b5cf6', // GPL-3.0 - purple - '#f59e0b', // BSD - orange - '#ef4444', // AGPL - red - '#06b6d4', // MPL - cyan - '#ec4899', // Other - pink - '#d1d5db', // Proprietary - light gray + '#10b981', + '#3b82f6', + '#8b5cf6', + '#f59e0b', + '#ef4444', + '#06b6d4', + '#ec4899', + '#d1d5db', ] let currentAngle = 0 const radius = 80 const centerX = 120 const centerY = 120 - - // Combine license stats with proprietary const allStats = [ - ...stats.licenseStats.map((s, i) => ({ - ...s, - color: colors[i % (colors.length - 1)], + ...stats.licenseStats.map((stat, index) => ({ + ...stat, + color: colors[index % (colors.length - 1)], })), { license: 'Proprietary', @@ -499,16 +433,12 @@ export function OpenSourceRankPage() { const startAngle = currentAngle const angle = (stat.percentage / 100) * 360 currentAngle += angle - - // Calculate arc path const startRad = (startAngle - 90) * (Math.PI / 180) const endRad = (startAngle + angle - 90) * (Math.PI / 180) - const x1 = centerX + radius * Math.cos(startRad) const y1 = centerY + radius * Math.sin(startRad) const x2 = centerX + radius * Math.cos(endRad) const y2 = centerY + radius * Math.sin(endRad) - const largeArcFlag = angle > 180 ? 1 : 0 if (stat.percentage === 0) return null @@ -532,13 +462,11 @@ export function OpenSourceRankPage() {
- {/* License Breakdown */}

{tPage('statistics.licenseBreakdown')}

- {/* Open Source Licenses */} {stats.licenseStats.map((stat, index) => { const colors = [ '#10b981', @@ -550,6 +478,7 @@ export function OpenSourceRankPage() { '#ec4899', ] const color = colors[index % colors.length] + return (
{tShared('terms.proprietary')}
{stats.proprietary} {tPage('statistics.projects')} ( - {stats.proprietaryPercentage} - %) + {stats.proprietaryPercentage}%)
- {/* Summary Stats */}
diff --git a/src/lib/generated/github-stars.ts b/src/lib/generated/github-stars.ts index 2d1cb4d1..6fe40afb 100644 --- a/src/lib/generated/github-stars.ts +++ b/src/lib/generated/github-stars.ts @@ -6,18 +6,26 @@ import githubStarsJson from '../../../data/github-stars.json' -export type GithubStarsData = Record> +export interface GithubStarsData { + observedAt: string + repositories: Record +} export const githubStarsData = githubStarsJson as GithubStarsData /** * Get GitHub stars for a specific product - * @param category - The product category (extensions, clis, desktops, ides) - * @param id - The product ID + * @param githubUrl - The product's GitHub repository URL * @returns The number of stars (in thousands) or null if not available */ -export function getGithubStars(category: string, id: string): number | null { - return githubStarsData[category]?.[id] ?? null +export function getGithubStars(githubUrl: string | null | undefined): number | null { + if (!githubUrl) return null + const repositoryId = githubUrl + .replace(/\/$/, '') + .replace(/\.git$/, '') + .replace(/^https:\/\/github\.com\//, '') + const stars = githubStarsData.repositories[repositoryId] + return typeof stars === 'number' ? Math.round(stars / 100) / 10 : null } export default githubStarsData diff --git a/src/lib/landscape-data.ts b/src/lib/landscape-data.ts index 34824495..9899dcaf 100644 --- a/src/lib/landscape-data.ts +++ b/src/lib/landscape-data.ts @@ -107,7 +107,7 @@ function ideToProduct(ide: ManifestIDE): LandscapeProduct { websiteUrl: ide.websiteUrl, docsUrl: ide.docsUrl || undefined, githubUrl: ide.githubUrl, - githubStars: getGithubStars('ides', ide.id), + githubStars: getGithubStars(ide.githubUrl), license: ide.license, latestVersion: ide.latestVersion, path: buildManifestPath('ides', ide.id), @@ -125,7 +125,7 @@ function cliToProduct(cli: ManifestCLI): LandscapeProduct { websiteUrl: cli.websiteUrl, docsUrl: cli.docsUrl || undefined, githubUrl: cli.githubUrl, - githubStars: getGithubStars('clis', cli.id), + githubStars: getGithubStars(cli.githubUrl), license: cli.license, latestVersion: cli.latestVersion, path: buildManifestPath('clis', cli.id), @@ -143,7 +143,7 @@ function desktopToProduct(desktop: ManifestDesktop): LandscapeProduct { websiteUrl: desktop.websiteUrl, docsUrl: desktop.docsUrl || undefined, githubUrl: desktop.githubUrl, - githubStars: getGithubStars('desktops', desktop.id), + githubStars: getGithubStars(desktop.githubUrl), license: desktop.license, latestVersion: desktop.latestVersion, path: buildManifestPath('desktops', desktop.id), @@ -161,7 +161,7 @@ function extensionToProduct(ext: ManifestExtension): LandscapeProduct { websiteUrl: ext.websiteUrl, docsUrl: ext.docsUrl || undefined, githubUrl: ext.githubUrl, - githubStars: getGithubStars('extensions', ext.id), + githubStars: getGithubStars(ext.githubUrl), license: ext.license, latestVersion: ext.latestVersion, path: buildManifestPath('extensions', ext.id), diff --git a/src/types/manifests.ts b/src/types/manifests.ts index 22726075..5102e461 100644 --- a/src/types/manifests.ts +++ b/src/types/manifests.ts @@ -145,6 +145,16 @@ export interface ManifestRelatedProduct { productId: string } +/** + * Source-code relationship for a product's linked GitHub repository. + * Based on: /manifests/$schemas/ref/product.schema.json#$defs/sourceCode + */ +export interface ManifestSourceCode { + status: 'open' | 'partial' | 'closed' + repositoryRole: 'source' | 'feedback' | 'documentation' + license?: string +} + /** * Declarative latest-version source * Based on: /manifests/$schemas/ref/product.schema.json#$defs/releaseTracking @@ -191,6 +201,7 @@ export interface ManifestBaseProduct extends ManifestVendorEntity { releaseTracking?: ManifestReleaseTracking githubUrl: string | null license: string + sourceCode?: ManifestSourceCode pricing: ManifestPricingTier[] resourceUrls: ManifestResourceUrls /** Product-specific URLs; organization URLs are inherited from the vendor. */ @@ -477,10 +488,8 @@ export interface ManifestCollections { * Based on: /manifests/$schemas/github-stars.schema.json */ export interface ManifestGitHubStars { - desktops: { [productId: string]: number | null } - extensions: { [productId: string]: number | null } - clis: { [productId: string]: number | null } - ides: { [productId: string]: number | null } + observedAt: string + repositories: Record } // ============================================================================= diff --git a/tests/validate/githubStars.consistency.test.ts b/tests/validate/githubStars.consistency.test.ts index 7be207e7..15437825 100644 --- a/tests/validate/githubStars.consistency.test.ts +++ b/tests/validate/githubStars.consistency.test.ts @@ -2,108 +2,66 @@ import fs from 'node:fs' import path from 'node:path' import Ajv from 'ajv' +import addFormats from 'ajv-formats' import { describe, expect, it } from 'vitest' -/** - * Read and parse JSON from disk. - */ -function readJsonFile(filePath: string): unknown { - const content = fs.readFileSync(filePath, 'utf8') - return JSON.parse(content) as unknown +type SurfaceType = 'ide' | 'cli' | 'desktop' | 'extension' + +type StarsData = { + observedAt: string + repositories: Record } -/** - * Collect manifest IDs from manifests/{category}/*.json. - */ -function getManifestIds(rootDir: string, category: string): string[] { - const dirPath = path.join(rootDir, 'manifests', category) - if (!fs.existsSync(dirPath)) { - return [] - } - return fs - .readdirSync(dirPath) - .filter(file => file.endsWith('.json')) - .map(file => path.basename(file, '.json')) - .sort() +const directories: Record = { + ide: 'ides', + cli: 'clis', + desktop: 'desktops', + extension: 'extensions', } -/** - * Collect github-stars IDs for a given category from data/github-stars.json. - */ -function getGithubStarIds(githubStars: unknown, category: string): string[] { - if (githubStars === null || typeof githubStars !== 'object') { - return [] - } - const record = githubStars as Record - const section = record[category] - if (section === null || typeof section !== 'object') { - return [] - } - return Object.keys(section as Record).sort() +function readJsonFile(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown +} + +function normalizeRepositoryUrl(url: string): string { + return url.replace(/\/$/, '').replace(/\.git$/, '') } -/** - * Validate that github-stars.json and manifests are in sync. - */ function validateGithubStarsConsistency(rootDir: string): string[] { const failures: string[] = [] - const categories = ['extensions', 'clis', 'desktops', 'ides'] as const - const starsByRepository = new Map< - string, - Array<{ category: (typeof categories)[number]; id: string; stars: number | null }> - >() - - const githubStarsPath = path.join(rootDir, 'data', 'github-stars.json') - const githubStars = readJsonFile(githubStarsPath) - const githubStarsRecord = githubStars as Record> - - for (const category of categories) { - const manifestIds = getManifestIds(rootDir, category) - const githubStarIds = getGithubStarIds(githubStars, category) - const categoryStars = githubStarsRecord[category] ?? {} - - const orphaned = githubStarIds.filter(id => !manifestIds.includes(id)) - const missing = manifestIds.filter(id => !githubStarIds.includes(id)) - - if (orphaned.length > 0) { - failures.push( - `[${category}] entries in data/github-stars.json without manifest files:\n${orphaned.map(i => `- ${i}`).join('\n')}` - ) - } - if (missing.length > 0) { - failures.push( - `[${category}] manifest files missing in data/github-stars.json:\n${missing.map(i => `- ${i}`).join('\n')}` - ) - } - - for (const id of manifestIds) { - if (!(id in categoryStars)) continue - - const manifestPath = path.join(rootDir, 'manifests', category, `${id}.json`) - const manifest = readJsonFile(manifestPath) as Record - const stars = categoryStars[id] as number | null - if (manifest.githubUrl === null && stars !== null) { - failures.push( - `[${category}] ${id} has githubUrl: null but a non-null star count in data/github-stars.json` - ) + const data = readJsonFile(path.join(rootDir, 'data', 'github-stars.json')) as StarsData + const associatedRepositories = new Set() + + for (const [surfaceType, directory] of Object.entries(directories) as Array< + [SurfaceType, string] + >) { + const directoryPath = path.join(rootDir, 'manifests', directory) + for (const file of fs.readdirSync(directoryPath).filter(name => name.endsWith('.json'))) { + const manifest = readJsonFile(path.join(directoryPath, file)) as { + id: string + githubUrl?: string | null + sourceCode?: unknown + } + const key = `${surfaceType}:${manifest.id}` + if (typeof manifest.githubUrl !== 'string') { + if (manifest.sourceCode !== undefined) { + failures.push(`${key} defines sourceCode without a githubUrl`) + } + continue } - if (typeof manifest.githubUrl === 'string') { - const repositoryEntries = starsByRepository.get(manifest.githubUrl) ?? [] - repositoryEntries.push({ category, id, stars }) - starsByRepository.set(manifest.githubUrl, repositoryEntries) + const normalizedUrl = normalizeRepositoryUrl(manifest.githubUrl) + const repositoryId = normalizedUrl.replace('https://github.com/', '') + if (!(repositoryId in data.repositories)) { + failures.push(`${key} references ${repositoryId}, which is missing from github-stars.json`) } + associatedRepositories.add(repositoryId) } } - for (const [repositoryUrl, entries] of starsByRepository) { - const uniqueValues = new Set(entries.map(entry => entry.stars)) - if (uniqueValues.size > 1) { - failures.push( - `${repositoryUrl} has inconsistent star counts:\n${entries - .map(entry => `- [${entry.category}] ${entry.id}: ${String(entry.stars)}`) - .join('\n')}` - ) + for (const repositoryId of Object.keys(data.repositories)) { + if (!associatedRepositories.has(repositoryId)) { + failures.push(`${repositoryId} has a star snapshot but no product manifest association`) } } @@ -118,15 +76,59 @@ describe('validate: github-stars consistency', () => { ) const githubStars = readJsonFile(path.join(rootDir, 'data', 'github-stars.json')) const ajv = new Ajv({ allErrors: true }) + addFormats(ajv) const validate = ajv.compile(schema as object) expect(validate(githubStars), JSON.stringify(validate.errors, null, 2)).toBe(true) }) - it('data/github-stars.json matches manifest files', () => { + it('derives every repository association from product manifests', () => { const failures = validateGithubStarsConsistency(process.cwd()) if (failures.length > 0) { - throw new Error(`github-stars consistency validation failed:\n\n${failures.join('\n\n')}`) + throw new Error(`github-stars consistency validation failed:\n\n${failures.join('\n')}`) + } + }) + + it('associates Goose CLI and desktop surfaces through their manifests', () => { + const data = readJsonFile(path.join(process.cwd(), 'data', 'github-stars.json')) as StarsData + const cli = readJsonFile(path.join(process.cwd(), 'manifests', 'clis', 'goose.json')) as { + githubUrl: string } + const desktop = readJsonFile( + path.join(process.cwd(), 'manifests', 'desktops', 'goose.json') + ) as { githubUrl: string } + + expect(data.repositories['aaif-goose/goose']).toBeTypeOf('number') + expect(cli.githubUrl).toBe('https://github.com/aaif-goose/goose') + expect(desktop.githubUrl).toBe(cli.githubUrl) + }) + + it('records source-code coverage on the affected product manifests', () => { + const readManifest = (directory: string, id: string) => + readJsonFile(path.join(process.cwd(), 'manifests', directory, `${id}.json`)) as { + sourceCode?: { + status: 'open' | 'partial' | 'closed' + repositoryRole: 'source' | 'feedback' | 'documentation' + license?: string + } + } + + expect(readManifest('clis', 'codex-cli').sourceCode).toEqual({ + status: 'open', + repositoryRole: 'source', + }) + expect(readManifest('desktops', 'codex-app').sourceCode).toEqual({ + status: 'closed', + repositoryRole: 'feedback', + }) + expect(readManifest('extensions', 'codex').sourceCode).toEqual({ + status: 'closed', + repositoryRole: 'feedback', + }) + expect(readManifest('ides', 'intellij-idea').sourceCode).toEqual({ + status: 'partial', + repositoryRole: 'source', + license: 'Apache-2.0', + }) }) }) diff --git a/translations/de/pages/open-source-rank.json b/translations/de/pages/open-source-rank.json index f116c325..9f38a107 100644 --- a/translations/de/pages/open-source-rank.json +++ b/translations/de/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "Keine Open-Source-Projekte für diese Kategorie gefunden.", "note": "Hinweis: Diese Seite zeigt nur Projekte mit GitHub-Repositories. Einige Projekte unterhalten GitHub-Repositories als Benutzerfeedback-Kanäle, obwohl sie nicht Open Source sind.", + "repositoryScope": { + "source": "Der Quellcode ist in diesem Repository enthalten", + "partial": "Dieses Repository enthält nur den Open-Source-Kern des Produkts", + "related": "Zugehöriges Produkt; sein Quellcode befindet sich nicht in diesem Repository" + }, "statistics": { "closedSourceCount": "Proprietäre Projekte", "closedSourcePercentage": "Proprietärer Anteil", diff --git a/translations/en/pages/open-source-rank.json b/translations/en/pages/open-source-rank.json index 35e3d712..460eab30 100644 --- a/translations/en/pages/open-source-rank.json +++ b/translations/en/pages/open-source-rank.json @@ -1,26 +1,31 @@ { - "description": "Discover and explore open source AI coding tools ranked by GitHub stars. Compare IDEs, CLIs, and extensions to find the perfect tools for your development workflow.", + "description": "Explore AI coding products by GitHub repository stars. Each repository appears once, with related products and surfaces grouped together.", "filter": { "all": "All" }, "meta": { - "description": "Ranking of all open source projects in AI Coding Stack, sorted by GitHub stars. View statistics and trends of open source vs proprietary tools.", + "description": "Repository-level ranking of AI coding products by GitHub stars, with related IDE, CLI, desktop, and extension surfaces grouped together.", "title": "Open Source Projects Ranking" }, "noResults": "No open source projects found for this category.", - "note": "Note: This page only shows projects with GitHub repositories. Some projects, while not open source, maintain GitHub repositories as user feedback channels.", + "note": "Each GitHub repository appears once. A product may span multiple surface types; repositories used only for feedback or documentation are kept separate from open source repositories.", + "repositoryScope": { + "source": "Source code included in this repository", + "partial": "This repository contains only the product's open-source core", + "related": "Related product; its source code is not in this repository" + }, "statistics": { - "closedSourceCount": "Proprietary Projects", + "closedSourceCount": "Other Product Repositories", "closedSourcePercentage": "Proprietary Percentage", "licenseBreakdown": "License Distribution", - "openSourceCount": "Open Source Projects", + "openSourceCount": "Open Source Repositories", "projects": "projects", - "title": "Open Source vs Proprietary Distribution", - "totalProjects": "Total Projects" + "title": "Repository Distribution", + "totalProjects": "Total Repositories" }, "table": { - "openSourceTitle": "Open Source Projects ({count})", - "proprietaryTitle": "Proprietary Projects ({count})", + "openSourceTitle": "Open Source Repositories ({count})", + "proprietaryTitle": "Other Product Repositories ({count})", "rank": "Rank" }, "title": "Open Source Projects Ranking" diff --git a/translations/es/pages/open-source-rank.json b/translations/es/pages/open-source-rank.json index fb7d7b85..093c4e59 100644 --- a/translations/es/pages/open-source-rank.json +++ b/translations/es/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "No se encontraron proyectos de código abierto para esta categoría.", "note": "Nota: Esta página solo muestra proyectos con repositorios de GitHub. Algunos proyectos, aunque no sean de código abierto, mantienen repositorios de GitHub como canales de comentarios de usuarios.", + "repositoryScope": { + "source": "El código fuente está incluido en este repositorio", + "partial": "Este repositorio solo contiene el núcleo de código abierto del producto", + "related": "Producto relacionado; su código fuente no está en este repositorio" + }, "statistics": { "closedSourceCount": "Proyectos de código propietario", "closedSourcePercentage": "Porcentaje propietario", diff --git a/translations/fr/pages/open-source-rank.json b/translations/fr/pages/open-source-rank.json index 75946ce4..39d37efe 100644 --- a/translations/fr/pages/open-source-rank.json +++ b/translations/fr/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "Aucun projet open source trouvé pour cette catégorie.", "note": "Note: Cette page affiche uniquement les projets avec des dépôts GitHub. Certains projets, bien qu'ils ne soient pas open source, maintiennent des dépôts GitHub comme canaux de retour des utilisateurs.", + "repositoryScope": { + "source": "Le code source est inclus dans ce dépôt", + "partial": "Ce dépôt contient uniquement le cœur open source du produit", + "related": "Produit associé ; son code source ne se trouve pas dans ce dépôt" + }, "statistics": { "closedSourceCount": "Projets propriétaires", "closedSourcePercentage": "Pourcentage propriétaire", diff --git a/translations/id/pages/open-source-rank.json b/translations/id/pages/open-source-rank.json index 4371b0e8..17655649 100644 --- a/translations/id/pages/open-source-rank.json +++ b/translations/id/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "Tidak ada proyek sumber terbuka yang ditemukan untuk kategori ini.", "note": "Catatan: Halaman ini hanya menampilkan proyek yang memiliki repositori GitHub. Beberapa proyek yang bukan sumber terbuka tetap menyediakan repositori GitHub sebagai saluran umpan balik pengguna.", + "repositoryScope": { + "source": "Kode sumber disertakan dalam repositori ini", + "partial": "Repositori ini hanya berisi inti sumber terbuka produk", + "related": "Produk terkait; kode sumbernya tidak ada dalam repositori ini" + }, "statistics": { "closedSourceCount": "Proyek Proprietary", "closedSourcePercentage": "Persentase Proprietari", diff --git a/translations/ja/pages/open-source-rank.json b/translations/ja/pages/open-source-rank.json index bb016be1..0b4397c9 100644 --- a/translations/ja/pages/open-source-rank.json +++ b/translations/ja/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "このカテゴリに該当するオープンソースプロジェクトはありません。", "note": "注:このページにはGitHubリポジトリを持つプロジェクトのみ表示されます。オープンソースではない一部のプロジェクトも、ユーザーからのフィードバック窓口としてGitHubリポジトリを運用しています。", + "repositoryScope": { + "source": "ソースコードはこのリポジトリに含まれています", + "partial": "このリポジトリには製品のオープンソースコアのみが含まれています", + "related": "関連製品です。ソースコードはこのリポジトリに含まれていません" + }, "statistics": { "closedSourceCount": "プロプライエタリプロジェクト", "closedSourcePercentage": "プロプライエタリの割合", diff --git a/translations/ko/pages/open-source-rank.json b/translations/ko/pages/open-source-rank.json index e3453457..0ad4e379 100644 --- a/translations/ko/pages/open-source-rank.json +++ b/translations/ko/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "이 카테고리에서 오픈 소스 프로젝트를 찾을 수 없습니다.", "note": "참고: 이 페이지에는 GitHub 저장소가 있는 프로젝트만 표시됩니다. 오픈 소스가 아닌 일부 프로젝트도 사용자 피드백 채널로 GitHub 저장소를 운영합니다.", + "repositoryScope": { + "source": "소스 코드가 이 저장소에 포함되어 있습니다", + "partial": "이 저장소에는 제품의 오픈 소스 핵심만 포함되어 있습니다", + "related": "관련 제품이며 소스 코드는 이 저장소에 포함되어 있지 않습니다" + }, "statistics": { "closedSourceCount": "독점 프로젝트", "closedSourcePercentage": "독점 프로젝트 비율", diff --git a/translations/pt/pages/open-source-rank.json b/translations/pt/pages/open-source-rank.json index 9b3ba2df..bdcd057a 100644 --- a/translations/pt/pages/open-source-rank.json +++ b/translations/pt/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "Nenhum projeto de código aberto encontrado para esta categoria.", "note": "Nota: Esta página mostra apenas projetos com repositórios GitHub. Alguns projetos, embora não sejam de código aberto, mantêm repositórios GitHub como canais de feedback dos usuários.", + "repositoryScope": { + "source": "O código-fonte está incluído neste repositório", + "partial": "Este repositório contém apenas o núcleo de código aberto do produto", + "related": "Produto relacionado; seu código-fonte não está neste repositório" + }, "statistics": { "closedSourceCount": "Projetos proprietários", "closedSourcePercentage": "Porcentagem proprietária", diff --git a/translations/ru/pages/open-source-rank.json b/translations/ru/pages/open-source-rank.json index 127e2fef..ebe0179a 100644 --- a/translations/ru/pages/open-source-rank.json +++ b/translations/ru/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "В этой категории не найдено проектов с открытым исходным кодом.", "note": "Примечание: на этой странице показаны только проекты с репозиториями GitHub. Некоторые проекты, не являясь открытыми, поддерживают репозитории GitHub как каналы обратной связи с пользователями.", + "repositoryScope": { + "source": "Исходный код находится в этом репозитории", + "partial": "Этот репозиторий содержит только открытое ядро продукта", + "related": "Связанный продукт; его исходного кода нет в этом репозитории" + }, "statistics": { "closedSourceCount": "Собственные проекты", "closedSourcePercentage": "Доля проприетарных проектов", diff --git a/translations/tr/pages/open-source-rank.json b/translations/tr/pages/open-source-rank.json index b91b50bc..683af92f 100644 --- a/translations/tr/pages/open-source-rank.json +++ b/translations/tr/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "Bu kategoride açık kaynak proje bulunamadı.", "note": "Not: Bu sayfa yalnızca GitHub deposu bulunan projeleri gösterir. Açık kaynak olmayan bazı projeler, kullanıcı geri bildirim kanalı olarak GitHub depoları bulundurur.", + "repositoryScope": { + "source": "Kaynak kod bu depoda yer alır", + "partial": "Bu depo yalnızca ürünün açık kaynak çekirdeğini içerir", + "related": "İlgili ürün; kaynak kodu bu depoda yer almaz" + }, "statistics": { "closedSourceCount": "Tescilli Projeler", "closedSourcePercentage": "Tescilli Proje Yüzdesi", diff --git a/translations/zh-Hans/pages/open-source-rank.json b/translations/zh-Hans/pages/open-source-rank.json index 9e3f6928..65ea4652 100644 --- a/translations/zh-Hans/pages/open-source-rank.json +++ b/translations/zh-Hans/pages/open-source-rank.json @@ -1,26 +1,31 @@ { - "description": "发现并探索按 GitHub 星标数排名的开源 AI 编程工具。比较 IDE、CLI 和扩展,为你的开发工作流找到最合适的工具。", + "description": "按 GitHub 仓库星标数探索 AI 编码产品。每个仓库只出现一次,并聚合关联产品及其不同形态。", "filter": { "all": "全部" }, "meta": { - "description": "AI Coding Stack 中所有开源项目的排名,按 GitHub 星标数排序。查看开源与专有工具的统计数据和趋势。", + "description": "按 GitHub 仓库星标数排列 AI 编码产品,并聚合关联的 IDE、CLI、桌面应用和扩展形态。", "title": "开源项目排名" }, "noResults": "此类别中没有开源项目。", - "note": "注意:本页面仅显示拥有 GitHub 仓库的项目。部分项目虽非开源,但仍将 GitHub 仓库作为用户反馈渠道。", + "note": "每个 GitHub 仓库只出现一次。一个产品可以包含多个形态;仅用于反馈或文档的仓库会与开源仓库分开显示。", + "repositoryScope": { + "source": "源代码包含在此仓库中", + "partial": "此仓库仅包含该产品的开源核心", + "related": "相关产品;其源代码不在此仓库中" + }, "statistics": { - "closedSourceCount": "专有项目", + "closedSourceCount": "其他产品仓库", "closedSourcePercentage": "专有项目占比", "licenseBreakdown": "许可证分布", - "openSourceCount": "开源项目", + "openSourceCount": "开源仓库", "projects": "个项目", - "title": "开源与专有项目分布", - "totalProjects": "项目总数" + "title": "仓库分布", + "totalProjects": "仓库总数" }, "table": { - "openSourceTitle": "开源项目 ({count})", - "proprietaryTitle": "专有项目 ({count})", + "openSourceTitle": "开源仓库 ({count})", + "proprietaryTitle": "其他产品仓库 ({count})", "rank": "排名" }, "title": "开源项目排名" diff --git a/translations/zh-Hant/pages/open-source-rank.json b/translations/zh-Hant/pages/open-source-rank.json index eba0c9f8..297e2fd3 100644 --- a/translations/zh-Hant/pages/open-source-rank.json +++ b/translations/zh-Hant/pages/open-source-rank.json @@ -9,6 +9,11 @@ }, "noResults": "此類別中沒有開源專案。", "note": "注意:本頁面僅顯示擁有 GitHub 儲存庫的專案。部分專案雖非開源,但仍將 GitHub 儲存庫作為使用者意見回饋管道。", + "repositoryScope": { + "source": "原始碼包含在此儲存庫中", + "partial": "此儲存庫僅包含該產品的開放原始碼核心", + "related": "相關產品;其原始碼不在此儲存庫中" + }, "statistics": { "closedSourceCount": "專有專案", "closedSourcePercentage": "專有專案占比", From 2f2321a910b7e49b58c167755ddfd7fba63c115d Mon Sep 17 00:00:00 2001 From: Pan YANG Date: Sun, 2 Aug 2026 19:31:16 +0800 Subject: [PATCH 3/4] feat(comparison): add desktop app comparison --- scripts/validate/lib/routes.ts | 1 + .../desktops/comparison/page.client.tsx | 307 ++++++++++++++++++ src/app/[locale]/desktops/comparison/page.tsx | 21 ++ src/app/[locale]/desktops/page.client.tsx | 13 +- src/app/sitemap.ts | 1 + src/lib/metadata/i18n-validation.ts | 9 +- src/lib/metadata/registry.ts | 11 +- translations/de/pages/comparison.json | 4 + translations/en/pages/comparison.json | 4 + translations/es/pages/comparison.json | 4 + translations/fr/pages/comparison.json | 4 + translations/id/pages/comparison.json | 4 + translations/ja/pages/comparison.json | 4 + translations/ko/pages/comparison.json | 4 + translations/pt/pages/comparison.json | 4 + translations/ru/pages/comparison.json | 4 + translations/tr/pages/comparison.json | 4 + translations/zh-Hans/pages/comparison.json | 4 + translations/zh-Hant/pages/comparison.json | 4 + 19 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 src/app/[locale]/desktops/comparison/page.client.tsx create mode 100644 src/app/[locale]/desktops/comparison/page.tsx diff --git a/scripts/validate/lib/routes.ts b/scripts/validate/lib/routes.ts index fddb8f16..4ca3e8ae 100644 --- a/scripts/validate/lib/routes.ts +++ b/scripts/validate/lib/routes.ts @@ -36,6 +36,7 @@ export function getStaticRoutes(): string[] { '/open-source-rank', '/search', '/clis/comparison', + '/desktops/comparison', '/extensions/comparison', '/ides/comparison', '/models/comparison', diff --git a/src/app/[locale]/desktops/comparison/page.client.tsx b/src/app/[locale]/desktops/comparison/page.client.tsx new file mode 100644 index 00000000..6ab72a6e --- /dev/null +++ b/src/app/[locale]/desktops/comparison/page.client.tsx @@ -0,0 +1,307 @@ +'use client' + +import { Download, FileText, Github, Home, Linkedin, Twitter, Youtube } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/controls/PlatformIcons' +import Footer from '@/components/Footer' +import Header from '@/components/Header' +import { Breadcrumb } from '@/components/navigation/Breadcrumb' +import ComparisonTable, { type ComparisonColumn } from '@/components/product/ComparisonTable' +import { PricingSummaryValue } from '@/components/product/ProductPricing' +import { withVendorCommunityUrlsForCatalog } from '@/lib/community-urls' +import { desktopsData, vendorsData } from '@/lib/generated' +import { getGithubStars } from '@/lib/generated/github-stars' +import { renderLicense } from '@/lib/license' +import type { PricingTier } from '@/lib/pricing' +import type { ManifestDesktop, ManifestVendor } from '@/types/manifests' + +const desktops = withVendorCommunityUrlsForCatalog( + desktopsData as unknown as ManifestDesktop[], + vendorsData as unknown as ManifestVendor[] +) + +type Props = { + locale: string +} + +export default function DesktopComparisonPageClient({ locale: _locale }: Props) { + const tPage = useTranslations('pages.comparison') + const tShared = useTranslations('shared') + const columns: ComparisonColumn[] = [ + { + key: 'vendor', + label: tShared('categories.singular.vendor'), + }, + { + key: 'license', + label: tShared('terms.license'), + render: (value: unknown, item: Record) => + renderLicense(value, item, tShared), + }, + { + key: 'latestVersion', + label: tShared('terms.version'), + }, + { + key: 'platforms', + label: tShared('terms.platforms'), + render: (value: unknown) => { + const platforms = value as Array<{ os: string }> | string[] + if (!platforms || platforms.length === 0) return '-' + + // Handle both old format (string[]) and new format (Array<{ os: string }>) + const platformNames = Array.isArray(platforms) + ? platforms.map(p => (typeof p === 'string' ? p : p.os)) + : [] + + return ( +
+ {platformNames.includes('macOS') && ( + + + + )} + {platformNames.includes('Windows') && ( + + + + )} + {platformNames.includes('Linux') && ( + + + + )} +
+ ) + }, + }, + { + key: 'githubStars', + label: tShared('terms.stars'), + render: (_: unknown, item: Record) => { + const githubUrl = item.githubUrl as string | null | undefined + const stars = getGithubStars(githubUrl) + + if (stars === null || stars === undefined) + return - + + const starsText = `${stars.toFixed(1)}k` + + if (githubUrl) { + return ( + + {starsText} + + ) + } + + return {starsText} + }, + }, + { + key: 'links', + label: tPage('columns.links'), + render: (_: unknown, item: Record) => { + const websiteUrl = item.websiteUrl as string | undefined + const docsUrl = item.docsUrl as string | undefined + const resourceUrls = item.resourceUrls as + | { + download?: string + } + | undefined + const communityUrls = item.communityUrls as + | { + github?: string + twitter?: string + linkedin?: string + youtube?: string + reddit?: string + } + | undefined + + return ( +
+ {websiteUrl ? ( + + + + ) : ( + + + + )} + {resourceUrls?.download ? ( + + + + ) : ( + + + + )} + {docsUrl ? ( + + + + ) : ( + + + + )} + {communityUrls?.github ? ( + + + + ) : ( + + + + )} + {communityUrls?.twitter ? ( + + + + ) : ( + + + + )} + {communityUrls?.linkedin ? ( + + + + ) : ( + + + + )} + {communityUrls?.youtube ? ( + + + + ) : ( + + + + )} +
+ ) + }, + }, + { + key: 'pricing-free', + label: tPage('columns.freePlan'), + render: (_: unknown, item: Record) => { + const pricing = item.pricing as PricingTier[] + if (!pricing || pricing.length === 0) return '-' + const freePlan = pricing.find(p => p.value === 0) + return freePlan ? '✓' : '-' + }, + }, + { + key: 'pricing-min', + label: tPage('columns.startingPrice'), + render: (_: unknown, item: Record) => { + const pricing = item.pricing as PricingTier[] + return + }, + }, + { + key: 'pricing-max', + label: tPage('columns.maxPrice'), + render: (_: unknown, item: Record) => { + const pricing = item.pricing as PricingTier[] + return + }, + }, + ] + + return ( + <> +
+ + + + {/* Page Header */} +
+
+

+ {tPage('desktops.title')} +

+

+ {tPage('desktops.subtitle')} +

+
+
+ + {/* Comparison Table */} +
+
+ []} + columns={columns} + itemLinkPrefix={`/desktops`} + nameColumnLabel={tShared('labels.name')} + caption={tPage('desktops.title')} + scrollHint={tPage('table.scrollHint')} + /> +
+
+ +