Skip to content

Commit 308f8d3

Browse files
Merge pull request #2 from DataLab-Platform/feat/catalog-page-redesign
feat: redesign the catalog page with plugin cards and search
2 parents 59668cd + 1af7564 commit 308f8d3

8 files changed

Lines changed: 911 additions & 61 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ Catalog of plugins for [DataLab](https://datalab-platform.com/) desktop and [Dat
44

55
## Installing a plugin
66

7+
In DataLab 1.4 or later, open **Plugins > Configure plugins...**, select the **Available plugins** tab and click **Install**: DataLab downloads the plugin and checks it against the catalog.
8+
9+
To install a downloaded file instead (also with the standalone version of DataLab):
10+
711
1. Open <https://datalab-platform.com/plugins/> and download the wheel (`.whl`) of the plugin.
812
2. In DataLab, open **Plugins > Configure plugins...**, go to the **Install plugins** tab and click **Install from file...**.
913

10-
This also works with the standalone (Windows installer) version of DataLab, from DataLab 1.4.
11-
1214
A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust. Being listed here does not mean that a plugin was audited.
1315

1416
## Publishing a plugin

‎tests/test_catalog.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,12 @@ def test_github_release_is_mirrored_in_the_published_catalog(
124124
assert (tmp_path / "site" / release["url"]).read_bytes() == wheel[1]
125125
published = json.loads((tmp_path / "site" / "catalog.json").read_text("utf-8"))
126126
assert published["schema_version"] == 1
127-
assert "Example" in (tmp_path / "site" / "index.html").read_text("utf-8")
127+
page = (tmp_path / "site" / "index.html").read_text("utf-8")
128+
assert 'data-tier="community" data-targets="desktop web"' in page
129+
assert f'href="{release["url"]}" download>Download 1.0.0</a>' in page
130+
assert ">Community</span>" in page
131+
for name in catalog.PAGE_ASSETS:
132+
assert (tmp_path / "site" / name).is_file()
128133

129134

130135
def test_missing_digest_reports_the_value_to_declare(
@@ -221,6 +226,49 @@ def test_revoked_plugin_is_listed_without_downloading_its_wheels(
221226
assert plugin["releases"] == [{"version": "1.0.0", "sha256": "a" * 64}]
222227
assert not (tmp_path / "site" / "wheels").exists()
223228
assert not served
229+
page = (tmp_path / "site" / "index.html").read_text("utf-8")
230+
assert '<p class="notice status-revoked">Malicious code</p>' in page
231+
assert "No installable release" in page
232+
assert "download>" not in page
233+
234+
235+
def test_page_escapes_metadata_read_from_wheels() -> None:
236+
"""Wheel summaries are untrusted and must not inject markup."""
237+
page = catalog.render_index(
238+
{
239+
"generated_at": "2026-10-08T13:29:23+00:00",
240+
"plugins": [
241+
{
242+
"id": "io.github.someone.example",
243+
"name": "Camera & Detector",
244+
"tier": "community",
245+
"status": "active",
246+
"repository": "https://github.com/someone/example",
247+
"summary": '<script>alert("x")</script>',
248+
"keywords": ['"><img src=x>'],
249+
"releases": [],
250+
}
251+
],
252+
}
253+
)
254+
255+
assert "<script>alert" not in page
256+
assert "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;" in page
257+
assert "<img src=x>" not in page
258+
assert ">CD</span>" in page
259+
assert "Camera &amp; Detector</a>" in page
260+
assert "1 plugin<" in page
261+
assert '<time datetime="2026-10-08T13:29:23+00:00">2026-10-08 13:29 UTC' in page
262+
263+
264+
def test_empty_catalog_page_invites_submissions() -> None:
265+
"""The page of an empty catalog links to the contribution guide."""
266+
page = catalog.render_index(
267+
{"generated_at": "2026-10-08T13:29:23+00:00", "plugins": []}
268+
)
269+
270+
assert f'href="{catalog.CONTRIBUTING_URL}">Submit the first one' in page
271+
assert 'class="card"' not in page
224272

225273

226274
@pytest.mark.parametrize(

‎tools/catalog.py‎

Lines changed: 143 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import json
2323
import os
2424
import shutil
25+
import string
2526
import sys
2627
import urllib.error
2728
import urllib.parse
@@ -46,6 +47,21 @@
4647
ROOT = Path(__file__).resolve().parents[1]
4748
ENTRIES_DIR = ROOT / "plugins"
4849
SCHEMA_PATH = ROOT / "schema" / "plugin-entry.schema.json"
50+
PAGE_DIR = ROOT / "tools" / "page"
51+
PAGE_ASSETS = ("style.css", "catalog.js", "logo.svg", "favicon.ico")
52+
CONTRIBUTING_URL = (
53+
"https://github.com/DataLab-Platform/plugins/blob/main/CONTRIBUTING.md"
54+
)
55+
CAPABILITY_LABELS = {
56+
"application": "Application",
57+
"processing": "Processing",
58+
"io": "I/O",
59+
"visualization": "Visualization",
60+
}
61+
TIER_DESCRIPTIONS = {
62+
"official": "Maintained by the DataLab team",
63+
"community": "Maintained by its author",
64+
}
4965
CATALOG_SCHEMA_VERSION = 1
5066
OFFICIAL_OWNER = "datalab-platform"
5167
OFFICIAL_ID_PREFIX = "org.datalab."
@@ -393,71 +409,140 @@ def write_site(output: Path, catalog: dict, files: Mapping[str, bytes]) -> None:
393409
)
394410
(output / "schema").mkdir(exist_ok=True)
395411
shutil.copy2(SCHEMA_PATH, output / "schema" / SCHEMA_PATH.name)
412+
for name in PAGE_ASSETS:
413+
shutil.copy2(PAGE_DIR / name, output / name)
396414
(output / "index.html").write_text(render_index(catalog), encoding="utf-8")
397415
(output / ".nojekyll").write_text("", encoding="utf-8")
398416

399417

418+
def _format_size(size: int) -> str:
419+
"""Return a human-readable file size."""
420+
if size < 1024:
421+
return f"{size} B"
422+
if size < 1024 * 1024:
423+
return f"{size / 1024:.0f} kB"
424+
return f"{size / (1024 * 1024):.1f} MB"
425+
426+
427+
def _count_plugins(count: int) -> str:
428+
return f"{count} plugin{'' if count == 1 else 's'}"
429+
430+
431+
def _render_card(plugin: dict) -> str:
432+
"""Return the HTML card of a catalog plugin."""
433+
esc = html.escape
434+
status, tier = plugin["status"], plugin["tier"]
435+
installable = [
436+
release
437+
for release in plugin["releases"]
438+
if "url" in release and "yanked" not in release
439+
]
440+
latest = installable[0] if installable and status != "revoked" else None
441+
targets = latest["targets"] if latest else []
442+
capabilities = [
443+
CAPABILITY_LABELS.get(name, name) for name in plugin.get("capabilities", [])
444+
]
445+
keywords = plugin.get("keywords", [])
446+
summary = plugin.get("summary", "")
447+
searchable = " ".join(
448+
[plugin["name"], plugin["id"], summary, *keywords, *capabilities, *targets]
449+
).lower()
450+
words = [word for word in plugin["name"].split() if word[:1].isalnum()]
451+
initials = "".join(word[0] for word in words[:2]).upper() or "?"
452+
hue = int(hashlib.sha256(plugin["id"].encode()).hexdigest()[:4], 16) % 360
453+
454+
badges = [
455+
f'<span class="badge tier-{esc(tier)}" '
456+
f'title="{esc(TIER_DESCRIPTIONS.get(tier, ""))}">{esc(tier.title())}</span>'
457+
]
458+
if status != "active":
459+
badges.append(
460+
f'<span class="badge status-{esc(status)}">{esc(status.title())}</span>'
461+
)
462+
badges += [f'<span class="chip">{esc(label)}</span>' for label in capabilities]
463+
badges += [
464+
f'<span class="chip target">{esc(target.title())}</span>' for target in targets
465+
]
466+
body = [f'<div class="badges">{"".join(badges)}</div>']
467+
if status != "active" and plugin.get("status_reason"):
468+
body.append(
469+
f'<p class="notice status-{esc(status)}">{esc(plugin["status_reason"])}</p>'
470+
)
471+
if summary:
472+
body.append(f'<p class="summary">{esc(summary)}</p>')
473+
if keywords:
474+
items = "".join(f"<li>{esc(keyword)}</li>" for keyword in keywords)
475+
body.append(f'<ul class="keywords" aria-label="Keywords">{items}</ul>')
476+
477+
foot = []
478+
if latest:
479+
meta = [f"Version {latest['version']}"]
480+
if plugin.get("license"):
481+
meta.append(plugin["license"])
482+
meta.append(_format_size(latest["size"]))
483+
if latest.get("requires_python"):
484+
meta.append(f"Python {latest['requires_python']}")
485+
foot.append(
486+
'<p class="meta">'
487+
+ "".join(f"<span>{esc(item)}</span>" for item in meta)
488+
+ "</p>"
489+
)
490+
actions = [
491+
f'<a class="button primary" href="{esc(latest["url"])}" download>'
492+
f"Download {esc(latest['version'])}</a>"
493+
]
494+
else:
495+
actions = ['<span class="unavailable">No installable release</span>']
496+
repository = plugin["repository"]
497+
actions.append(f'<a class="button" href="{esc(repository)}">Source code</a>')
498+
documentation = plugin.get("documentation", repository)
499+
if documentation != repository:
500+
actions.append(
501+
f'<a class="button" href="{esc(documentation)}">Documentation</a>'
502+
)
503+
foot.append(f'<div class="actions">{"".join(actions)}</div>')
504+
if latest:
505+
foot.append(
506+
'<details class="digest"><summary>SHA-256</summary>'
507+
f"<code>{esc(latest['sha256'])}</code></details>"
508+
)
509+
510+
return (
511+
f'<article class="card" data-tier="{esc(tier)}" '
512+
f'data-targets="{esc(" ".join(targets))}" data-search="{esc(searchable)}">'
513+
'<header class="card-head">'
514+
f'<span class="monogram" style="--hue: {hue}" aria-hidden="true">'
515+
f"{esc(initials)}</span>"
516+
f'<div><h2><a href="{esc(repository)}">{esc(plugin["name"])}</a></h2>'
517+
f'<code class="plugin-id">{esc(plugin["id"])}</code></div>'
518+
"</header>" + "".join(body) + f'<div class="card-foot">{"".join(foot)}</div>'
519+
"</article>"
520+
)
521+
522+
400523
def render_index(catalog: dict) -> str:
401524
"""Return the HTML page listing the catalog plugins."""
402-
rows = []
403-
for plugin in catalog["plugins"]:
404-
installable = [
405-
release
406-
for release in plugin["releases"]
407-
if "url" in release and "yanked" not in release
408-
]
409-
latest = installable[0] if installable else None
410-
download = "&mdash;"
411-
if latest and plugin["status"] != "revoked":
412-
url, version = html.escape(latest["url"]), html.escape(latest["version"])
413-
download = f'<a href="{url}">{version}</a>'
414-
description = plugin.get("summary", plugin.get("status_reason", ""))
415-
targets = ", ".join(latest["targets"]) if latest else ""
416-
rows.append(
417-
"<tr>"
418-
f'<td><a href="{html.escape(plugin["repository"])}">'
419-
f"{html.escape(plugin['name'])}</a><br><small>{html.escape(plugin['id'])}"
420-
"</small></td>"
421-
f"<td>{html.escape(description)}</td>"
422-
f"<td>{html.escape(targets)}</td>"
423-
f"<td>{html.escape(plugin['tier'])}<br><small>"
424-
f"{html.escape(plugin['status'])}</small></td>"
425-
f"<td>{download}</td>"
426-
"</tr>"
525+
plugins = catalog["plugins"]
526+
if plugins:
527+
cards = (
528+
'<div class="grid">\n'
529+
+ "\n".join(_render_card(plugin) for plugin in plugins)
530+
+ "\n</div>"
427531
)
428-
table = "\n".join(rows) or '<tr><td colspan="5">No plugin yet.</td></tr>'
429-
return f"""<!DOCTYPE html>
430-
<html lang="en">
431-
<head>
432-
<meta charset="utf-8">
433-
<meta name="viewport" content="width=device-width, initial-scale=1">
434-
<title>DataLab plugins</title>
435-
<style>
436-
body {{ font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 64rem; }}
437-
table {{ border-collapse: collapse; width: 100%; }}
438-
th, td {{ border-bottom: 1px solid #ddd; padding: .5rem; text-align: left; }}
439-
small {{ color: #666; }}
440-
</style>
441-
</head>
442-
<body>
443-
<h1>DataLab plugins</h1>
444-
<p>Download a wheel and install it in DataLab with <b>Plugins &gt; Configure
445-
plugins... &gt; Install plugins</b>. Plugins run with the same rights as DataLab:
446-
install only plugins from authors you trust. Machine-readable catalog:
447-
<a href="catalog.json">catalog.json</a>.
448-
Submit a plugin: <a href="https://github.com/DataLab-Platform/plugins">
449-
DataLab-Platform/plugins</a>.</p>
450-
<table>
451-
<thead><tr><th>Plugin</th><th>Description</th><th>Targets</th><th>Tier</th>
452-
<th>Download</th></tr></thead>
453-
<tbody>
454-
{table}
455-
</tbody>
456-
</table>
457-
<p><small>Updated {html.escape(catalog["generated_at"])}</small></p>
458-
</body>
459-
</html>
460-
"""
532+
else:
533+
cards = (
534+
f'<p class="empty">No plugin yet. <a href="{CONTRIBUTING_URL}">'
535+
"Submit the first one</a>.</p>"
536+
)
537+
generated = datetime.datetime.fromisoformat(catalog["generated_at"])
538+
generated = generated.astimezone(datetime.timezone.utc)
539+
template = string.Template((PAGE_DIR / "index.html").read_text(encoding="utf-8"))
540+
return template.substitute(
541+
cards=cards,
542+
count=_count_plugins(len(plugins)),
543+
updated=f"{generated:%Y-%m-%d %H:%M} UTC",
544+
updated_iso=html.escape(catalog["generated_at"]),
545+
)
461546

462547

463548
def report(catalog: dict) -> str:

‎tools/page/catalog.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use strict";
2+
3+
(() => {
4+
const toolbar = document.querySelector(".toolbar");
5+
const cards = Array.from(document.querySelectorAll(".card"));
6+
if (!toolbar || cards.length === 0) {
7+
return;
8+
}
9+
const search = document.getElementById("search");
10+
const count = document.getElementById("count");
11+
const noMatch = document.getElementById("no-match");
12+
const filters = { tier: "", target: "" };
13+
const plugins = (n) => `${n} plugin${n === 1 ? "" : "s"}`;
14+
15+
function update() {
16+
const words = search.value.toLowerCase().split(/\s+/).filter(Boolean);
17+
let shown = 0;
18+
for (const card of cards) {
19+
const visible =
20+
words.every((word) => card.dataset.search.includes(word)) &&
21+
(!filters.tier || card.dataset.tier === filters.tier) &&
22+
(!filters.target || card.dataset.targets.split(" ").includes(filters.target));
23+
card.hidden = !visible;
24+
shown += visible ? 1 : 0;
25+
}
26+
count.textContent =
27+
shown === cards.length ? plugins(shown) : `${shown} of ${plugins(cards.length)}`;
28+
noMatch.hidden = shown > 0;
29+
}
30+
31+
const buttons = Array.from(toolbar.querySelectorAll("button[data-filter]"));
32+
for (const button of buttons) {
33+
button.addEventListener("click", () => {
34+
const group = button.dataset.filter;
35+
filters[group] = button.dataset.value;
36+
for (const other of buttons) {
37+
if (other.dataset.filter === group) {
38+
other.setAttribute("aria-pressed", String(other === button));
39+
}
40+
}
41+
update();
42+
});
43+
}
44+
search.addEventListener("input", update);
45+
toolbar.hidden = false;
46+
})();

‎tools/page/favicon.ico‎

5.3 KB
Binary file not shown.

0 commit comments

Comments
 (0)