A second notebook
The step after Getting started, before the MoMA notebook: Express's release history from the npm registry, fetched, reshaped, charted and summarized in six cells. See Example notebooks for the others.
A short notebook in the shape a lot of data work takes: fetch some JSON, reshape it, draw a chart, write a summary. The data is every release of Express, from the npm registry's public API, which needs no key. The only package it imports is vega-lite-api, for the chart.
If this is your first notebook, start with Getting started.
Fetch
const pkg = await (await fetch("https://registry.npmjs.org/express")).json();
({ name: pkg.name, description: pkg.description, distTags: pkg["dist-tags"] }){
name: 'express',
description: 'Fast, unopinionated, minimalist web framework',
distTags: { latest: '5.3.0', 'latest-4': '4.22.3' }
}The response is the package's whole registry record: metadata for every version under versions, and when each was published under time. It stays in pkg, so the cells below don't fetch it again.
Reshape
One row per published version. time also lists versions that were later unpublished, so the rows come from versions. A version is a prerelease if it has anything after major.minor.patch, as in 5.0.0-beta.1 or the older 1.0.0rc4.
type Release = { version: string; line: string; prerelease: boolean; date: Date };
const releases: Release[] = Object.keys(pkg.versions)
.map((version) => ({
version,
line: version.split(".")[0] + ".x",
prerelease: !/^\d+\.\d+\.\d+$/.test(version),
date: new Date(pkg.time[version]),
}))
// By date, then by version for releases published at the same moment.
.toSorted((a, b) => a.date.getTime() - b.date.getTime()
|| a.version.localeCompare(b.version, "en", { numeric: true }));
console.log(releases.length, "releases,", releases.filter((r) => r.prerelease).length, "of them prereleases");
releases.slice(-5)290 releases, 43 of them prereleases
[
{
version: '5.2.1',
line: '5.x',
prerelease: false,
date: 2025-12-01T20:49:43.268Z
},
{
version: '4.22.1',
line: '4.x',
prerelease: false,
date: 2025-12-01T20:50:41.122Z
},
{
version: '4.22.2',
line: '4.x',
prerelease: false,
date: 2026-05-11T18:50:00.007Z
},
{
version: '4.22.3',
line: '4.x',
prerelease: false,
date: 2026-09-14T06:46:38.631Z
},
{
version: '5.3.0',
line: '5.x',
prerelease: false,
date: 2026-10-09T12:56:54.505Z
}
]Chart
Stable releases per year, colored by major version. Prereleases are left out: the old ones for 1.x to 3.x all share one timestamp from August 2013, so their dates say little.
vega-lite-api builds a Vega-Lite chart in code, and the kernel shows any object with a toSpec() method as a chart, so the cell's last expression is all it takes. Vega-Lite does the counting: year("date") groups the rows by year and count() counts each group.
import * as vl from "npm:vega-lite-api@5";
vl.markBar()
.data(releases.filter((r) => !r.prerelease))
.encode(
vl.x().year("date").title(null),
vl.y().count().title("releases"),
vl.color().fieldN("line").title("version"),
vl.tooltip([vl.year("date"), vl.fieldN("line"), vl.count()]),
)
.width(460)
.height(220)Downloads
Which of those versions are people installing? A second endpoint returns last week's downloads per version. The cell shows a placeholder while it fetches, then replaces it with the result: displaying again with the same display_id and update: true updates the output in place instead of adding another.
await jupyter.display(jupyter.md`Fetching last week's downloads...`, { display_id: "downloads" });
const { downloads }: { downloads: Record<string, number> } =
await (await fetch("https://api.npmjs.org/versions/express/last-week")).json();
const total = Object.values(downloads).reduce((sum, n) => sum + n, 0);
await jupyter.display(
jupyter.md`**${total.toLocaleString()}** downloads last week, across ${Object.keys(downloads).length} versions.`,
{ display_id: "downloads", update: true },
);87,264,652 downloads last week, across 290 versions.
Summary
One row per major version, built as a Markdown table and rendered with jupyter.md. The downloads share counts every version in the line, prereleases included.
const byLine = Object.groupBy(releases, (r) => r.line);
const rows = Object.entries(byLine).map(([line, list]) => {
const stable = list!.filter((r) => !r.prerelease);
const latest = stable.at(-1)!;
const weekly = list!.reduce((sum, r) => sum + (downloads[r.version] ?? 0), 0);
return `| ${line} | ${stable.length} | ${list!.length - stable.length} | ${stable[0].date.getUTCFullYear()} | `
+ `${latest.version} (${latest.date.toISOString().slice(0, 10)}) | ${(100 * weekly / total).toFixed(1)}% |`;
});
jupyter.md`| Line | Releases | Prereleases | First release | Latest release | Share of last week's downloads |
| --- | ---: | ---: | ---: | --- | ---: |
${rows.join("\n")}`| Line | Releases | Prereleases | First release | Latest release | Share of last week's downloads |
|---|---|---|---|---|---|
| 0.x | 2 | 0 | 2010 | 0.14.1 (2010-12-29) | 0.0% |
| 1.x | 9 | 6 | 2010 | 1.0.8 (2011-03-02) | 0.0% |
| 2.x | 39 | 6 | 2011 | 2.5.11 (2012-07-04) | 0.0% |
| 3.x | 96 | 16 | 2012 | 3.21.2 (2015-07-31) | 0.0% |
| 4.x | 95 | 4 | 2014 | 4.22.3 (2026-09-14) | 41.8% |
| 5.x | 6 | 11 | 2024 | 5.3.0 (2026-10-09) | 58.1% |
Next
- Swap
expressfor another package, or the registry for another JSON API. - The jupyter namespace covers
jupyter.display,jupyter.mdand the other ways to show output. - MoMA's collection is a longer notebook: a 160,000-row dataset with Polars, Observable Plot, a widget and React.