diff --git a/projects/internals/metadata/README.md b/projects/internals/metadata/README.md index c339a5fdab..921792e3aa 100644 --- a/projects/internals/metadata/README.md +++ b/projects/internals/metadata/README.md @@ -26,6 +26,7 @@ Node.js scripts that parse the monorepo and generate static JSON metadata files: - **`lighthouse.ts`** - Collects performance metrics (bundled via Git LFS) - **`wireit.ts`** - Generates build dependency graph visualization data - **`releases.ts`** - Parses git history to extract package release information +- **`adoption.ts`** - Refreshes public npm, jsDelivr, and GitHub adoption metrics ### 2. Services (`src/services/`) @@ -37,6 +38,7 @@ Runtime services that provide cached access to generated metadata with fallback - **`TestsService`** - Exposes test coverage and results - **`ReleasesService`** - Returns release history - **`WireitService`** - Exposes build graph data +- **`AdoptionService`** - Returns the public adoption metrics snapshot Services use a **dual-loading pattern**: first attempting to load from local `static/*.json` files, then falling back to fetching from public CDN if unavailable. @@ -83,3 +85,6 @@ The metadata scripts generate common metadata about projects in the repo. This i - `pnpm run generate:examples`: runs the examples metadata script gathering all the source examples from the packages. - `pnpm run generate:wireit`: runs the wireit script that gathers all metadata details about the CI build and wireit dependencies. +## Manual/scheduled tasks + +- `pnpm run generate:adoption`: refreshes the public adoption metrics snapshot from npm, jsDelivr, and GitHub public APIs. This task is not part of normal CI because it uses live public network data. diff --git a/projects/internals/metadata/package.json b/projects/internals/metadata/package.json index 33c4874247..430c32fb36 100644 --- a/projects/internals/metadata/package.json +++ b/projects/internals/metadata/package.json @@ -4,12 +4,13 @@ "private": true, "version": "0.0.0", "scripts": { - "dev": "node --experimental-strip-types ./src/metadata.ts", + "dev": "node ./src/metadata.ts", "ci": "wireit", "build": "wireit", "generate:api": "wireit", "generate:tests": "wireit", "generate:examples": "wireit", + "generate:adoption": "wireit", "generate:lighthouse": "wireit", "generate:wireit": "wireit", "generate:releases": "wireit", @@ -28,6 +29,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./services/adoption.service.js": { + "types": "./dist/services/adoption.service.d.ts", + "default": "./dist/src/services/adoption.service.js" + }, "./services/api.service.js": { "types": "./dist/services/api.service.d.ts", "default": "./dist/src/services/api.service.js" @@ -65,6 +70,7 @@ "files": [ "src/**", "!src/**/*.test.ts", + "static/adoption.json", "static/lighthouse.json", "static/releases.json", "static/tests.json", @@ -85,7 +91,7 @@ ] }, "generate:api": { - "command": "node --experimental-strip-types ./src/tasks/api.ts", + "command": "node ./src/tasks/api.ts", "files": [ "./src/tasks/api.ts", "./src/tasks/api.utils.ts", @@ -143,7 +149,7 @@ ] }, "generate:projects": { - "command": "node --experimental-strip-types ./src/tasks/projects.ts", + "command": "node ./src/tasks/projects.ts", "files": [ "./src/tasks/projects.ts", "./src/tasks/projects.utils.ts", @@ -177,7 +183,7 @@ ] }, "generate:tests": { - "command": "node --experimental-strip-types ./src/tasks/tests.ts", + "command": "node ./src/tasks/tests.ts", "files": [ "./src/tasks/tests.ts", "./src/tasks/tests.utils.ts", @@ -272,7 +278,7 @@ ] }, "generate:examples": { - "command": "node --experimental-strip-types ./src/tasks/examples.ts", + "command": "node ./src/tasks/examples.ts", "clean": false, "files": [ "./src/tasks/examples.ts", @@ -321,8 +327,20 @@ } ] }, + "generate:adoption": { + "command": "node ./src/tasks/adoption.ts", + "clean": false, + "files": [ + "src/tasks/adoption.ts", + "src/tasks/adoption.utils.ts", + "../../*/package.json" + ], + "output": [ + "static/adoption.json" + ] + }, "generate:lighthouse": { - "command": "node --experimental-strip-types ./src/tasks/lighthouse.ts", + "command": "node ./src/tasks/lighthouse.ts", "clean": false, "files": [ "src/tasks/lighthouse.ts", @@ -336,7 +354,7 @@ ] }, "generate:wireit": { - "command": "node --experimental-strip-types ./src/tasks/wireit.ts", + "command": "node ./src/tasks/wireit.ts", "clean": false, "files": [ "src/tasks/wireit.ts", @@ -348,7 +366,7 @@ "dependencies": [] }, "generate:releases": { - "command": "node --experimental-strip-types ./src/tasks/releases.ts", + "command": "node ./src/tasks/releases.ts", "clean": false, "files": [ "src/tasks/releases.ts", diff --git a/projects/internals/metadata/src/index.ts b/projects/internals/metadata/src/index.ts index fd69b44417..16e8defbcd 100644 --- a/projects/internals/metadata/src/index.ts +++ b/projects/internals/metadata/src/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export { ExamplesService } from './services/examples.service.js'; +export * from './services/adoption.service.js'; export * from './services/api.service.js'; export * from './services/tests.service.js'; export * from './services/wireit.service.js'; diff --git a/projects/internals/metadata/src/services/adoption.service.test.ts b/projects/internals/metadata/src/services/adoption.service.test.ts new file mode 100644 index 0000000000..1a1eeef0e7 --- /dev/null +++ b/projects/internals/metadata/src/services/adoption.service.test.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { AdoptionService } from './adoption.service.js'; + +describe('AdoptionService', () => { + it('should return the adoption data', async () => { + const adoption = await AdoptionService.getData(); + + expect(adoption.created).toBeDefined(); + expect(adoption.packages.length).toBeGreaterThan(0); + expect(adoption.totals.packages).toBe(adoption.packages.length); + expect(adoption.github.repository).toBe('NVIDIA/elements'); + expect(await AdoptionService.getData()).toEqual(adoption); + + adoption.github.repository = 'modified'; + expect((await AdoptionService.getData()).github.repository).toBe('NVIDIA/elements'); + }); +}); diff --git a/projects/internals/metadata/src/services/adoption.service.ts b/projects/internals/metadata/src/services/adoption.service.ts new file mode 100644 index 0000000000..5d1f4ca35f --- /dev/null +++ b/projects/internals/metadata/src/services/adoption.service.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AdoptionSummary } from '../types.js'; + +export class AdoptionService { + static #adoption: AdoptionSummary = { + created: '', + period: '', + sources: { + npmDownloads: '', + npmRegistry: '', + jsdelivr: '', + github: '' + }, + totals: { + packages: 0, + publishedPackages: 0, + partialPackages: 0, + unavailablePackages: 0, + npmDownloads: 0, + cdnRequests: 0 + }, + packages: [], + github: { + repository: '', + stars: 0, + forks: 0, + subscribers: 0, + contributors: 0, + releases: 0, + stargazers: [], + errors: [] + } + }; + + static async getData(): Promise { + if (AdoptionService.#adoption.created === '') { + AdoptionService.#adoption = (await import('../../static/adoption.json', { with: { type: 'json' } })) + .default as AdoptionSummary; + } + + return structuredClone(AdoptionService.#adoption); + } +} diff --git a/projects/internals/metadata/src/services/releases.service.test.ts b/projects/internals/metadata/src/services/releases.service.test.ts index ff61643b9f..644ebcc0ce 100644 --- a/projects/internals/metadata/src/services/releases.service.test.ts +++ b/projects/internals/metadata/src/services/releases.service.test.ts @@ -10,6 +10,7 @@ describe('ReleasesService', () => { expect(releases).toBeDefined(); expect(releases.created).toBeDefined(); expect(releases.data).toBeDefined(); - expect(releases.data.length).toBeGreaterThan(0); + expect(releases.data.length).toBeGreaterThan(1); + expect(releases.data.some(release => release.name === '@nvidia-elements/core-v2.0.2')).toBe(true); }); }); diff --git a/projects/internals/metadata/src/services/wireit.service.test.ts b/projects/internals/metadata/src/services/wireit.service.test.ts index facd369f3f..b7cb102fd1 100644 --- a/projects/internals/metadata/src/services/wireit.service.test.ts +++ b/projects/internals/metadata/src/services/wireit.service.test.ts @@ -6,6 +6,10 @@ import { WireitService } from './wireit.service.js'; describe('WireitService', () => { it('should return the wireit graph data', async () => { - expect(WireitService.getData).toBeTruthy(); + const graph = await WireitService.getData(); + + expect(graph.nodes.length).toBeGreaterThan(0); + expect(graph.links.length).toBeGreaterThan(0); + expect(await WireitService.getData()).toBe(graph); }); }); diff --git a/projects/internals/metadata/src/tasks/adoption.ts b/projects/internals/metadata/src/tasks/adoption.ts new file mode 100644 index 0000000000..7df1c67567 --- /dev/null +++ b/projects/internals/metadata/src/tasks/adoption.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { getAdoptionData } from './adoption.utils.ts'; + +const adoption = await getAdoptionData(); + +writeFileSync(resolve(import.meta.dirname, '../../static/adoption.json'), JSON.stringify(adoption, null, 2)); + +console.log('✅ Adoption metrics generated successfully.'); diff --git a/projects/internals/metadata/src/tasks/adoption.utils.test.ts b/projects/internals/metadata/src/tasks/adoption.utils.test.ts new file mode 100644 index 0000000000..d18e1c0bf8 --- /dev/null +++ b/projects/internals/metadata/src/tasks/adoption.utils.test.ts @@ -0,0 +1,382 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createAdoptionSummary, + fetchPublicJson, + getGitHubMetrics, + getPackageAdoptionData, + getWorkspacePackages, + parseGitHubPaginationTotal, + parseGitHubRepository, + parseGitHubStargazers, + parseJsDelivrStats, + parseNpmDownloads, + parseNpmRegistry +} from './adoption.utils.ts'; + +const npmDownloadsFixture = { + start: '2026-06-01', + end: '2026-06-03', + package: '@nvidia-elements/core', + downloads: [ + { downloads: 4, day: '2026-06-01' }, + { downloads: 0, day: '2026-06-02' }, + { downloads: 9, day: '2026-06-03' } + ] +}; + +const npmRegistryFixture = { + name: '@nvidia-elements/core', + 'dist-tags': { + latest: '2.0.2' + }, + versions: { + '2.0.1': {}, + '2.0.2': {} + }, + time: { + created: '2026-05-01T00:00:00.000Z', + modified: '2026-06-23T00:00:00.000Z', + '2.0.1': '2026-06-10T00:00:00.000Z', + '2.0.2': '2026-06-23T00:00:00.000Z' + } +}; + +const jsDelivrFixture = { + rank: 40357, + typeRank: 18103, + total: 100, + versions: { + '2.0.1': { + total: 70, + dates: { + '2026-06-01': 30, + '2026-06-02': 40 + } + }, + '2.0.2': { + total: 30, + dates: { + '2026-06-01': 10, + '2026-06-02': 20 + } + } + }, + commits: {}, + branches: {} +}; + +const githubRepositoryFixture = { + stargazers_count: 23, + forks_count: 4, + subscribers_count: 3 +}; + +const githubStargazersFixture = [ + { starred_at: '2026-03-19T17:13:15Z', user: { login: 'one' } }, + { starred_at: '2026-03-20T08:45:43Z', user: { login: 'two' } }, + { starred_at: '2026-04-01T08:45:43Z', user: { login: 'three' } } +]; + +function jsonResponse(data: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set('content-type', 'application/json'); + + return new Response(JSON.stringify(data), { + ...init, + status: init.status ?? 200, + headers + }); +} + +function hasUrlHostname(url: string, hostname: string): boolean { + return new URL(url).hostname === hostname; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('adoption utilities', () => { + it('should parse npm downloads totals', () => { + const downloads = parseNpmDownloads(npmDownloadsFixture); + + expect(downloads).toEqual({ + start: '2026-06-01', + end: '2026-06-03', + total: 13, + daily: [ + { date: '2026-06-01', count: 4 }, + { date: '2026-06-02', count: 0 }, + { date: '2026-06-03', count: 9 } + ] + }); + }); + + it('should parse npm registry publish metadata', () => { + const registry = parseNpmRegistry(npmRegistryFixture); + + expect(registry).toEqual({ + latestVersion: '2.0.2', + publishedAt: '2026-06-23T00:00:00.000Z', + versionCount: 2, + publishDates: [ + { version: '2.0.1', date: '2026-06-10T00:00:00.000Z' }, + { version: '2.0.2', date: '2026-06-23T00:00:00.000Z' } + ] + }); + }); + + it('should parse jsDelivr totals and latest version share', () => { + const cdn = parseJsDelivrStats(jsDelivrFixture, '2.0.2'); + + expect(cdn.total).toBe(100); + expect(cdn.topVersion).toEqual({ version: '2.0.1', total: 70, share: 70 }); + expect(cdn.latestVersionShare).toBe(30); + expect(cdn.daily).toEqual([ + { date: '2026-06-01', count: 40 }, + { date: '2026-06-02', count: 60 } + ]); + }); + + it('should parse GitHub public interest metadata', () => { + expect(parseGitHubRepository(githubRepositoryFixture)).toEqual({ + stars: 23, + forks: 4, + subscribers: 3 + }); + expect( + parseGitHubPaginationTotal('; rel="last"', 1) + ).toBe(68); + expect(parseGitHubStargazers(githubStargazersFixture)).toEqual([ + { month: '2026-03', stars: 2, cumulativeStars: 2 }, + { month: '2026-04', stars: 1, cumulativeStars: 3 } + ]); + }); + + it('should read workspace packages from project package files', async () => { + const root = await mkdtemp(join(tmpdir(), 'metadata-adoption-')); + + await Promise.all([ + mkdir(join(root, 'core')), + mkdir(join(root, 'private-project')), + mkdir(join(root, 'site')), + mkdir(join(root, 'notes')) + ]); + await Promise.all([ + writeFile( + join(root, 'core', 'package.json'), + JSON.stringify({ name: '@nvidia-elements/core', version: '2.0.2' }) + ), + writeFile( + join(root, 'private-project', 'package.json'), + JSON.stringify({ name: '@nvidia-elements/private', version: '0.0.0', private: true }) + ), + writeFile(join(root, 'site', 'package.json'), JSON.stringify({ name: 'site', version: '0.0.0' })) + ]); + + const packages = await getWorkspacePackages(pathToFileURL(`${root}/`)); + + expect(packages).toEqual([{ name: '@nvidia-elements/core', workspaceVersion: '2.0.2' }]); + await rm(root, { recursive: true, force: true }); + }); + + it('should capture fetch failures without throwing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 503, statusText: 'Service Unavailable' })) + ); + + const result = await fetchPublicJson('https://example.test/error', 'npm-registry'); + + expect(result).toEqual({ + ok: false, + error: { + source: 'npm-registry', + status: 503, + message: '503 Service Unavailable' + } + }); + }); + + it('should mark unpublished packages unavailable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (hasUrlHostname(url, 'api.npmjs.org')) { + return jsonResponse(npmDownloadsFixture); + } + + if (hasUrlHostname(url, 'data.jsdelivr.com')) { + return jsonResponse(jsDelivrFixture); + } + + return new Response('', { status: 404, statusText: 'Not Found' }); + }) + ); + + const packageData = await getPackageAdoptionData({ + name: '@nvidia-elements/media', + workspaceVersion: '0.0.0' + }); + + expect(packageData.status).toBe('unavailable'); + expect(packageData.latestVersion).toBeNull(); + expect(packageData.npm.total).toBe(13); + expect(packageData.cdn.total).toBe(100); + expect(packageData.errors.map(error => error.source)).toEqual(['npm-registry']); + }); + + it('should preserve available package data when one source fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (hasUrlHostname(url, 'registry.npmjs.org')) { + return jsonResponse(npmRegistryFixture); + } + + if (hasUrlHostname(url, 'api.npmjs.org')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + return jsonResponse(jsDelivrFixture); + }) + ); + + const packageData = await getPackageAdoptionData({ + name: '@nvidia-elements/core', + workspaceVersion: '2.0.2' + }); + + expect(packageData.status).toBe('partial'); + expect(packageData.latestVersion).toBe('2.0.2'); + expect(packageData.npm.total).toBe(0); + expect(packageData.cdn.total).toBe(100); + expect(packageData.errors).toEqual([ + { + source: 'npm-downloads', + status: 500, + message: '500 Internal Server Error' + } + ]); + }); + + it('should parse GitHub metrics from public endpoints', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url.endsWith('/stargazers?per_page=100')) { + return jsonResponse(githubStargazersFixture.slice(0, 2), { + headers: { + link: '; rel="last"' + } + }); + } + + if (url.endsWith('/stargazers?per_page=100&page=2')) { + return jsonResponse(githubStargazersFixture.slice(2)); + } + + if (url.endsWith('/contributors?per_page=1')) { + return jsonResponse([{ login: 'contributor' }], { + headers: { + link: '; rel="last"' + } + }); + } + + if (url.endsWith('/releases?per_page=1')) { + return jsonResponse([{ tag_name: '@nvidia-elements/core-v2.0.2' }], { + headers: { + link: '; rel="last"' + } + }); + } + + return jsonResponse(githubRepositoryFixture); + }) + ); + + const metrics = await getGitHubMetrics(); + + expect(metrics).toMatchObject({ + repository: 'NVIDIA/elements', + stars: 23, + forks: 4, + subscribers: 3, + contributors: 15, + releases: 68, + errors: [] + }); + expect(metrics.stargazers).toHaveLength(2); + }); + + it('should create adoption totals without counting unavailable packages as published', () => { + const github = { + repository: 'NVIDIA/elements', + stars: 23, + forks: 4, + subscribers: 3, + contributors: 15, + releases: 68, + stargazers: [], + errors: [] + }; + const summary = createAdoptionSummary( + [ + { + name: '@nvidia-elements/core', + workspaceVersion: '2.0.2', + status: 'published', + latestVersion: '2.0.2', + publishedAt: '2026-06-23T00:00:00.000Z', + versionCount: 2, + publishDates: [], + npm: { start: '2026-06-01', end: '2026-06-03', total: 13, daily: [] }, + cdn: { ...parseJsDelivrStats(jsDelivrFixture, '2.0.2'), total: 100 }, + errors: [] + }, + { + name: '@nvidia-elements/code', + workspaceVersion: '2.0.1', + status: 'partial', + latestVersion: '2.0.1', + publishedAt: '2026-06-20T00:00:00.000Z', + versionCount: 1, + publishDates: [], + npm: { start: '2026-06-01', end: '2026-06-03', total: 7, daily: [] }, + cdn: { ...parseJsDelivrStats({}, '2.0.1'), total: 10 }, + errors: [] + }, + { + name: '@nvidia-elements/media', + workspaceVersion: '0.0.0', + status: 'unavailable', + latestVersion: null, + publishedAt: null, + versionCount: 0, + publishDates: [], + npm: { start: '', end: '', total: 0, daily: [] }, + cdn: parseJsDelivrStats({}, null), + errors: [] + } + ], + github + ); + + expect(summary.totals).toEqual({ + packages: 3, + publishedPackages: 1, + partialPackages: 1, + unavailablePackages: 1, + npmDownloads: 20, + cdnRequests: 110 + }); + }); +}); diff --git a/projects/internals/metadata/src/tasks/adoption.utils.ts b/projects/internals/metadata/src/tasks/adoption.utils.ts new file mode 100644 index 0000000000..c1af25111e --- /dev/null +++ b/projects/internals/metadata/src/tasks/adoption.utils.ts @@ -0,0 +1,487 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readdir, readFile } from 'node:fs/promises'; +import type { + AdoptionCdnStats, + AdoptionDailyCount, + AdoptionGitHubMetrics, + AdoptionGitHubStargazerMonth, + AdoptionNpmDownloads, + AdoptionPackage, + AdoptionPublishDate, + AdoptionSource, + AdoptionSourceError, + AdoptionSummary, + AdoptionTopCdnVersion +} from '../types.js'; + +interface WorkspacePackage { + name: string; + workspaceVersion: string; +} + +interface NpmRegistryMetadata { + latestVersion: string | null; + publishedAt: string | null; + versionCount: number; + publishDates: AdoptionPublishDate[]; +} + +interface GitHubRepositoryMetrics { + stars: number; + forks: number; + subscribers: number; +} + +interface GitHubStargazersResult { + data: unknown[]; + errors: AdoptionSourceError[]; +} + +type PublicJsonResult = + | { + ok: true; + data: unknown; + headers: Headers; + } + | { + ok: false; + error: AdoptionSourceError; + }; + +type UnknownRecord = Record; + +const repository = 'NVIDIA/elements'; +const period = 'last-month'; +const githubHeaders = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' +}; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isWorkspacePackage(value: WorkspacePackage | null): value is WorkspacePackage { + return value !== null; +} + +function getRecord(value: unknown, key: string): UnknownRecord | null { + if (!isRecord(value)) { + return null; + } + + const property = value[key]; + return isRecord(property) ? property : null; +} + +function getString(value: unknown, key: string): string | null { + if (!isRecord(value)) { + return null; + } + + const property = value[key]; + return typeof property === 'string' ? property : null; +} + +function getNumber(value: unknown, key: string): number | null { + if (!isRecord(value)) { + return null; + } + + const property = value[key]; + return typeof property === 'number' && Number.isFinite(property) ? property : null; +} + +function getArray(value: unknown, key: string): unknown[] { + if (!isRecord(value)) { + return []; + } + + const property = value[key]; + return Array.isArray(property) ? property : []; +} + +function roundPercentage(value: number): number { + return Number(value.toFixed(2)); +} + +function getDateRecordCounts(dates: UnknownRecord | null): AdoptionDailyCount[] { + return Object.entries(dates ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +function createEmptyNpmDownloads(): AdoptionNpmDownloads { + return { + start: '', + end: '', + total: 0, + daily: [] + }; +} + +function createEmptyCdnStats(): AdoptionCdnStats { + return { + rank: null, + typeRank: null, + total: 0, + daily: [], + versions: [], + topVersion: null, + latestVersionShare: 0 + }; +} + +function getSourceError(source: AdoptionSource, status: number | null, message: string): AdoptionSourceError { + return { + source, + status, + message + }; +} + +async function getPackageFile(directoryUrl: URL): Promise { + try { + const packageJson: unknown = JSON.parse(await readFile(new URL('package.json', directoryUrl), 'utf8')); + const name = getString(packageJson, 'name'); + const workspaceVersion = getString(packageJson, 'version'); + const isPrivate = isRecord(packageJson) && packageJson.private === true; + + if (!name?.startsWith('@nvidia-elements/') || !workspaceVersion || isPrivate) { + return null; + } + + return { + name, + workspaceVersion + }; + } catch { + // Not every projects directory entry is an npm package. + return null; + } +} + +export async function getWorkspacePackages( + projectsRoot = new URL('../../../../', import.meta.url) +): Promise { + const entries = await readdir(projectsRoot, { withFileTypes: true }); + const packages = await Promise.all( + entries.filter(entry => entry.isDirectory()).map(entry => getPackageFile(new URL(`${entry.name}/`, projectsRoot))) + ); + + return packages.filter(isWorkspacePackage).sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function fetchPublicJson( + url: string, + source: AdoptionSource, + headers: Record = {} +): Promise { + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + const statusText = response.statusText.trim(); + return { + ok: false, + error: getSourceError( + source, + response.status, + statusText ? `${response.status} ${statusText}` : `HTTP ${response.status}` + ) + }; + } + + return { + ok: true, + data: await response.json(), + headers: response.headers + }; + } catch (error) { + // Network endpoints can fail independently of the generator process. + return { + ok: false, + error: getSourceError(source, null, error instanceof Error ? error.message : 'unknown network error') + }; + } +} + +export function parseNpmDownloads(data: unknown): AdoptionNpmDownloads { + const daily = getArray(data, 'downloads') + .filter(isRecord) + .map(download => ({ + date: getString(download, 'day') ?? '', + count: getNumber(download, 'downloads') ?? 0 + })) + .filter(download => download.date !== '') + .sort((a, b) => a.date.localeCompare(b.date)); + + return { + start: getString(data, 'start') ?? daily[0]?.date ?? '', + end: getString(data, 'end') ?? daily.at(-1)?.date ?? '', + total: daily.reduce((total, day) => total + day.count, 0), + daily + }; +} + +export function parseNpmRegistry(data: unknown): NpmRegistryMetadata | null { + const distTags = getRecord(data, 'dist-tags'); + const latestVersion = getString(distTags, 'latest'); + const versions = getRecord(data, 'versions'); + const time = getRecord(data, 'time'); + + if (!latestVersion || !versions || !time) { + return null; + } + + const publishDates = Object.keys(versions) + .map(version => ({ + version, + date: typeof time[version] === 'string' ? time[version] : '' + })) + .filter(publishDate => publishDate.date !== '') + .sort((a, b) => a.date.localeCompare(b.date)); + + return { + latestVersion, + publishedAt: typeof time[latestVersion] === 'string' ? time[latestVersion] : null, + versionCount: Object.keys(versions).length, + publishDates + }; +} + +export function parseJsDelivrStats(data: unknown, latestVersion: string | null): AdoptionCdnStats { + const total = getNumber(data, 'total') ?? 0; + const versions = Object.entries(getRecord(data, 'versions') ?? {}) + .filter((entry): entry is [string, UnknownRecord] => isRecord(entry[1])) + .map(([version, versionData]) => { + const versionTotal = getNumber(versionData, 'total') ?? 0; + + return { + version, + total: versionTotal, + share: total > 0 ? roundPercentage((versionTotal / total) * 100) : 0, + daily: getDateRecordCounts(getRecord(versionData, 'dates')) + }; + }) + .sort((a, b) => b.total - a.total); + const dailyByDate = new Map(); + + versions + .flatMap(version => version.daily) + .forEach(day => { + dailyByDate.set(day.date, (dailyByDate.get(day.date) ?? 0) + day.count); + }); + + const topVersionData = versions[0]; + const topVersion: AdoptionTopCdnVersion | null = topVersionData + ? { + version: topVersionData.version, + total: topVersionData.total, + share: topVersionData.share + } + : null; + const latestVersionStats = latestVersion ? versions.find(version => version.version === latestVersion) : undefined; + + return { + rank: getNumber(data, 'rank'), + typeRank: getNumber(data, 'typeRank'), + total, + daily: [...dailyByDate.entries()] + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)), + versions, + topVersion, + latestVersionShare: latestVersionStats?.share ?? 0 + }; +} + +export function parseGitHubRepository(data: unknown): GitHubRepositoryMetrics { + return { + stars: getNumber(data, 'stargazers_count') ?? 0, + forks: getNumber(data, 'forks_count') ?? 0, + subscribers: getNumber(data, 'subscribers_count') ?? 0 + }; +} + +export function parseGitHubPaginationTotal(linkHeader: string | null, fallbackTotal: number): number { + const pageMatch = linkHeader?.match(/[?&]page=(\d+)>;\s*rel="last"/); + return pageMatch?.[1] ? Number(pageMatch[1]) : fallbackTotal; +} + +export function parseGitHubStargazers(data: unknown): AdoptionGitHubStargazerMonth[] { + const monthCounts = new Map(); + + (Array.isArray(data) ? data : []) + .filter(isRecord) + .map(stargazer => getString(stargazer, 'starred_at')) + .filter((starredAt): starredAt is string => starredAt !== null) + .map(starredAt => starredAt.slice(0, 7)) + .forEach(month => { + monthCounts.set(month, (monthCounts.get(month) ?? 0) + 1); + }); + + let cumulativeStars = 0; + return [...monthCounts.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, stars]) => { + cumulativeStars += stars; + + return { + month, + stars, + cumulativeStars + }; + }); +} + +async function getGitHubStargazerData(headers: Record): Promise { + const firstPage = await fetchPublicJson( + `https://api.github.com/repos/${repository}/stargazers?per_page=100`, + 'github', + { + ...headers, + Accept: 'application/vnd.github.star+json' + } + ); + + if (!firstPage.ok) { + return { + data: [], + errors: [firstPage.error] + }; + } + + const totalPages = parseGitHubPaginationTotal(firstPage.headers.get('link'), 1); + const remainingPages = await Promise.all( + Array.from({ length: Math.max(totalPages - 1, 0) }, (_, index) => + fetchPublicJson( + `https://api.github.com/repos/${repository}/stargazers?per_page=100&page=${index + 2}`, + 'github', + { + ...headers, + Accept: 'application/vnd.github.star+json' + } + ) + ) + ); + + return { + data: [firstPage, ...remainingPages].flatMap(result => + result.ok && Array.isArray(result.data) ? result.data : [] + ), + errors: remainingPages.flatMap(result => (result.ok ? [] : [result.error])) + }; +} + +export async function getPackageAdoptionData(workspacePackage: WorkspacePackage): Promise { + const encodedPackage = encodeURIComponent(workspacePackage.name); + const [registryResult, downloadsResult, cdnResult] = await Promise.all([ + fetchPublicJson(`https://registry.npmjs.org/${encodedPackage}`, 'npm-registry'), + fetchPublicJson(`https://api.npmjs.org/downloads/range/${period}/${encodedPackage}`, 'npm-downloads'), + fetchPublicJson(`https://data.jsdelivr.com/v1/package/npm/${encodedPackage}/stats/month`, 'jsdelivr') + ]); + const errors = [registryResult, downloadsResult, cdnResult].flatMap(result => (result.ok ? [] : [result.error])); + const registry = registryResult.ok ? parseNpmRegistry(registryResult.data) : null; + + if (!registry) { + return { + name: workspacePackage.name, + workspaceVersion: workspacePackage.workspaceVersion, + status: registryResult.ok ? 'partial' : 'unavailable', + latestVersion: null, + publishedAt: null, + versionCount: 0, + publishDates: [], + npm: downloadsResult.ok ? parseNpmDownloads(downloadsResult.data) : createEmptyNpmDownloads(), + cdn: cdnResult.ok ? parseJsDelivrStats(cdnResult.data, null) : createEmptyCdnStats(), + errors + }; + } + + return { + name: workspacePackage.name, + workspaceVersion: workspacePackage.workspaceVersion, + status: errors.length === 0 ? 'published' : 'partial', + latestVersion: registry.latestVersion, + publishedAt: registry.publishedAt, + versionCount: registry.versionCount, + publishDates: registry.publishDates, + npm: downloadsResult.ok ? parseNpmDownloads(downloadsResult.data) : createEmptyNpmDownloads(), + cdn: cdnResult.ok ? parseJsDelivrStats(cdnResult.data, registry.latestVersion) : createEmptyCdnStats(), + errors + }; +} + +export async function getGitHubMetrics(): Promise { + const token = process.env.GITHUB_TOKEN; + const authorizationHeaders = token ? { ...githubHeaders, Authorization: `Bearer ${token}` } : githubHeaders; + const [repositoryResult, stargazersResult, contributorsResult, releasesResult] = await Promise.all([ + fetchPublicJson(`https://api.github.com/repos/${repository}`, 'github', authorizationHeaders), + getGitHubStargazerData(authorizationHeaders), + fetchPublicJson( + `https://api.github.com/repos/${repository}/contributors?per_page=1`, + 'github', + authorizationHeaders + ), + fetchPublicJson(`https://api.github.com/repos/${repository}/releases?per_page=1`, 'github', authorizationHeaders) + ]); + const errors = [repositoryResult, contributorsResult, releasesResult].flatMap(result => + result.ok ? [] : [result.error] + ); + const repositoryMetrics = repositoryResult.ok + ? parseGitHubRepository(repositoryResult.data) + : parseGitHubRepository({}); + const contributorsFallback = + contributorsResult.ok && Array.isArray(contributorsResult.data) ? contributorsResult.data.length : 0; + const releasesFallback = releasesResult.ok && Array.isArray(releasesResult.data) ? releasesResult.data.length : 0; + + return { + repository, + stars: repositoryMetrics.stars, + forks: repositoryMetrics.forks, + subscribers: repositoryMetrics.subscribers, + contributors: contributorsResult.ok + ? parseGitHubPaginationTotal(contributorsResult.headers.get('link'), contributorsFallback) + : 0, + releases: releasesResult.ok ? parseGitHubPaginationTotal(releasesResult.headers.get('link'), releasesFallback) : 0, + stargazers: parseGitHubStargazers(stargazersResult.data), + errors: [...errors, ...stargazersResult.errors] + }; +} + +export function createAdoptionSummary(packages: AdoptionPackage[], github: AdoptionGitHubMetrics): AdoptionSummary { + return { + created: new Date().toISOString(), + period, + sources: { + npmDownloads: 'https://api.npmjs.org/downloads/', + npmRegistry: 'https://registry.npmjs.org/', + jsdelivr: 'https://data.jsdelivr.com/', + github: 'https://api.github.com/repos/NVIDIA/elements' + }, + totals: { + packages: packages.length, + publishedPackages: packages.filter(packageData => packageData.status === 'published').length, + partialPackages: packages.filter(packageData => packageData.status === 'partial').length, + unavailablePackages: packages.filter(packageData => packageData.status === 'unavailable').length, + npmDownloads: packages.reduce((total, packageData) => total + packageData.npm.total, 0), + cdnRequests: packages.reduce((total, packageData) => total + packageData.cdn.total, 0) + }, + packages, + github + }; +} + +export async function getAdoptionData(): Promise { + const packages = await getWorkspacePackages(); + const [packageData, github] = await Promise.all([ + Promise.all(packages.map(getPackageAdoptionData)), + getGitHubMetrics() + ]); + + return createAdoptionSummary(packageData, github); +} diff --git a/projects/internals/metadata/src/types.ts b/projects/internals/metadata/src/types.ts index 61bbd5fbfb..794aa3bdc9 100644 --- a/projects/internals/metadata/src/types.ts +++ b/projects/internals/metadata/src/types.ts @@ -446,6 +446,146 @@ export interface WireitGraph { links: WireitGraphLink[]; } +/** + * @summary Public source used for adoption metadata. + */ +export type AdoptionSource = 'npm-downloads' | 'npm-registry' | 'jsdelivr' | 'github'; + +/** + * @summary Availability state for a package in public adoption metadata. + */ +export type AdoptionPackageStatus = 'published' | 'partial' | 'unavailable'; + +/** + * @summary Source failure captured during adoption metadata generation. + */ +export interface AdoptionSourceError { + source: AdoptionSource; + status: number | null; + message: string; +} + +/** + * @summary Daily public count for downloads, requests, or interest signals. + */ +export interface AdoptionDailyCount { + date: string; + count: number; +} + +/** + * @summary Public npm downloads over the snapshot window. + */ +export interface AdoptionNpmDownloads { + start: string; + end: string; + total: number; + daily: AdoptionDailyCount[]; +} + +/** + * @summary Published npm version timestamp. + */ +export interface AdoptionPublishDate { + version: string; + date: string; +} + +/** + * @summary jsDelivr requests for one package version. + */ +export interface AdoptionCdnVersion { + version: string; + total: number; + share: number; + daily: AdoptionDailyCount[]; +} + +/** + * @summary Top jsDelivr package version for the snapshot window. + */ +export interface AdoptionTopCdnVersion { + version: string; + total: number; + share: number; +} + +/** + * @summary Public jsDelivr request metadata over the snapshot window. + */ +export interface AdoptionCdnStats { + rank: number | null; + typeRank: number | null; + total: number; + daily: AdoptionDailyCount[]; + versions: AdoptionCdnVersion[]; + topVersion: AdoptionTopCdnVersion | null; + latestVersionShare: number; +} + +/** + * @summary Public adoption metadata for one Elements package. + */ +export interface AdoptionPackage { + name: string; + workspaceVersion: string; + status: AdoptionPackageStatus; + latestVersion: string | null; + publishedAt: string | null; + versionCount: number; + publishDates: AdoptionPublishDate[]; + npm: AdoptionNpmDownloads; + cdn: AdoptionCdnStats; + errors: AdoptionSourceError[]; +} + +/** + * @summary Monthly public GitHub star growth. + */ +export interface AdoptionGitHubStargazerMonth { + month: string; + stars: number; + cumulativeStars: number; +} + +/** + * @summary Public GitHub interest metadata. + */ +export interface AdoptionGitHubMetrics { + repository: string; + stars: number; + forks: number; + subscribers: number; + contributors: number; + releases: number; + stargazers: AdoptionGitHubStargazerMonth[]; + errors: AdoptionSourceError[]; +} + +/** + * @summary Public adoption metadata. + */ +export interface AdoptionSummary { + created: string; + period: string; + sources: { + npmDownloads: string; + npmRegistry: string; + jsdelivr: string; + github: string; + }; + totals: { + packages: number; + publishedPackages: number; + partialPackages: number; + unavailablePackages: number; + npmDownloads: number; + cdnRequests: number; + }; + packages: AdoptionPackage[]; + github: AdoptionGitHubMetrics; +} + export interface ArtifactoryInstance { instance: string; downloads: number; diff --git a/projects/internals/metadata/static/adoption.json b/projects/internals/metadata/static/adoption.json new file mode 100644 index 0000000000..2711f9c17e --- /dev/null +++ b/projects/internals/metadata/static/adoption.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:143af416f041570db6ebe96d7b3f4ad2af9f9ce54224b4c84f90208bb6a226ee +size 113967 diff --git a/projects/internals/metadata/static/releases.json b/projects/internals/metadata/static/releases.json index dbc73c8f21..03535f77d1 100644 --- a/projects/internals/metadata/static/releases.json +++ b/projects/internals/metadata/static/releases.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:020e9d6401053af62ee30de9bfa22a63974f752a009bb13dfe561b24683e2758 -size 205 +oid sha256:0b0386ccd025d07fa11cb1256899292743f3fcdf70e2af41d99294afdaef4b86 +size 13631 diff --git a/projects/internals/metadata/vitest.config.ts b/projects/internals/metadata/vitest.config.ts index 08113a9956..62bd96d118 100644 --- a/projects/internals/metadata/vitest.config.ts +++ b/projects/internals/metadata/vitest.config.ts @@ -21,6 +21,7 @@ export default mergeConfig(libraryNodeTestConfig, { 'src/services/tests.service.ts', 'src/tasks/metadata.ts', 'src/tasks/metadata.utils.ts', + 'src/tasks/adoption.ts', 'src/tasks/lighthouse.ts', 'src/tasks/tests.ts', 'src/tasks/releases.ts', diff --git a/projects/site/package.json b/projects/site/package.json index 6061da529a..ad8269ee94 100644 --- a/projects/site/package.json +++ b/projects/site/package.json @@ -201,7 +201,7 @@ ] }, "test": { - "command": "vitest run src/_11ty/layouts/metadata.test.ts src/_11ty/layouts/links.test.ts src/_11ty/transforms/site-urls.test.ts src/_11ty/shortcodes/api.test.ts src/_11ty/shortcodes/example.test.ts src/_11ty/plugins/llms-txt.test.ts src/_11ty/plugins/sitemap-xml.test.ts src/_11ty/utils/env.test.ts src/docs/metrics/api-status.test.ts src/examples/index.test.ts", + "command": "vitest run src/_11ty/layouts/metadata.test.ts src/_11ty/layouts/links.test.ts src/_11ty/transforms/site-urls.test.ts src/_11ty/shortcodes/api.test.ts src/_11ty/shortcodes/example.test.ts src/_11ty/plugins/llms-txt.test.ts src/_11ty/plugins/sitemap-xml.test.ts src/_11ty/utils/env.test.ts src/docs/metrics/api-status.test.ts src/examples/index.test.ts src/docs/metrics/adoption-data.test.ts src/docs/metrics/release-data.test.ts", "files": [ "src/**/*.js", "src/**/*.md", diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index 580b90cc98..55a993941c 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -133,7 +133,7 @@ export const renderBaseHead = data => { export const renderDocsNav = data => /* html */ ` - + Getting Started Getting Started Installation diff --git a/projects/site/src/docs/metrics/adoption-data.test.ts b/projects/site/src/docs/metrics/adoption-data.test.ts new file mode 100644 index 0000000000..0ba219dca9 --- /dev/null +++ b/projects/site/src/docs/metrics/adoption-data.test.ts @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + getCdnVersionShareRows, + getChannelMixPoints, + getPackageDownloadTrend, + getReleaseAdoptionTimeline +} from './adoption-data.js'; + +const adoptionFixture = { + packages: [ + { + name: '@nvidia-elements/core', + status: 'published', + latestVersion: '2.0.2', + publishDates: [], + npm: { + total: 15, + daily: [ + { date: '2026-06-01', count: 5 }, + { date: '2026-06-02', count: 10 } + ] + }, + cdn: { + total: 100, + daily: [ + { date: '2026-06-01', count: 40 }, + { date: '2026-06-02', count: 60 } + ], + versions: [ + { version: '2.0.1', total: 65, share: 65 }, + { version: '2.0.2', total: 30, share: 30 }, + { version: '0.1.4', total: 5, share: 5 } + ], + latestVersionShare: 30, + topVersion: { + version: '2.0.1', + total: 65, + share: 65 + } + } + }, + { + name: '@nvidia-elements/code', + status: 'published', + latestVersion: '2.0.1', + publishDates: [], + npm: { + total: 5, + daily: [{ date: '2026-06-02', count: 5 }] + }, + cdn: { + total: 10, + daily: [{ date: '2026-06-02', count: 10 }], + versions: [{ version: '2.0.1', total: 10, share: 100 }], + latestVersionShare: 100, + topVersion: { + version: '2.0.1', + total: 10, + share: 100 + } + } + }, + { + name: '@nvidia-elements/lint', + status: 'published', + latestVersion: '2.0.1', + publishDates: [], + npm: { + total: 40, + daily: [ + { date: '2026-06-01', count: 25 }, + { date: '2026-06-02', count: 15 } + ] + }, + cdn: { + total: 0, + daily: [], + versions: [], + latestVersionShare: 0, + topVersion: null + } + }, + { + name: '@nvidia-elements/media', + status: 'unavailable', + latestVersion: null, + publishDates: [], + npm: { + total: 0, + daily: [] + }, + cdn: { + total: 0, + daily: [], + versions: [], + latestVersionShare: 0, + topVersion: null + } + } + ] +}; + +describe('adoption chart data', () => { + it('should create package download totals for a stacked trend chart', () => { + const trend = getPackageDownloadTrend(adoptionFixture); + + expect(trend.labels).toEqual(['2026-06-01', '2026-06-02']); + expect(trend.packages).toEqual([ + { name: 'lint', total: 40, values: [25, 15] }, + { name: 'core', total: 15, values: [5, 10] }, + { name: 'code', total: 5, values: [0, 5] } + ]); + }); + + it('should expose latest version share rows for CDN adoption', () => { + expect(getCdnVersionShareRows(adoptionFixture)).toEqual([ + { + name: 'core', + latestVersion: '2.0.2', + latestVersionShare: 30, + latestVersionRequests: 30, + topVersion: '2.0.1', + topVersionShare: 65, + topNonLatestVersion: '2.0.1', + topNonLatestVersionShare: 65, + topNonLatestVersionRequests: 65, + otherVersionShare: 5, + otherVersionRequests: 5, + cdnRequests: 100 + }, + { + name: 'code', + latestVersion: '2.0.1', + latestVersionShare: 100, + latestVersionRequests: 10, + topVersion: '2.0.1', + topVersionShare: 100, + topNonLatestVersion: 'none', + topNonLatestVersionShare: 0, + topNonLatestVersionRequests: 0, + otherVersionShare: 0, + otherVersionRequests: 0, + cdnRequests: 10 + } + ]); + }); + + it('should select the leading non-latest CDN version from unsorted versions', () => { + const corePackage = adoptionFixture.packages[0]; + const rows = getCdnVersionShareRows({ + packages: [ + { + ...corePackage, + cdn: { + ...corePackage.cdn, + versions: [corePackage.cdn.versions[2], corePackage.cdn.versions[1], corePackage.cdn.versions[0]] + } + } + ] + }); + + expect(rows[0]).toMatchObject({ + topNonLatestVersion: '2.0.1', + topNonLatestVersionShare: 65, + topNonLatestVersionRequests: 65 + }); + }); + + it('should align release markers with adoption activity', () => { + const timeline = getReleaseAdoptionTimeline({ + packages: adoptionFixture.packages.map((packageData, index) => + index === 0 + ? { + ...packageData, + publishDates: [{ version: '2.0.2', date: '2026-06-02T12:00:00.000Z' }] + } + : packageData + ) + }); + + expect(timeline).toEqual({ + labels: ['2026-06-01', '2026-06-02'], + npmDownloads: [30, 30], + cdnRequests: [40, 70], + releaseMarkers: [{ date: '2026-06-02', label: 'core 2.0.2', value: 70 }] + }); + }); + + it('should expose npm and CDN channel mix points', () => { + expect(getChannelMixPoints(adoptionFixture)).toEqual([ + { name: 'core', status: 'published', x: 15, y: 100 }, + { name: 'lint', status: 'published', x: 40, y: 0 }, + { name: 'code', status: 'published', x: 5, y: 10 }, + { name: 'media', status: 'unavailable', x: 0, y: 0 } + ]); + }); +}); diff --git a/projects/site/src/docs/metrics/adoption-data.ts b/projects/site/src/docs/metrics/adoption-data.ts new file mode 100644 index 0000000000..1cbaea90f3 --- /dev/null +++ b/projects/site/src/docs/metrics/adoption-data.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface PackageDownloadTrend { + labels: string[]; + packages: { + name: string; + total: number; + values: number[]; + }[]; +} + +export interface CdnVersionShareRow { + name: string; + latestVersion: string; + latestVersionShare: number; + latestVersionRequests: number; + topVersion: string; + topVersionShare: number; + topNonLatestVersion: string; + topNonLatestVersionShare: number; + topNonLatestVersionRequests: number; + otherVersionShare: number; + otherVersionRequests: number; + cdnRequests: number; +} + +export interface ChannelMixPoint { + x: number; + y: number; + name: string; + status: string; +} + +export interface ReleaseAdoptionTimeline { + labels: string[]; + npmDownloads: number[]; + cdnRequests: number[]; + releaseMarkers: { + date: string; + label: string; + value: number; + }[]; +} + +interface AdoptionDailyCountLike { + date: string; + count: number; +} + +interface AdoptionPackageLike { + name: string; + status: string; + latestVersion: string | null; + publishDates: { + version: string; + date: string; + }[]; + npm: { + total: number; + daily: AdoptionDailyCountLike[]; + }; + cdn: { + total: number; + daily: AdoptionDailyCountLike[]; + versions: { + version: string; + total: number; + share: number; + }[]; + latestVersionShare: number; + topVersion: { + version: string; + total?: number; + share: number; + } | null; + }; +} + +interface AdoptionCdnVersionLike { + version: string; + total: number; + share: number; +} + +interface AdoptionSummaryLike { + packages: AdoptionPackageLike[]; +} + +export function getPackageLabel(packageName: string): string { + return packageName.replace('@nvidia-elements/', ''); +} + +function getSortedDates(packages: AdoptionPackageLike[]): string[] { + return [ + ...new Set( + packages.flatMap(packageData => [ + ...packageData.npm.daily.map(day => day.date), + ...packageData.cdn.daily.map(day => day.date) + ]) + ) + ].sort((a, b) => a.localeCompare(b)); +} + +function getDailyValue(packageData: AdoptionPackageLike, date: string, source: 'npm' | 'cdn'): number { + const daily = source === 'npm' ? packageData.npm.daily : packageData.cdn.daily; + return daily.find(day => day.date === date)?.count ?? 0; +} + +function getLatestVersionData( + packageData: AdoptionPackageLike, + latestVersion: string +): AdoptionCdnVersionLike | undefined { + return packageData.cdn.versions.find(version => version.version === latestVersion); +} + +function getTopNonLatestVersionData( + packageData: AdoptionPackageLike, + latestVersion: string +): AdoptionCdnVersionLike | undefined { + return packageData.cdn.versions + .filter(version => version.version !== latestVersion) + .reduce< + AdoptionCdnVersionLike | undefined + >((topVersion, version) => (!topVersion || version.total > topVersion.total ? version : topVersion), undefined); +} + +function getLatestVersion(packageData: AdoptionPackageLike): string { + return packageData.latestVersion ?? 'unavailable'; +} + +function getTopVersion(packageData: AdoptionPackageLike): string { + return packageData.cdn.topVersion?.version ?? 'unavailable'; +} + +function getTopVersionShare(packageData: AdoptionPackageLike): number { + return packageData.cdn.topVersion?.share ?? 0; +} + +function getVersionName(versionData?: AdoptionCdnVersionLike): string { + return versionData?.version ?? 'none'; +} + +function getVersionShare(versionData?: AdoptionCdnVersionLike): number { + return versionData?.share ?? 0; +} + +function getVersionTotal(versionData?: AdoptionCdnVersionLike): number { + return versionData?.total ?? 0; +} + +function getRoundedPercent(value: number): number { + return Number(value.toFixed(2)); +} + +function getOtherVersionRequests( + packageData: AdoptionPackageLike, + latestRequests: number, + topNonLatestRequests: number +): number { + return Math.max(packageData.cdn.total - latestRequests - topNonLatestRequests, 0); +} + +function getOtherVersionShare( + packageData: AdoptionPackageLike, + topNonLatestVersionData?: AdoptionCdnVersionLike +): number { + return getRoundedPercent( + Math.max(100 - packageData.cdn.latestVersionShare - getVersionShare(topNonLatestVersionData), 0) + ); +} + +function getCdnVersionShareRow(packageData: AdoptionPackageLike): CdnVersionShareRow { + const latestVersion = getLatestVersion(packageData); + const latestVersionData = getLatestVersionData(packageData, latestVersion); + const topNonLatestVersionData = getTopNonLatestVersionData(packageData, latestVersion); + const latestVersionRequests = getVersionTotal(latestVersionData); + const topNonLatestVersionRequests = getVersionTotal(topNonLatestVersionData); + + return { + name: getPackageLabel(packageData.name), + latestVersion, + latestVersionShare: packageData.cdn.latestVersionShare, + latestVersionRequests, + topVersion: getTopVersion(packageData), + topVersionShare: getTopVersionShare(packageData), + topNonLatestVersion: getVersionName(topNonLatestVersionData), + topNonLatestVersionShare: getVersionShare(topNonLatestVersionData), + topNonLatestVersionRequests, + otherVersionShare: getOtherVersionShare(packageData, topNonLatestVersionData), + otherVersionRequests: getOtherVersionRequests(packageData, latestVersionRequests, topNonLatestVersionRequests), + cdnRequests: packageData.cdn.total + }; +} + +export function getPackageDownloadTrend(adoption: AdoptionSummaryLike): PackageDownloadTrend { + const labels = getSortedDates(adoption.packages); + const packages = adoption.packages + .filter(packageData => packageData.npm.total > 0) + .map(packageData => ({ + name: getPackageLabel(packageData.name), + total: packageData.npm.total, + values: labels.map(date => getDailyValue(packageData, date, 'npm')) + })) + .sort((a, b) => b.total - a.total); + + return { + labels, + packages + }; +} + +export function getCdnVersionShareRows(adoption: AdoptionSummaryLike): CdnVersionShareRow[] { + return adoption.packages + .filter(packageData => packageData.cdn.total > 0) + .map(getCdnVersionShareRow) + .sort((a, b) => b.cdnRequests - a.cdnRequests); +} + +export function getChannelMixPoints(adoption: AdoptionSummaryLike): ChannelMixPoint[] { + return adoption.packages + .map(packageData => ({ + x: packageData.npm.total, + y: packageData.cdn.total, + name: getPackageLabel(packageData.name), + status: packageData.status + })) + .sort((a, b) => b.x + b.y - (a.x + a.y)); +} + +export function getReleaseAdoptionTimeline(adoption: AdoptionSummaryLike): ReleaseAdoptionTimeline { + const labels = getSortedDates(adoption.packages); + const npmDownloads = labels.map(date => + adoption.packages.reduce((total, packageData) => total + getDailyValue(packageData, date, 'npm'), 0) + ); + const cdnRequests = labels.map(date => + adoption.packages.reduce((total, packageData) => total + getDailyValue(packageData, date, 'cdn'), 0) + ); + const releaseMarkers = adoption.packages + .flatMap(packageData => + packageData.publishDates.map(release => { + const date = release.date.slice(0, 10); + const value = Math.max( + adoption.packages.reduce((total, currentPackage) => total + getDailyValue(currentPackage, date, 'npm'), 0), + adoption.packages.reduce((total, currentPackage) => total + getDailyValue(currentPackage, date, 'cdn'), 0) + ); + + return { + date, + label: `${getPackageLabel(packageData.name)} ${release.version}`, + value + }; + }) + ) + .filter(marker => labels.includes(marker.date)); + + return { + labels, + npmDownloads, + cdnRequests, + releaseMarkers + }; +} diff --git a/projects/site/src/docs/metrics/adoption.ts b/projects/site/src/docs/metrics/adoption.ts new file mode 100644 index 0000000000..e686dd69df --- /dev/null +++ b/projects/site/src/docs/metrics/adoption.ts @@ -0,0 +1,445 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Chart } from 'chart.js/auto'; +import { getThemeTokens } from '@nvidia-elements/core'; +import { AdoptionService } from '@internals/metadata'; +import { + getCdnVersionShareRows, + getChannelMixPoints, + getPackageDownloadTrend, + getReleaseAdoptionTimeline +} from './adoption-data.js'; + +const tokens = getThemeTokens(); +const adoption = await AdoptionService.getData(); +const packageDownloadTrend = getPackageDownloadTrend(adoption); +const cdnVersionShareRows = getCdnVersionShareRows(adoption); +const channelMixPoints = getChannelMixPoints(adoption); +const releaseAdoptionTimeline = getReleaseAdoptionTimeline(adoption); +const chartColors = [ + tokens['--nve-sys-visualization-categorical-grass'], + tokens['--nve-sys-visualization-categorical-cyan'], + tokens['--nve-sys-visualization-categorical-amber'], + tokens['--nve-sys-visualization-categorical-violet'], + tokens['--nve-sys-visualization-categorical-rose'], + tokens['--nve-sys-visualization-categorical-red'] +]; + +function getCanvas(id: string): HTMLCanvasElement { + return globalThis.document.getElementById(id) as HTMLCanvasElement; +} + +function getChartColor(index: number): string { + return chartColors[index % chartColors.length] ?? tokens['--nve-sys-visualization-categorical-grass']; +} + +function getCurrencyFormat(value: number): string { + return value.toLocaleString(); +} + +function getVersionShareLabel(version: string, share: number, requests: number): string { + return `${version}: ${share.toFixed(1)}% (${getCurrencyFormat(requests)} requests)`; +} + +function getCdnVersionTooltipLabel(row: (typeof cdnVersionShareRows)[number], datasetLabel?: string): string { + if (datasetLabel === 'Latest version') { + return `Latest ${getVersionShareLabel(row.latestVersion, row.latestVersionShare, row.latestVersionRequests)}`; + } + + if (datasetLabel === 'Leading non-latest version') { + return row.topNonLatestVersion === 'none' + ? 'No non-latest version traffic' + : `Top non-latest ${getVersionShareLabel( + row.topNonLatestVersion, + row.topNonLatestVersionShare, + row.topNonLatestVersionRequests + )}`; + } + + return `Other versions: ${row.otherVersionShare.toFixed(1)}% (${getCurrencyFormat(row.otherVersionRequests)} requests)`; +} + +new Chart(getCanvas('adoption-downloads-chart'), { + type: 'bar', + data: { + labels: packageDownloadTrend.labels, + datasets: packageDownloadTrend.packages.map((packageData, index) => ({ + label: packageData.name, + data: packageData.values, + backgroundColor: getChartColor(index), + borderWidth: 0 + })) + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2.1, + scales: { + x: { + stacked: true, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 9 }, + maxRotation: 45, + minRotation: 45, + autoSkip: true, + maxTicksLimit: 12 + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + } + }, + y: { + stacked: true, + title: { + display: true, + text: 'Downloads', + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 11 } + }, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 10 } + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + }, + beginAtZero: true + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: tokens['--nve-sys-text-emphasis-color'], + usePointStyle: true, + font: { size: 10 } + } + }, + tooltip: { + callbacks: { + label: context => `${context.dataset.label}: ${getCurrencyFormat(context.parsed.y)} downloads` + } + } + } + } +}); + +new Chart(getCanvas('adoption-cdn-version-chart'), { + type: 'bar', + data: { + labels: cdnVersionShareRows.map(row => row.name), + datasets: [ + { + label: 'Latest version', + data: cdnVersionShareRows.map(row => row.latestVersionShare), + backgroundColor: tokens['--nve-sys-visualization-categorical-grass'], + stack: 'versions', + borderWidth: 0 + }, + { + label: 'Leading non-latest version', + data: cdnVersionShareRows.map(row => row.topNonLatestVersionShare), + backgroundColor: tokens['--nve-sys-visualization-categorical-amber'], + stack: 'versions', + borderWidth: 0 + }, + { + label: 'Other versions', + data: cdnVersionShareRows.map(row => row.otherVersionShare), + backgroundColor: tokens['--nve-ref-border-color-muted'], + stack: 'versions', + borderWidth: 0 + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2.1, + scales: { + x: { + stacked: true, + max: 100, + title: { + display: true, + text: 'jsDelivr Request Share', + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 11 } + }, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'], + callback: value => `${value}%` + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + } + }, + y: { + stacked: true, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 10 } + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + } + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: tokens['--nve-sys-text-emphasis-color'], + usePointStyle: true, + font: { size: 10 } + } + }, + tooltip: { + callbacks: { + title: context => { + const row = cdnVersionShareRows[context[0].dataIndex]; + + return row ? `${row.name}: ${getCurrencyFormat(row.cdnRequests)} CDN requests` : ''; + }, + label: context => { + const row = cdnVersionShareRows[context.dataIndex]; + return row ? getCdnVersionTooltipLabel(row, context.dataset.label) : ''; + } + } + } + } + } +}); + +new Chart(getCanvas('adoption-channel-mix-chart'), { + type: 'scatter', + data: { + datasets: [ + { + label: 'Packages', + data: channelMixPoints, + backgroundColor: channelMixPoints.map(point => + point.status === 'unavailable' + ? tokens['--nve-ref-border-color-muted'] + : point.status === 'partial' + ? tokens['--nve-sys-visualization-categorical-amber'] + : tokens['--nve-sys-visualization-categorical-grass'] + ), + borderWidth: 0, + pointRadius: 5, + pointHoverRadius: 7 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2.1, + scales: { + x: { + title: { + display: true, + text: 'npm Downloads', + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 11 } + }, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'] + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + }, + beginAtZero: true + }, + y: { + title: { + display: true, + text: 'CDN Requests', + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 11 } + }, + ticks: { + color: tokens['--nve-sys-text-emphasis-color'] + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + }, + beginAtZero: true + } + }, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + label: context => { + const point = channelMixPoints[context.dataIndex]; + return point + ? [ + point.name, + `npm: ${getCurrencyFormat(point.x)}`, + `CDN: ${getCurrencyFormat(point.y)}`, + `Status: ${point.status}` + ] + : []; + } + } + } + } + } +}); + +new Chart(getCanvas('adoption-release-overlay-chart'), { + type: 'line', + data: { + labels: releaseAdoptionTimeline.labels, + datasets: [ + { + label: 'npm Downloads', + data: releaseAdoptionTimeline.npmDownloads, + borderColor: tokens['--nve-sys-visualization-categorical-grass'], + backgroundColor: tokens['--nve-sys-visualization-categorical-grass'], + borderWidth: 2, + tension: 0.35 + }, + { + label: 'CDN Requests', + data: releaseAdoptionTimeline.cdnRequests, + borderColor: tokens['--nve-sys-visualization-categorical-cyan'], + backgroundColor: tokens['--nve-sys-visualization-categorical-cyan'], + borderWidth: 2, + tension: 0.35 + }, + { + label: 'Releases', + type: 'scatter', + data: releaseAdoptionTimeline.releaseMarkers.map(marker => ({ + x: marker.date, + y: marker.value + })), + backgroundColor: tokens['--nve-sys-visualization-categorical-amber'], + pointRadius: 4, + pointHoverRadius: 6 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2.1, + scales: { + x: { + ticks: { + color: tokens['--nve-sys-text-emphasis-color'], + font: { size: 9 }, + autoSkip: true, + maxTicksLimit: 12, + maxRotation: 45, + minRotation: 45 + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + } + }, + y: { + ticks: { + color: tokens['--nve-sys-text-emphasis-color'] + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + }, + beginAtZero: true + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: tokens['--nve-sys-text-emphasis-color'], + usePointStyle: true, + font: { size: 10 } + } + }, + tooltip: { + callbacks: { + label: context => { + const marker = releaseAdoptionTimeline.releaseMarkers[context.dataIndex]; + + if (context.dataset.label === 'Releases' && marker) { + return marker.label; + } + + return `${context.dataset.label}: ${getCurrencyFormat(context.parsed.y)}`; + } + } + } + } + } +}); + +new Chart(getCanvas('adoption-github-interest-chart'), { + type: 'line', + data: { + labels: adoption.github.stargazers.map(stargazer => stargazer.month), + datasets: [ + { + label: 'Stars Added', + data: adoption.github.stargazers.map(stargazer => stargazer.stars), + borderColor: tokens['--nve-sys-visualization-categorical-amber'], + backgroundColor: tokens['--nve-sys-visualization-categorical-amber'], + borderWidth: 2, + tension: 0.35 + }, + { + label: 'Cumulative Stars', + data: adoption.github.stargazers.map(stargazer => stargazer.cumulativeStars), + borderColor: tokens['--nve-sys-visualization-categorical-grass'], + backgroundColor: tokens['--nve-sys-visualization-categorical-grass'], + borderWidth: 2, + tension: 0.35 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 2.1, + scales: { + x: { + ticks: { + color: tokens['--nve-sys-text-emphasis-color'] + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + } + }, + y: { + ticks: { + color: tokens['--nve-sys-text-emphasis-color'] + }, + grid: { + color: tokens['--nve-ref-border-color-muted'] + }, + beginAtZero: true + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: tokens['--nve-sys-text-emphasis-color'], + usePointStyle: true, + font: { size: 10 } + } + } + } + } +}); diff --git a/projects/site/src/docs/metrics/bundle-explorer.11ty.js b/projects/site/src/docs/metrics/bundle-explorer.11ty.js index dec6d76768..4107458e5b 100644 --- a/projects/site/src/docs/metrics/bundle-explorer.11ty.js +++ b/projects/site/src/docs/metrics/bundle-explorer.11ty.js @@ -14,7 +14,7 @@ export function render() {
- Metrics + Metrics API Status Testing & Performance Wireit Explorer diff --git a/projects/site/src/docs/metrics/index.11ty.js b/projects/site/src/docs/metrics/index.11ty.js index 8fdca95673..4300f45009 100644 --- a/projects/site/src/docs/metrics/index.11ty.js +++ b/projects/site/src/docs/metrics/index.11ty.js @@ -1,6 +1,6 @@ // @ts-check -import { ReleasesService, TestsService, ApiService } from '@internals/metadata'; +import { ReleasesService, TestsService, ApiService, AdoptionService } from '@internals/metadata'; export const data = { title: 'Metrics', @@ -11,6 +11,7 @@ export const data = { const releases = await ReleasesService.getData(); const testMetrics = await TestsService.getData(); const apiMetrics = await ApiService.getData(); +const adoption = await AdoptionService.getData(); const releasesReportDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'long' }).format( new Date(releases.created) @@ -29,24 +30,44 @@ const totalTests = Object.values(testMetrics.projects).reduce( 0 ); -export function render() { - return this.renderTemplate( - /* html */ ` -