diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13604ec..c3bd239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/api/tests/lib/connection-fields.test.ts b/apps/api/tests/lib/connection-fields.test.ts new file mode 100644 index 0000000..108855c --- /dev/null +++ b/apps/api/tests/lib/connection-fields.test.ts @@ -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) + }) + } +}) diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json new file mode 100644 index 0000000..7ba5c26 --- /dev/null +++ b/apps/docs/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "sveltekit", + "outputDirectory": null +} diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index 560e26b..9e1f7e4 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -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:", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index c9c09fc..81e7a98 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -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:", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 043d6dc..7bb43f1 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -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:", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 0538c74..1143a77 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -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 :", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index 9ed1d29..f900b69 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -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:", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index cba6992..7453730 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -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:", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3fc101d..28539d2 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/src/lib/components/PluginCard.svelte b/apps/frontend/src/lib/components/PluginCard.svelte index 5061579..6707efe 100644 --- a/apps/frontend/src/lib/components/PluginCard.svelte +++ b/apps/frontend/src/lib/components/PluginCard.svelte @@ -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' @@ -53,6 +56,14 @@
{plugin.author.split('<')[0].trim()}
+ + {#if plugin.category} {plugin.category} {/if} diff --git a/apps/frontend/src/lib/downloads.ts b/apps/frontend/src/lib/downloads.ts new file mode 100644 index 0000000..0d0212a --- /dev/null +++ b/apps/frontend/src/lib/downloads.ts @@ -0,0 +1,69 @@ +export type VersionDownloadStatistics = { + version: string + total: number + platforms: Record +} + +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 { + 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 = {} + 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 { + 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) +} diff --git a/apps/frontend/src/routes/plugins/[slug]/+page.svelte b/apps/frontend/src/routes/plugins/[slug]/+page.svelte index 6974fba..bb78b14 100644 --- a/apps/frontend/src/routes/plugins/[slug]/+page.svelte +++ b/apps/frontend/src/routes/plugins/[slug]/+page.svelte @@ -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 } - let plugin = $state(null) let stats = $state(null) - let downloadStats = $state<{ total: number; versions: VersionStat[] } | null>(null) + let downloadStats = $state({ status: 'loading' }) let loading = $state(true) let notFound = $state(false) let deleting = $state(false) @@ -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 @@ -531,7 +531,7 @@ > {/if} {formatNumber(plugin.downloads)}{formatDownloadCount(plugin.downloads, locale)}
@@ -899,11 +899,11 @@

{m.plugin_detail_downloads_per_version()}

- {#if downloadStats && downloadStats.versions.length > 0} + {#if downloadStats.status === 'ready' && downloadStats.data.versions.length > 0}
-
+
({ ...v, label: `v${v.version}` }))} + data={downloadStats.data.versions.map((v) => ({ ...v, label: `v${v.version}` }))} x="total" y="label" orientation="horizontal" @@ -911,14 +911,16 @@ />
    - {#each downloadStats.versions as v (v.version)} + {#each downloadStats.data.versions as v (v.version)}
  • v{v.version} - {formatNumber(v.total)} + {formatDownloadCount(v.total, locale)} {#each Object.entries(v.platforms) as [pl, n] (pl)} - {platformLabel(pl)} · {formatNumber(n)} + {platformLabel(pl)} · {formatDownloadCount(n, locale)} {/each} @@ -926,6 +928,14 @@ {/each}
+ {:else if downloadStats.status === 'loading'} +
+ {m.common_loading()} +
+ {:else if downloadStats.status === 'unavailable'} +
+ {m.plugin_detail_downloads_unavailable()} +
{:else}
{m.plugin_detail_downloads_empty()} @@ -954,7 +964,7 @@ {m.plugin_detail_stat_downloads()}
-
{formatNumber(plugin.downloads)}
+
{formatDownloadCount(plugin.downloads, locale)}
{ + test.each([ + [0, '0'], + [1, '1'], + [999, '999'], + [1234, '1.2K'], + [1234567, '1.2M'], + [1234567890, '1.2B'], + ])('formats %s without confusing thousands and millions', (input, expected) => { + expect(formatDownloadCount(input, 'en')).toBe(expected) + }) + + test.each([ + null, + undefined, + NaN, + Infinity, + -Infinity, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ])('keeps invalid or unavailable counts separate from zero: %s', (value) => + expect(formatDownloadCount(value)).toBe('—')) + + test('provides exact locale-aware counts for accessible card labels', () => { + expect(formatDownloadCount(1234567, 'en', false)).toBe('1,234,567') + expect(formatDownloadCount(1234567, 'de', false)).toBe('1.234.567') + expect(formatDownloadCount(1234567, 'zh-CN')).not.toBe(formatDownloadCount(1234567, 'en')) + }) +}) + +describe('read-only download statistics', () => { + test('distinguishes a successful empty response from unavailable data', async () => { + const request = mock(async () => ({ data: { total: 0, versions: [] }, error: null })) + expect(await fetchDownloadStatistics(request)).toEqual({ status: 'ready', data: { total: 0, versions: [] } }) + expect(request).toHaveBeenCalledTimes(1) + }) + + test('preserves universal and platform counts without local increments', async () => { + const request = mock(async () => ({ data: populated })) + expect(await fetchDownloadStatistics(request)).toEqual({ status: 'ready', data: populated }) + expect(populated.total).toBe(1234567) + expect(request).toHaveBeenCalledTimes(1) + }) + + test('does not retry a failed request or substitute zero', async () => { + const request = mock(async () => { + throw new Error('offline') + }) + expect(await fetchDownloadStatistics(request)).toEqual({ status: 'unavailable' }) + expect(request).toHaveBeenCalledTimes(1) + }) + + test('honors an API error even if an old data object is supplied', async () => { + expect(await fetchDownloadStatistics(async () => ({ data: populated, error: { status: 503 } }))).toEqual({ + status: 'unavailable', + }) + }) + + test.each([ + undefined, + null, + {}, + [], + { total: 0 }, + { total: -1, versions: [] }, + { total: '0', versions: [] }, + { total: Infinity, versions: [] }, + { total: 0, versions: null }, + { total: 0, versions: [null] }, + { total: 0, versions: [{ version: '', total: 0, platforms: {} }] }, + { total: 0, versions: [{ version: '1.0.0', total: 1.5, platforms: {} }] }, + { total: 0, versions: [{ version: '1.0.0', total: 0, platforms: [] }] }, + { total: 0, versions: [{ version: '1.0.0', total: 0, platforms: { universal: -1 } }] }, + { total: 0, versions: [{ version: '1.0.0', total: 0, platforms: { universal: null } }] }, + ])('rejects malformed statistics: %j', async (data) => { + expect(parseDownloadStatistics(data)).toBeNull() + expect(await fetchDownloadStatistics(async () => ({ data }))).toEqual({ status: 'unavailable' }) + }) + + test('copies API data without mutating it or assigning special keys to the prototype', () => { + const input = JSON.parse('{"total":1,"versions":[{"version":"1.0.0","total":1,"platforms":{"__proto__":1}}]}') + const before = JSON.stringify(input) + const result = parseDownloadStatistics(input) + expect(result).not.toBe(input) + expect(JSON.stringify(input)).toBe(before) + expect(Object.hasOwn(result!.versions[0].platforms, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(result!.versions[0].platforms)).toBe(Object.prototype) + }) +}) diff --git a/apps/website/vercel.json b/apps/website/vercel.json new file mode 100644 index 0000000..7ba5c26 --- /dev/null +++ b/apps/website/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "sveltekit", + "outputDirectory": null +} diff --git a/deploy/tabularis/README.md b/deploy/tabularis/README.md new file mode 100644 index 0000000..48d87c6 --- /dev/null +++ b/deploy/tabularis/README.md @@ -0,0 +1,22 @@ +# Tabularis registry follow-ups + +## Connection field presentation (Tabularis PR #629) + +`connection-fields.json` is an **additive operator extension delta**, copied from the host manifest contract at Tabularis commit `d7cb7288a6c7fe6656491ca46ae70fa42924de6e`. It describes presentation metadata only, never credentials or secret values. It is not a new Tabularium core field. + +Operator rollout: + +1. Back up the current global manifest extensions and `driver` kind configuration. +2. Merge the delta into the existing global extensions. If `driver` has its own nonempty override, merge it there as well: kind overrides **replace**, rather than extend, the global delta. Never replace the complete configuration with this small fragment. +3. Preserve all existing required flags and unrelated driver metadata; do not modify or enable the `theme` kind. +4. Check `/manifest.schema.json?kind=driver`, then use `/api/manifest/validate` to test a complete driver manifest with `connection_fields`. Verify that manifests without the optional field still work and that unknown field names, oversized strings and invalid types are rejected. +5. Coordinate the supporting Tabularis runtime and the dependent BigQuery plugin release. Schema acceptance does not establish host/keychain compatibility. Do not publish the dependent plugin just because validation passes. +6. Roll back by restoring the original operator configurations if validation regresses. + +The registry's strict authoring endpoint rejects unknown properties; lenient ingestion may strip them. Both are reasons to add the extension before depending on this metadata. Merely deploying this repository does **not** apply the operator delta. + +## Download statistics (Tabularis PR #793) + +The frontend follow-up shows compact locale-aware counts, exact accessible card labels, and distinct loading/empty/unavailable/populated states. It does not change counting APIs or perform tracked downloads. It can ship independently of theme-package support. + +Keep the theme rollout gated: packaged platform acceptance, real runtime/tooling version assignments, old-client/profile protection, staged kind admission, and published author journeys are still separate requirements. Neither this operator delta nor the statistics UI enables themes. diff --git a/deploy/tabularis/connection-fields.json b/deploy/tabularis/connection-fields.json new file mode 100644 index 0000000..3901288 --- /dev/null +++ b/deploy/tabularis/connection-fields.json @@ -0,0 +1,30 @@ +{ + "connection_fields": { + "type": "object", + "description": "Optional presentation overrides for host-owned connection fields. Omitted fields keep the standard Tabularis behavior.", + "additionalProperties": false, + "patternProperties": { + "^(host|port|username|password|database)$": { + "type": "object", + "additionalProperties": false, + "properties": { + "hidden": { + "type": "boolean", + "default": false, + "description": "Hide this field for the driver." + }, + "label": { + "type": "string", + "maxLength": 80, + "description": "Driver-specific label." + }, + "placeholder": { + "type": "string", + "maxLength": 200, + "description": "Driver-specific input placeholder." + } + } + } + } + } +} diff --git a/scripts/check-vercel-cache.ts b/scripts/check-vercel-cache.ts new file mode 100644 index 0000000..bbf2407 --- /dev/null +++ b/scripts/check-vercel-cache.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve, join } from 'node:path' +import { $ } from 'bun' + +const app = process.argv[2] +assert(app === 'website' || app === 'docs', 'Expected website or docs') +const root = resolve(import.meta.dir, '..') +const output = join(root, 'apps', app, '.vercel/output') +const cache = await mkdtemp(join(tmpdir(), 'tabularium-vercel-cache-')) +const args = ['run', 'build', `--filter=@tabularium/${app}`, '--cache=local:rw', `--cache-dir=${cache}`] + +async function snapshot() { + assert.equal((await Bun.file(join(output, 'config.json')).json()).version, 3) + const files = [] + for await (const file of new Bun.Glob('**/*').scan({ cwd: output, onlyFiles: true, followSymlinks: true })) { + const hash = createHash('sha256') + .update(await Bun.file(join(output, file)).bytes()) + .digest('hex') + files.push(`${file}:${hash}`) + } + assert(files.length > 1, 'Expected deployment payloads, not only a config file') + return files.sort() +} + +try { + await $`bunx turbo ${args}`.cwd(root) + const before = await snapshot() + await rm(output, { recursive: true, force: true }) + await rm(join(root, 'apps', app, '.svelte-kit'), { recursive: true, force: true }) + const plan = await $`bunx turbo ${args} --dry=json`.cwd(root).quiet().json() + assert.equal( + plan.tasks.find((task: { taskId: string }) => task.taskId === `@tabularium/${app}#build`)?.cache.status, + 'HIT', + ) + await $`bunx turbo ${args}`.cwd(root) + assert.deepEqual(await snapshot(), before, 'Cached build must restore every Vercel output file') + console.log(`PASS: ${app} cache hit restored ${before.length} identical deployment files`) +} finally { + await rm(cache, { recursive: true, force: true }) +} diff --git a/turbo.json b/turbo.json index 1ea665f..437fd8e 100644 --- a/turbo.json +++ b/turbo.json @@ -8,7 +8,7 @@ }, "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**", ".svelte-kit/**", ".vercel/output/**"] }, "test": { "outputs": [],