|
22 | 22 | import json |
23 | 23 | import os |
24 | 24 | import shutil |
| 25 | +import string |
25 | 26 | import sys |
26 | 27 | import urllib.error |
27 | 28 | import urllib.parse |
|
46 | 47 | ROOT = Path(__file__).resolve().parents[1] |
47 | 48 | ENTRIES_DIR = ROOT / "plugins" |
48 | 49 | 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 | +} |
49 | 65 | CATALOG_SCHEMA_VERSION = 1 |
50 | 66 | OFFICIAL_OWNER = "datalab-platform" |
51 | 67 | OFFICIAL_ID_PREFIX = "org.datalab." |
@@ -393,71 +409,140 @@ def write_site(output: Path, catalog: dict, files: Mapping[str, bytes]) -> None: |
393 | 409 | ) |
394 | 410 | (output / "schema").mkdir(exist_ok=True) |
395 | 411 | shutil.copy2(SCHEMA_PATH, output / "schema" / SCHEMA_PATH.name) |
| 412 | + for name in PAGE_ASSETS: |
| 413 | + shutil.copy2(PAGE_DIR / name, output / name) |
396 | 414 | (output / "index.html").write_text(render_index(catalog), encoding="utf-8") |
397 | 415 | (output / ".nojekyll").write_text("", encoding="utf-8") |
398 | 416 |
|
399 | 417 |
|
| 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 | + |
400 | 523 | def render_index(catalog: dict) -> str: |
401 | 524 | """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 = "—" |
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>" |
427 | 531 | ) |
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 > Configure |
445 | | -plugins... > 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 | + ) |
461 | 546 |
|
462 | 547 |
|
463 | 548 | def report(catalog: dict) -> str: |
|
0 commit comments