diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e2183fa9..568908e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,9 +6,19 @@ on: workflow_run: workflows: ["Update package table"] types: [completed] + +# ghp-import force-pushes gh-pages; two runs racing each other could publish +# the older build last. +concurrency: + group: deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest + # Only redeploy for a table refresh that actually succeeded; a push to main + # deploys unconditionally. + if: github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' # `ghp-import --push` pushes the built site to the gh-pages branch permissions: contents: write diff --git a/.github/workflows/update-package-table.yml b/.github/workflows/update-package-table.yml index bcc46658..19fc6af8 100644 --- a/.github/workflows/update-package-table.yml +++ b/.github/workflows/update-package-table.yml @@ -5,6 +5,12 @@ on: - cron: "0 */6 * * *" workflow_dispatch: +# A second run pushing over a still-running first one would fail or interleave +# commits; queue instead. +concurrency: + group: update-package-table + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -18,37 +24,21 @@ jobs: with: persist-credentials: false - uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1 - # foxy and galactic are end-of-life; public/data/{foxy,galactic}.json are - # committed snapshots and deliberately not regenerated here. - - name: Create table noetic - run: | - pixi run compare-completeness noetic robostack-noetic - - name: Create table humble - run: | - pixi run compare-completeness humble robostack-humble - - name: Create table jazzy - run: | - pixi run compare-completeness jazzy robostack-jazzy - - name: Create table kilted - run: | - pixi run compare-completeness kilted robostack-kilted - - name: Create table rolling - run: | - pixi run compare-completeness rolling https://prefix.dev/robostack-rolling - - name: Create table lyrical + # Regenerates every distro with a dataChannel in src/data/distros.json; + # a distro without one (foxy, galactic) is a committed snapshot. A failed + # distro does not stop the others: whatever succeeded is still committed + # below, and the run is marked failed at the end. + - name: Update tables + id: update + continue-on-error: true run: | - pixi run compare-completeness lyrical https://prefix.dev/robostack-lyrical + pixi run update-tables - name: Commit changes id: commit run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git add public/data/noetic.json - git add public/data/humble.json - git add public/data/jazzy.json - git add public/data/kilted.json - git add public/data/rolling.json - git add public/data/lyrical.json + git add public/data git commit -m "Update tables" continue-on-error: true - name: Push changes @@ -57,3 +47,6 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: ${{ github.ref }} + - name: Surface update failures + if: steps.update.outcome == 'failure' + run: exit 1 diff --git a/astro.config.mjs b/astro.config.mjs index 457aacfa..c5b956ee 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import svelte from "@astrojs/svelte"; +import { newestRelease } from "./src/data/distros.ts"; export default defineConfig({ site: "https://robostack.github.io", @@ -46,7 +47,7 @@ export default defineConfig({ { label: "Conda", slug: "conda" }, ], }, - { label: "Packages", link: "/lyrical.html" }, + { label: "Packages", link: `/${newestRelease().name}.html` }, { label: "JupyterRos", slug: "JupyterRos" }, { label: "Support", slug: "support" }, { label: "Contributing", slug: "Contributing" }, diff --git a/pixi.toml b/pixi.toml index 203c5588..819fa1cd 100644 --- a/pixi.toml +++ b/pixi.toml @@ -91,6 +91,10 @@ compare-completeness = { cmd = "python scripts/compare_pkg_completeness.py", description = "Add two arguments to give it the DISTRO and CHANNEL", } +update-tables = { + cmd = "python scripts/compare_pkg_completeness.py --all", + description = "Regenerate the package tables for every distro the pipeline maintains", +} [environments] default = ["site", "lint", "scripts"] diff --git a/scripts/compare_pkg_completeness.py b/scripts/compare_pkg_completeness.py index 51066849..ccb383b8 100644 --- a/scripts/compare_pkg_completeness.py +++ b/scripts/compare_pkg_completeness.py @@ -6,9 +6,13 @@ - `rosdistro`'s `distribution.yaml` for the package list, the released version, and the upstream source repository. - `rosdistro`'s distribution cache for each package's `package.xml`, which is where - the descriptions and licences come from. + the descriptions come from. - The channel's `repodata.json` per platform, for what actually got built. +Packages that exist on the channel but were never released into `rosdistro` (extra +recipes, mostly) get a row too, marked with `indexed` 0: they have no description +and no index version, but they are installable and should be findable. + Availability is always relative to a mutex. Everything on a channel is built against one version of `ros2-distro-mutex` (`ros-distro-mutex` on ROS 1), and builds for different mutex versions cannot be installed together. A package built for 0.8 but @@ -21,11 +25,13 @@ would work today, but the specs appear in two forms (`0.9.* humble_*` and `>=0.9.0,<0.10.0a0`) and nothing stops a third from showing up. -The JSON is positional to keep it small; `PackageTable.svelte` unpacks it by -index, so the order in `PackageRecordJson` is load-bearing. +The JSON is positional to keep it small; `PackageTable.svelte` unpacks it via the +`fields` list in the document head, so the two only have to agree on the names. Usage: python scripts/compare_pkg_completeness.py channel is an anaconda.org channel name or a full base URL. + python scripts/compare_pkg_completeness.py --all + regenerates every distro with a `dataChannel` in src/data/distros.json. """ from __future__ import annotations @@ -34,29 +40,27 @@ import concurrent.futures import gzip import json -import os import re import sys import xml.etree.ElementTree as ET from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any, TypeAlias import niquests import yaml from rattler import MatchSpec, PackageRecord +from urllib3.util.retry import Retry + +# The distro list and the platform list are shared with the site through +# src/data/distros.json. +DISTROS_JSON = Path(__file__).parent.parent / "src" / "data" / "distros.json" -# The bit positions here are the bit positions the page reads. The page takes -# the platform order from the JSON itself; only the icon map in +# The order is the bit-position order the page reads. The page takes the +# platform order from the generated JSON itself; only the icon map in # src/components/PackageTable.svelte is keyed by platform id. -PLATFORMS: list[str] = [ - "linux-64", - "linux-aarch64", - "osx-64", - "osx-arm64", - "win-64", - "emscripten-wasm32", -] +PLATFORMS: list[str] = json.loads(DISTROS_JSON.read_text())["platforms"] ROSDISTRO = "https://raw.githubusercontent.com/ros/rosdistro/master" LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader) @@ -64,13 +68,23 @@ # ROS 1 and ROS 2 name their mutex differently, and a channel only ever has one. MUTEX_NAMES: tuple[str, ...] = ("ros2-distro-mutex", "ros-distro-mutex") +# Newest mutex generations to keep. Jazzy has published eleven; the old ones +# dominate the payload while only the recent generations are still useful to +# select in the table. +MUTEX_LIMIT = 4 + # A raw repodata record, as it comes out of the JSON. Artifact: TypeAlias = dict[str, Any] # Mutex version -> every artifact published for it. A version can ship more than # one build, and a spec only has to match one of them. MutexRecords: TypeAlias = dict[str, list[PackageRecord]] -session = niquests.Session() +# The whole document is a handful of GETs against rosdistro and the channel; +# retrying transient failures (anaconda.org 5xxs, mostly) keeps one hiccup from +# failing a whole six-hourly refresh. +session = niquests.Session( + retries=Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) +) @dataclass @@ -118,8 +132,8 @@ def index_packages(distro: str) -> dict[str, IndexEntry]: return packages -def package_metadata(distro: str) -> dict[str, tuple[str, str]]: - """`{package: (description, licence)}` from the rosdistro distribution cache. +def package_metadata(distro: str) -> dict[str, str]: + """`{package: description}` from the rosdistro distribution cache. The cache is the only place these live, but the table is still useful without them, so a failure here is logged and skipped rather than raised. @@ -134,14 +148,13 @@ def package_metadata(distro: str) -> dict[str, tuple[str, str]]: print(f" warning: no distribution cache ({error})", file=sys.stderr) return {} - metadata: dict[str, tuple[str, str]] = {} + metadata: dict[str, str] = {} for name, package_xml in cache.get("release_package_xmls", {}).items(): try: root = ET.fromstring(package_xml) except ET.ParseError: continue - description = " ".join((root.findtext("description") or "").split()) - metadata[name] = (description, (root.findtext("license") or "").strip()) + metadata[name] = " ".join((root.findtext("description") or "").split()) return metadata @@ -187,6 +200,17 @@ def version_key(version: str) -> tuple[int, ...]: return tuple(int(p) if p.isdigit() else -1 for p in re.split(r"[._-]", str(version))[:4]) +def normalize_timestamp(timestamp: int) -> int: + """Repodata timestamps in milliseconds; some artifacts carry seconds instead. + + Workaround for https://github.com/RoboStack/ros-humble/issues/258: a timestamp + that would place the build before 2001 is taken to be in seconds. + """ + if 0 < timestamp < 1_000_000_000_000: + timestamp *= 1000 + return timestamp + + def collect_mutexes(repos: dict[str, list[Artifact]]) -> tuple[str, MutexRecords]: """Find the channel's mutex package and every version of it that was published. @@ -257,7 +281,9 @@ def collect_builds( if not name.startswith("ros-") or name in MUTEX_NAMES: continue - newest_build[name] = max(newest_build.get(name, 0), artifact.get("timestamp") or 0) + newest_build[name] = max( + newest_build.get(name, 0), normalize_timestamp(artifact.get("timestamp") or 0) + ) specs = [d for d in artifact.get("depends", []) if d.split(" ")[0] in MUTEX_NAMES] if specs: @@ -290,7 +316,7 @@ def build(distro: str, channel: str) -> dict[str, Any]: print(f" {platform}: {len(repos[platform])}", file=sys.stderr) mutex_package, mutex_records = collect_mutexes(repos) - mutexes = sorted(mutex_records, key=version_key, reverse=True) + mutexes = sorted(mutex_records, key=version_key, reverse=True)[:MUTEX_LIMIT] print(f" mutex: {mutex_package} {mutexes}", file=sys.stderr) builds, newest_build = collect_builds(repos, mutexes, mutex_matcher(mutex_records)) @@ -300,6 +326,11 @@ def build(distro: str, channel: str) -> dict[str, Any]: repo_urls: list[str] = [] repo_index: dict[str, int] = {} + def slots(per_mutex: dict[str, Slot]) -> list[Any]: + # Aligned with "mutexes": 0 where nothing is built for that mutex, + # otherwise [platform bitmask, newest version built there]. + return [[per_mutex[v].mask, per_mutex[v].version] if v in per_mutex else 0 for v in mutexes] + packages: list[list[Any]] = [] for name in sorted(index): conda_name = f"ros-{distro}-{name.replace('_', '-')}" @@ -310,23 +341,37 @@ def build(distro: str, channel: str) -> dict[str, Any]: repo_index[entry.source] = len(repo_urls) repo_urls.append(entry.source) - description, package_license = metadata.get(name, ("", "")) packages.append( [ name.replace("_", "-"), # conda spelling, `ros--` stripped - description, - package_license, + metadata.get(name, ""), entry.version, # as released into the ROS index newest_build.get(conda_name, 0) // 1000, # newest build, seconds repo_index.get(entry.source, -1), # index into "repos" - # Aligned with "mutexes": 0 where nothing is built for that mutex, - # otherwise [platform bitmask, newest version built there]. - [ - [per_mutex[v].mask, per_mutex[v].version] if v in per_mutex else 0 - for v in mutexes - ], + 1, # released into the ROS index + slots(per_mutex), + ] + ) + + # Packages on the channel that rosdistro has never released: no description, + # index version or source repository, but installable all the same. + prefix = f"ros-{distro}-" + released = {f"ros-{distro}-{name.replace('_', '-')}" for name in index} + for conda_name in sorted(set(builds) - released): + if not conda_name.startswith(prefix): + continue + packages.append( + [ + conda_name.removeprefix(prefix), + "", + "", + newest_build.get(conda_name, 0) // 1000, + -1, + 0, + slots(builds[conda_name]), ] ) + packages.sort(key=lambda package: package[0]) return { "distro": distro, @@ -334,13 +379,13 @@ def build(distro: str, channel: str) -> dict[str, Any]: "platforms": PLATFORMS, "mutexPackage": mutex_package, "mutexes": mutexes, - "fields": ["name", "desc", "license", "indexVersion", "updated", "repo", "builds"], + "fields": ["name", "desc", "indexVersion", "updated", "repo", "indexed", "builds"], "repos": repo_urls, "packages": packages, } -def write(document: dict[str, Any], path: str) -> None: +def write(document: dict[str, Any], path: Path) -> None: """Write the document with one package per line. Compact JSON on a single line would make every rebuild a one-line diff covering @@ -354,22 +399,17 @@ def write(document: dict[str, Any], path: str) -> None: body = ",\n".join(json.dumps(p, separators=(",", ":")) for p in document["packages"]) text = json.dumps(head, separators=(",", ":"))[:-1] + ',"packages":[\n' + body + "\n]}\n" - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8", newline="\n") as handle: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: handle.write(text) -def main() -> None: - """Build one distro and report what came out.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("distro", help="ROS distro to build the table for") - parser.add_argument("channel", help="conda channel name, or a full base URL") - args = parser.parse_args() - - print(f"{args.distro} ({args.channel}):", file=sys.stderr) - document = build(args.distro, args.channel) +def refresh(distro: str, channel: str) -> None: + """Build one distro, write it, and report what came out.""" + print(f"{distro} ({channel}):", file=sys.stderr) + document = build(distro, channel) - path = os.path.join("public", "data", f"{args.distro}.json") + path = Path("public") / "data" / f"{distro}.json" write(document, path) total = len(document["packages"]) @@ -378,10 +418,44 @@ def main() -> None: ever = sum(1 for p in document["packages"] if any(p[6])) print( f" -> {path}: {total} packages, {ever} built at some point, " - f"{on_newest} on mutex {newest}, {os.path.getsize(path) / 1e6:.2f} MB", + f"{on_newest} on mutex {newest}, {path.stat().st_size / 1e6:.2f} MB", file=sys.stderr, ) +def main() -> None: + """Build one distro, or every distro the pipeline maintains.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("distro", nargs="?", help="ROS distro to build the table for") + parser.add_argument("channel", nargs="?", help="conda channel name, or a full base URL") + parser.add_argument( + "--all", + action="store_true", + help="regenerate every distro with a dataChannel in src/data/distros.json", + ) + args = parser.parse_args() + + if args.all == bool(args.distro) or (args.distro and not args.channel): + parser.error("pass either or --all") + + if not args.all: + refresh(args.distro, args.channel) + return + + # One distro failing must not take the other five down with it: finish the + # loop, keep whatever succeeded, and only then report the failures. + failed: list[str] = [] + for entry in json.loads(DISTROS_JSON.read_text())["distros"]: + if not entry["dataChannel"]: + continue + try: + refresh(entry["name"], entry["dataChannel"]) + except Exception as error: # noqa: BLE001 - reported and folded into the exit code + print(f" error: {entry['name']} failed: {error}", file=sys.stderr) + failed.append(entry["name"]) + if failed: + sys.exit(f"failed to update: {', '.join(failed)}") + + if __name__ == "__main__": main() diff --git a/scripts/copy-to-distro-specific-channel.py b/scripts/copy-to-distro-specific-channel.py index c4228554..0f3137b1 100644 --- a/scripts/copy-to-distro-specific-channel.py +++ b/scripts/copy-to-distro-specific-channel.py @@ -3,6 +3,7 @@ import subprocess import niquests +from urllib3.util.retry import Retry # Configuration BASE_URL = "https://conda.anaconda.org" @@ -17,19 +18,26 @@ "emscripten-wasm32", ] +session = niquests.Session( + retries=Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) +) -def fetch_repodata(channel: str, platform: str) -> dict | None: + +def fetch_repodata(channel: str, platform: str) -> dict: """ Fetch the repodata.json file from a given channel and platform. + + A 404 means the channel has no such platform yet and reads as empty. Anything + else raises: treating a transient error as an empty channel would make the run + copy nothing and still report success. """ url = f"{BASE_URL}/{channel}/{platform}/repodata.json" - response = niquests.get(url) + response = session.get(url) - if response.status_code == 200: - return response.json() - else: - print(f"Error fetching repodata.json from {channel}/{platform}: {response.status_code}") - return None + if response.status_code == 404: + return {} + response.raise_for_status() + return response.json() or {} def upload_package( @@ -106,8 +114,8 @@ def main() -> None: source_repodata = {} destination_repodata = {} for platform in PLATFORMS: - source_repodata[platform] = fetch_repodata(SOURCE_CHANNEL, platform) or {} - destination_repodata[platform] = fetch_repodata(destination_channel, platform) or {} + source_repodata[platform] = fetch_repodata(SOURCE_CHANNEL, platform) + destination_repodata[platform] = fetch_repodata(destination_channel, platform) # Process packages for each platform for platform in PLATFORMS: diff --git a/src/assets/icons/bolt.svg b/src/assets/icons/bolt.svg new file mode 100644 index 00000000..6be7d091 --- /dev/null +++ b/src/assets/icons/bolt.svg @@ -0,0 +1,14 @@ + diff --git a/src/assets/icons/book.svg b/src/assets/icons/book.svg new file mode 100644 index 00000000..5c571a71 --- /dev/null +++ b/src/assets/icons/book.svg @@ -0,0 +1,17 @@ + diff --git a/src/assets/icons/chat.svg b/src/assets/icons/chat.svg new file mode 100644 index 00000000..b05acb80 --- /dev/null +++ b/src/assets/icons/chat.svg @@ -0,0 +1,16 @@ + diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 00000000..c71bf587 --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1,15 @@ + diff --git a/src/assets/icons/layers.svg b/src/assets/icons/layers.svg new file mode 100644 index 00000000..99da13d6 --- /dev/null +++ b/src/assets/icons/layers.svg @@ -0,0 +1,16 @@ + diff --git a/src/assets/icons/screen.svg b/src/assets/icons/screen.svg new file mode 100644 index 00000000..cdc20691 --- /dev/null +++ b/src/assets/icons/screen.svg @@ -0,0 +1,15 @@ + diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 00000000..da5d05e1 --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1,12 @@ + diff --git a/src/assets/icons/shield.svg b/src/assets/icons/shield.svg new file mode 100644 index 00000000..4c6c3025 --- /dev/null +++ b/src/assets/icons/shield.svg @@ -0,0 +1,15 @@ + diff --git a/src/components/DistroHead.astro b/src/components/DistroHead.astro index e006e4cb..d312311e 100644 --- a/src/components/DistroHead.astro +++ b/src/components/DistroHead.astro @@ -19,7 +19,7 @@ const { distro } = Astro.props; height="96" />
-

ROS{distro.ros} {title(distro)}

+

ROS {distro.ros} {title(distro)}

{supportLine(distro)}

diff --git a/src/components/DistroTabs.astro b/src/components/DistroTabs.astro index e2250533..d9c8d1fa 100644 --- a/src/components/DistroTabs.astro +++ b/src/components/DistroTabs.astro @@ -12,9 +12,16 @@ const { current } = Astro.props; { tabOrder().map((d) => ( {d.name} @@ -50,6 +57,10 @@ const { current } = Astro.props; .distro:hover { color: var(--sl-color-white); } + /* End-of-life releases stay reachable but recede; the active tab wins. */ + .distro--eol:not(.distro--active) { + color: var(--sl-color-gray-4); + } .distro--active { color: var(--sl-color-white); border-bottom-color: var(--sl-color-white); diff --git a/src/components/home/ClosingCta.astro b/src/components/home/ClosingCta.astro new file mode 100644 index 00000000..1a54b25c --- /dev/null +++ b/src/components/home/ClosingCta.astro @@ -0,0 +1,58 @@ +--- +import RsButton from "./RsButton.astro"; + +interface Props { + packagesHref: string; +} + +const { packagesHref } = Astro.props; +--- + +
+

Your robot doesn't care what OS you run

+

+ Four commands from an empty folder to rviz on your machine - whichever + machine that is. +

+
+ Get started + Browse the packages +
+
+ + diff --git a/src/components/home/Contribute.astro b/src/components/home/Contribute.astro new file mode 100644 index 00000000..2c820c5d --- /dev/null +++ b/src/components/home/Contribute.astro @@ -0,0 +1,72 @@ +--- +import RsButton from "./RsButton.astro"; +--- + +
+
+

Missing a package? Add it in one line

+

+ { + /* The space before the code chip is explicit: Astro drops an + indented line break instead of collapsing it to a space. */ + } + Most packages join RoboStack as a one-line pull request: put the name in a{ + " " + } + vinca.yaml and CI builds it for every platform. +

+
+ Add a package → +
+ + diff --git a/src/components/home/DistroCard.astro b/src/components/home/DistroCard.astro index c4f01147..3ee8351a 100644 --- a/src/components/home/DistroCard.astro +++ b/src/components/home/DistroCard.astro @@ -2,22 +2,16 @@ interface Props { href: string; channel: string; - /** CSS color for the channel dot. */ - dot: string; name: string; description: string; } -const { href, channel, dot, name, description } = Astro.props; +const { href, channel, name, description } = Astro.props; --- - +
- {channel} + {channel}
{name}
{description}
@@ -28,19 +22,12 @@ const { href, channel, dot, name, description } = Astro.props; .card { display: block; padding: 22px; - background: var(--bg-surface); - border: 1px solid var(--border-1); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); color: var(--fg-1); text-decoration: none; - transition: - box-shadow var(--dur-3) var(--ease-out), - transform var(--dur-3) var(--ease-out); + transition: box-shadow var(--dur-3) var(--ease-out); } .card:hover { box-shadow: var(--shadow-hover-accent); - transform: translateY(-2px); text-decoration: none; } .top { @@ -54,13 +41,6 @@ const { href, channel, dot, name, description } = Astro.props; align-items: center; gap: 10px; } - .dot { - width: 12px; - height: 12px; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; - } .channel code { font-family: var(--font-mono); font-size: 14px; diff --git a/src/components/home/DistroCards.astro b/src/components/home/DistroCards.astro new file mode 100644 index 00000000..aab424ec --- /dev/null +++ b/src/components/home/DistroCards.astro @@ -0,0 +1,75 @@ +--- +import type { Distro } from "../../data/distros"; +import { monthYear, tabOrder, title } from "../../data/distros"; +import DistroCard from "./DistroCard.astro"; + +/** + * The three distro cards, derived so a new release reshuffles them on its + * own: the two newest releases, then the newest LTS after them. The + * descriptions come from the same data: LTS or not, and the support window. + */ +const dated = tabOrder().filter((d) => d.status !== "rolling"); +const [newest, previous] = dated; +const olderLts = dated.slice(2).find((d) => d.lts && d.status === "active"); + +function supportSpan(d: Distro): string { + if (d.status === "eol") return `end of life since ${monthYear(d.eol ?? "")}`; + if (d.eol) return `supported until ${monthYear(d.eol)}`; + return "support window not yet published"; +} + +function kind(d: Distro): string { + return d.lts ? "LTS release" : "release"; +} + +const cards: { distro: Distro; description: string }[] = []; +if (newest) { + cards.push({ + distro: newest, + description: `The newest ${kind(newest)}, ${supportSpan(newest)}.`, + }); +} +if (previous) { + cards.push({ + distro: previous, + description: `The previous ${kind(previous)}, ${supportSpan(previous)}.`, + }); +} +if (olderLts) { + cards.push({ + distro: olderLts, + description: `An ${kind(olderLts)}, ${supportSpan(olderLts)}.`, + }); +} +--- + +
+ { + cards.map(({ distro, description }) => ( + + )) + } +
+ + diff --git a/src/components/home/PropCard.astro b/src/components/home/PropCard.astro index cb70bbcc..54742d09 100644 --- a/src/components/home/PropCard.astro +++ b/src/components/home/PropCard.astro @@ -14,7 +14,7 @@ const { title, href, linkLabel } = Astro.props; const Tag = href ? "a" : "div"; --- - +
{title}

@@ -26,21 +26,14 @@ const Tag = href ? "a" : "div"; position: relative; display: block; padding: 24px; - background: var(--bg-surface); - border: 1px solid var(--border-1); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); color: var(--fg-1); text-decoration: none; } a.card { - transition: - box-shadow var(--dur-3) var(--ease-out), - transform var(--dur-3) var(--ease-out); + transition: box-shadow var(--dur-3) var(--ease-out); } a.card:hover { box-shadow: var(--shadow-hover-accent); - transform: translateY(-2px); text-decoration: none; } .icon { @@ -52,13 +45,8 @@ const Tag = href ? "a" : "div"; align-items: center; justify-content: center; margin-bottom: 16px; - } - .icon :global(svg) { - fill: var(--fg-1); - } - .icon :global(svg.stroke) { - fill: none; - stroke: var(--fg-1); + /* The icon files in src/assets/icons paint with currentColor. */ + color: var(--fg-1); } .title { font-family: var(--font-display); diff --git a/src/components/home/QuickStart.astro b/src/components/home/QuickStart.astro index 15f22c04..77e11d63 100644 --- a/src/components/home/QuickStart.astro +++ b/src/components/home/QuickStart.astro @@ -1,14 +1,22 @@ --- -/** The hero's quick-start card: a mock terminal with a copy button. */ +import Copy from "../../assets/icons/copy.svg"; +import { newestRelease } from "../../data/distros"; + +/** + * The hero's quick-start card: a mock terminal with a copy button. The + * commands use the newest release as their example, matching where the + * package links land. + */ +const distro = newestRelease(); const commands = [ - "pixi init ros_ws -c https://prefix.dev/robostack-humble", + `pixi init ros_ws -c ${distro.base}/${distro.channel}`, "cd ros_ws", - "pixi add ros-humble-desktop", + `pixi add ros-${distro.name}-desktop`, "pixi run rviz2", ]; --- -
+
Quick start
@@ -19,18 +27,7 @@ const commands = [ terminal
@@ -52,17 +49,8 @@ const commands = [ diff --git a/src/components/PackageTable.astro b/src/components/package-table/PackageTable.astro similarity index 88% rename from src/components/PackageTable.astro rename to src/components/package-table/PackageTable.astro index 420379f9..5c294ee5 100644 --- a/src/components/PackageTable.astro +++ b/src/components/package-table/PackageTable.astro @@ -1,6 +1,6 @@ --- -import type { Distro } from "../data/distros"; -import { browseUrl } from "../data/distros"; +import type { Distro } from "../../data/distros"; +import { browseUrl } from "../../data/distros"; import PackageTable from "./PackageTable.svelte"; interface Props { diff --git a/src/components/PackageTable.svelte b/src/components/package-table/PackageTable.svelte similarity index 78% rename from src/components/PackageTable.svelte rename to src/components/package-table/PackageTable.svelte index d6583548..1c5e2575 100644 --- a/src/components/PackageTable.svelte +++ b/src/components/package-table/PackageTable.svelte @@ -1,9 +1,8 @@