Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
- { name: check-frontend, dir: 'apps/frontend', cmd: 'bun run check' }
- { name: check-api, dir: 'apps/api', cmd: 'bun run check' }
- { name: build-frontend, dir: 'apps/frontend', cmd: 'bun run build' }
- { name: vercel-cache-website, dir: '.', cmd: 'bun scripts/check-vercel-cache.ts website' }
- { name: vercel-cache-docs, dir: '.', cmd: 'bun scripts/check-vercel-cache.ts docs' }
steps:
- uses: actions/checkout@v4

Expand Down
35 changes: 35 additions & 0 deletions apps/api/tests/lib/connection-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import { clearDb } from '../helpers'
import { setExtensionsDelta, buildMergedSchema } from '../../src/lib/manifest-schema'
import { createKind } from '../../src/lib/kinds'
import { parseManifestText } from '../../src/lib/manifest'
import { validateManifest } from '@tabularium/manifest'
import delta from '../../../../deploy/tabularis/connection-fields.json'

beforeEach(clearDb)

describe('Tabularis connection_fields operator extension', () => {
for (const scoped of [false, true]) {
it(`preserves the PR #629 contract (kind override=${scoped})`, async () => {
const extensions = { engine: { type: 'string' }, ...delta }
await setExtensionsDelta(extensions)
if (scoped) await createKind({ key: 'driver', label: 'Drivers', description: null, extensionsSchema: extensions })
const base = { id: 'bigquery', name: 'BigQuery', version: '1.0.0', kind: 'driver', engine: 'bigquery' }
const fields = { host: { hidden: true }, username: { label: 'Project ID', placeholder: 'my-project' } }
expect(parseManifestText(JSON.stringify({ ...base, connection_fields: fields }))).toMatchObject({
...base,
connection_fields: fields,
})
expect(parseManifestText(JSON.stringify(base))).toMatchObject(base)
const schema = buildMergedSchema({ kind: 'driver' })
for (const invalid of [
{ arbitrary: { hidden: true } },
{ host: { hidden: 'yes' } },
{ host: { label: 'x'.repeat(81) } },
{ host: { placeholder: 'x'.repeat(201) } },
{ password: { secretValue: 'must-not-be-in-manifest' } },
])
expect(validateManifest({ ...base, connection_fields: invalid }, schema).ok).toBe(false)
})
}
})
5 changes: 5 additions & 0 deletions apps/docs/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "sveltekit",
"outputDirectory": null
}
1 change: 1 addition & 0 deletions apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "Noch keine Downloads erfasst.",
"plugin_detail_downloads_per_version": "Downloads pro Version",
"plugin_detail_downloads_total": "Gesamte Downloads",
"plugin_detail_downloads_unavailable": "Download-Statistiken sind derzeit nicht verfügbar.",
"plugin_detail_email_support": "Support per E-Mail",
"plugin_detail_featured": "Empfohlen",
"plugin_detail_install_subtitle": "Hol dir die neueste, für deine Plattform passende Asset-URL über die Catalog-API:",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "No downloads recorded yet.",
"plugin_detail_downloads_per_version": "Downloads by version",
"plugin_detail_downloads_total": "Total downloads",
"plugin_detail_downloads_unavailable": "Download statistics are currently unavailable.",
"plugin_detail_email_support": "Email support",
"plugin_detail_featured": "Featured",
"plugin_detail_install_subtitle": "Resolve the latest platform-matching asset URL via the catalog API:",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "Aún no hay descargas registradas.",
"plugin_detail_downloads_per_version": "Descargas por versión",
"plugin_detail_downloads_total": "Descargas totales",
"plugin_detail_downloads_unavailable": "Las estadísticas de descargas no están disponibles en este momento.",
"plugin_detail_email_support": "Soporte por email",
"plugin_detail_featured": "Destacado",
"plugin_detail_install_subtitle": "Resuelve la URL del asset que coincida con la plataforma más reciente vía la API del catálogo:",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "Aucun téléchargement enregistré pour le moment.",
"plugin_detail_downloads_per_version": "Téléchargements par version",
"plugin_detail_downloads_total": "Téléchargements totaux",
"plugin_detail_downloads_unavailable": "Les statistiques de téléchargement sont actuellement indisponibles.",
"plugin_detail_email_support": "Support par e-mail",
"plugin_detail_featured": "Mis en avant",
"plugin_detail_install_subtitle": "Résous l'URL de l'asset adapté à la plateforme la plus récente via l'API du catalogue :",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "Nessun download registrato ancora.",
"plugin_detail_downloads_per_version": "Download per versione",
"plugin_detail_downloads_total": "Download totali",
"plugin_detail_downloads_unavailable": "Le statistiche dei download non sono al momento disponibili.",
"plugin_detail_email_support": "Supporto via email",
"plugin_detail_featured": "In evidenza",
"plugin_detail_install_subtitle": "Risolvi l'URL dell'asset più recente per la piattaforma tramite l'API del catalogo:",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@
"plugin_detail_downloads_empty": "尚无下载记录。",
"plugin_detail_downloads_per_version": "各版本下载量",
"plugin_detail_downloads_total": "总下载量",
"plugin_detail_downloads_unavailable": "下载统计信息暂时不可用。",
"plugin_detail_email_support": "邮件支持",
"plugin_detail_featured": "精选",
"plugin_detail_install_subtitle": "通过目录 API 解析最新且匹配当前平台的资源 URL:",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "NODE_OPTIONS=--no-deprecation vite dev --port 5180",
"build": "vite build",
"preview": "vite preview",
"test": "bun test tests",
"prepare": "svelte-kit sync || echo ''",
"check": "paraglide-js compile --project project.inlang --outdir src/lib/paraglide --silent && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "paraglide-js compile --project project.inlang --outdir src/lib/paraglide --silent && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
Expand Down
11 changes: 11 additions & 0 deletions apps/frontend/src/lib/components/PluginCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import Badge from '$components/ui/Badge.svelte'
import VerifiedBadge from '$components/ui/VerifiedBadge.svelte'
import Boxes from '@lucide/svelte/icons/boxes'
import Download from '@lucide/svelte/icons/download'
import { formatDownloadCount } from '$lib/downloads'
import { i18n } from '$lib/stores/i18n.svelte'
import { m } from '$lib/paraglide/messages'
import type { Plugin } from '$lib/types'

Expand Down Expand Up @@ -53,6 +56,14 @@
<div class="mt-4 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span class="truncate">{plugin.author.split('<')[0].trim()}</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<span
class="inline-flex items-center gap-1 font-mono tabular-nums"
title={`${m.plugin_detail_stat_downloads()}: ${formatDownloadCount(plugin.downloads, i18n.current, false)}`}
aria-label={`${m.plugin_detail_stat_downloads()}: ${formatDownloadCount(plugin.downloads, i18n.current, false)}`}
>
<Download class="h-3.5 w-3.5" aria-hidden="true" />
{formatDownloadCount(plugin.downloads, i18n.current)}
</span>
{#if plugin.category}
<Badge variant="outline" class="text-[10px]">{plugin.category}</Badge>
{/if}
Expand Down
69 changes: 69 additions & 0 deletions apps/frontend/src/lib/downloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export type VersionDownloadStatistics = {
version: string
total: number
platforms: Record<string, number>
}

export type DownloadStatistics = {
total: number
versions: VersionDownloadStatistics[]
}

export type DownloadStatisticsResult = { status: 'ready'; data: DownloadStatistics } | { status: 'unavailable' }

export type DownloadStatisticsState = DownloadStatisticsResult | { status: 'loading' }

function isCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

export function parseDownloadStatistics(value: unknown): DownloadStatistics | null {
if (!isRecord(value) || !isCount(value.total) || !Array.isArray(value.versions)) return null
const versions: VersionDownloadStatistics[] = []
for (const entry of value.versions) {
if (
!isRecord(entry) ||
typeof entry.version !== 'string' ||
!entry.version ||
!isCount(entry.total) ||
!isRecord(entry.platforms)
) {
return null
}
const platforms: Record<string, number> = {}
for (const [platform, count] of Object.entries(entry.platforms)) {
if (!isCount(count)) return null
// Preserve keys as own data properties, including unexpected API keys.
Object.defineProperty(platforms, platform, { value: count, enumerable: true })
}
versions.push({ version: entry.version, total: entry.total, platforms })
}
return { total: value.total, versions }
}

/** Inject the read-only statistics request; never resolve a tracked download here. */
export async function fetchDownloadStatistics(
request: () => Promise<{ data?: unknown; error?: unknown }>,
): Promise<DownloadStatisticsResult> {
try {
const response = await request()
if (response.error != null) return { status: 'unavailable' }
const data = parseDownloadStatistics(response.data)
return data ? { status: 'ready', data } : { status: 'unavailable' }
} catch {
return { status: 'unavailable' }
}
}

/** Missing/invalid counters are not zero; compact output remains locale-aware. */
export function formatDownloadCount(count: number | null | undefined, locale = 'en', compact = true): string {
if (!isCount(count)) return '—'
return new Intl.NumberFormat(locale, {
notation: compact ? 'compact' : 'standard',
maximumFractionDigits: compact ? 1 : 0,
}).format(count)
}
54 changes: 32 additions & 22 deletions apps/frontend/src/routes/plugins/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,15 @@
import { i18n, LOCALE_LABELS, type Locale } from '$lib/stores/i18n.svelte'
import { toast } from 'svelte-sonner'
import type { Plugin, PluginStats, Release } from '$lib/types'
import { fetchDownloadStatistics, formatDownloadCount, type DownloadStatisticsState } from '$lib/downloads'
import { m } from '$lib/paraglide/messages'

const slug = $derived(page.params.slug)
const locale = $derived(i18n.current)

type VersionStat = { version: string; total: number; platforms: Record<string, number> }

let plugin = $state<Plugin | null>(null)
let stats = $state<PluginStats | null>(null)
let downloadStats = $state<{ total: number; versions: VersionStat[] } | null>(null)
let downloadStats = $state<DownloadStatisticsState>({ status: 'loading' })
let loading = $state(true)
let notFound = $state(false)
let deleting = $state(false)
Expand Down Expand Up @@ -87,23 +86,24 @@
}
}

async function loadDownloadStats() {
try {
const { data, error } = await eden.api.plugins({ slug }).downloads.get()
if (error) return
downloadStats = data as { total: number; versions: VersionStat[] }
} catch {
// silent
}
}

onMount(() => {
void load(locale)
void loadStats()
void loadDownloadStats()
if (!instanceInfo.loaded) void instanceInfo.refresh()
})

$effect(() => {
const currentSlug = slug
let disposed = false
downloadStats = { status: 'loading' }
void fetchDownloadStatistics(() => eden.api.plugins({ slug: currentSlug }).downloads.get()).then((result) => {
if (!disposed) downloadStats = result
})
return () => {
disposed = true
}
})

$effect(() => {
void slug
void locale
Expand Down Expand Up @@ -531,7 +531,7 @@
>
{/if}
<span class="inline-flex items-center gap-1.5"
><Download class="h-3.5 w-3.5" />{formatNumber(plugin.downloads)}</span
><Download class="h-3.5 w-3.5" />{formatDownloadCount(plugin.downloads, locale)}</span
>
</div>
</div>
Expand Down Expand Up @@ -899,33 +899,43 @@
<!-- DOWNLOAD STATS PER VERSION -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold tracking-tight">{m.plugin_detail_downloads_per_version()}</h2>
{#if downloadStats && downloadStats.versions.length > 0}
{#if downloadStats.status === 'ready' && downloadStats.data.versions.length > 0}
<div class="rounded-xl border border-border bg-card/50 p-4 sm:p-6 space-y-6">
<div style:height="{Math.max(180, downloadStats.versions.length * 28 + 60)}px">
<div style:height="{Math.max(180, downloadStats.data.versions.length * 28 + 60)}px">
<BarChart
data={downloadStats.versions.map((v) => ({ ...v, label: `v${v.version}` }))}
data={downloadStats.data.versions.map((v) => ({ ...v, label: `v${v.version}` }))}
x="total"
y="label"
orientation="horizontal"
bandPadding={0.3}
/>
</div>
<ul class="space-y-1.5 text-xs">
{#each downloadStats.versions as v (v.version)}
{#each downloadStats.data.versions as v (v.version)}
<li class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="font-mono text-foreground/80 min-w-[60px]">v{v.version}</span>
<span class="font-mono tabular-nums text-muted-foreground">{formatNumber(v.total)}</span>
<span class="font-mono tabular-nums text-muted-foreground"
>{formatDownloadCount(v.total, locale)}</span
>
<span class="flex flex-wrap gap-1.5">
{#each Object.entries(v.platforms) as [pl, n] (pl)}
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-foreground/5 text-muted-foreground">
{platformLabel(pl)} · {formatNumber(n)}
{platformLabel(pl)} · {formatDownloadCount(n, locale)}
</span>
{/each}
</span>
</li>
{/each}
</ul>
</div>
{:else if downloadStats.status === 'loading'}
<div role="status" class="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
{m.common_loading()}
</div>
{:else if downloadStats.status === 'unavailable'}
<div role="status" class="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
{m.plugin_detail_downloads_unavailable()}
</div>
{:else}
<div class="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
{m.plugin_detail_downloads_empty()}
Expand Down Expand Up @@ -954,7 +964,7 @@
<Download class="h-3.5 w-3.5" />
<span>{m.plugin_detail_stat_downloads()}</span>
</div>
<div class="text-2xl font-semibold leading-none">{formatNumber(plugin.downloads)}</div>
<div class="text-2xl font-semibold leading-none">{formatDownloadCount(plugin.downloads, locale)}</div>
</div>
<div class="flex items-center justify-between py-3 border-b border-dashed border-border/70">
<div
Expand Down
Loading
Loading