diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index c0203fd..37d5d77 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -9,6 +9,12 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: + # Monthly redeploy (1st of each month, 06:00 UTC) so the live stats injected + # at build time (GitHub stars/forks/contributors, PyPI downloads) stay fresh + # without needing a push. Scheduled runs always execute on the default branch. + schedule: + - cron: "0 6 1 * *" + # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read @@ -40,6 +46,12 @@ jobs: run: npm ci - name: Build site run: npm run build + - name: Refresh live stats + run: npm run stats + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + timeout-minutes: 3 - name: Add .nojekyll run: touch dist/.nojekyll - name: Setup Pages diff --git a/about.html b/about.html index eefe8ba..fbe72dc 100644 --- a/about.html +++ b/about.html @@ -45,6 +45,7 @@ /> + @@ -53,45 +54,55 @@ +
-
-
- - - -
- + href="https://app.rocketpy.org/" + class="simulate-button nav-cta" + role="button" + > + + Simulate + + +
-
+

Since 2018 · Open Source

@@ -1055,6 +1066,22 @@ renderFeatured("coreGrid", "core"); renderContributors(); renderStats(); + + // Mobile nav toggle — mirrors the landing page's shared site-header. + const header = document.querySelector(".site-header"); + const navToggle = document.querySelector(".nav-toggle"); + if (header && navToggle) { + navToggle.addEventListener("click", () => { + const open = header.classList.toggle("nav-open"); + navToggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + header.querySelectorAll(".nav-link").forEach((link) => + link.addEventListener("click", () => { + header.classList.remove("nav-open"); + navToggle.setAttribute("aria-expanded", "false"); + }), + ); + } diff --git a/css/footer.css b/css/footer.css index 3d2b43a..d762fad 100644 --- a/css/footer.css +++ b/css/footer.css @@ -191,3 +191,142 @@ margin-left: 0; } } + +/* ========================================================================== + EXPANDED SITE FOOTER (landing page) — additive; the legacy + .footer-left/.footer-center/.footer-right rules above still drive the + About page footer unchanged. + ========================================================================== */ +.site-footer { + padding: clamp(2.5rem, 5vw, 3.5rem) clamp(1.25rem, 4vw, 3rem) 1.8rem; + text-align: left; + font: + normal 16px "Open Sans", + sans-serif; +} + +.footer-top { + display: grid; + grid-template-columns: minmax(220px, 1fr) 2.2fr; + gap: clamp(2rem, 5vw, 4rem); + max-width: 1240px; + margin: 0 auto; +} + +.footer-brand .footer-logo { + margin-bottom: 14px; +} + +.footer-tagline { + font-family: "Open Sans", sans-serif; + font-size: 0.9rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); + max-width: 280px; + margin: 0; +} + +.footer-social { + display: flex; + gap: 10px; + margin-top: 18px; +} + +.footer-social a { + width: 38px; + height: 38px; + border-radius: 8px; + background: #33383b; + color: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 18px; + text-decoration: none; + transition: + background 0.2s ease, + transform 0.2s ease; +} + +.footer-social a:hover, +.footer-social a:focus-visible { + background: #4a5568; + transform: translateY(-2px); +} + +.footer-links-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; +} + +.footer-col h4 { + font-family: "Rubik", sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #ffffff; + margin: 0 0 12px; +} + +.footer-col a { + display: block; + font-family: "Open Sans", sans-serif; + font-size: 0.9rem; + font-weight: 400; + color: rgba(255, 255, 255, 0.68); + text-decoration: none; + padding: 6px 0; + transition: color 0.2s ease; +} + +.footer-col a:hover, +.footer-col a:focus-visible { + color: #ffffff; +} + +.footer-bottom { + max-width: 1240px; + margin: clamp(2rem, 4vw, 2.6rem) auto 0; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.footer-bottom p { + font-family: "Open Sans", sans-serif; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; +} + +.footer-bottom a { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} + +.footer-bottom a:hover { + color: #ffffff; +} + +@media (max-width: 880px) { + .footer-top { + grid-template-columns: 1fr; + } + + .footer-bottom { + justify-content: center; + text-align: center; + } +} + +@media (max-width: 620px) { + .footer-links-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/css/home.css b/css/home.css new file mode 100644 index 0000000..fc71020 --- /dev/null +++ b/css/home.css @@ -0,0 +1,988 @@ +/* ========================================================================== + ROCKETPY LANDING PAGE — HOME SECTIONS (home.css) + Loaded only by index.html, after main.css + footer.css. + Reuses main.css design tokens and ports the on-brand component recipes + first authored in about.css (stat row, featured cards, institution grid, + CTA cluster) so the landing page and the team page read as one system. + ========================================================================== */ + +/* ---- tokens ported from about.css (main.css owns a disjoint set, so these + only ADD; they never override main.css and never leak — home.css is + not loaded on about.html). ------------------------------------------- */ +:root { + --accent: #f0ab50; + --accent-soft: #ffd38f; + --accent-teal: #2dd4bf; + --accent-teal-soft: #99f6e4; + --panel: rgba(33, 35, 50, 0.45); + --panel-strong: rgba(33, 35, 50, 0.65); + --hairline: rgba(255, 255, 255, 0.08); + --hairline-strong: rgba(255, 255, 255, 0.18); + --ink-soft: rgba(255, 255, 255, 0.78); + --ink-faint: rgba(255, 255, 255, 0.6); + --code-bg: #0b1226; +} + +/* Compensate for the sticky header when jumping to an in-page anchor. */ +section[id], +:target { + scroll-margin-top: 92px; +} + +/* small layout helpers (keep markup free of inline styles) */ +.align-left { + text-align: left; +} +.justify-start { + justify-content: flex-start; +} +.mt-content { + margin-top: var(--space-content); +} + +/* Overflow guard: grid/flex children default to min-width:auto, so a wide + child (the code block's long lines) would stretch the page instead of + scrolling. Let them shrink and keep their own overflow-x:auto. */ +.split-layout > * { + min-width: 0; +} +.code-block { + max-width: 100%; +} + +/* The sticky site header / primary nav (base, responsive and reduced-motion + rules) now lives in the shared css/nav.css, loaded by both index.html and + about.html so they read as one site. */ + +/* =============================== HERO ==================================== */ +.hero-sub { + margin-top: 4px; +} + +.hero-sub strong { + color: #ffffff; + font-weight: 700; +} + +.install-chip { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: var(--space-content); + padding: 9px 9px 9px 16px; + background: var(--code-bg); + border: 1px solid var(--surface-border); + border-radius: 10px; + max-width: 100%; +} + +.install-chip code { + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 0.98rem; + color: #e6ebff; + white-space: nowrap; + overflow-x: auto; +} + +.install-chip code::before { + content: "$ "; + color: var(--accent-text); +} + +.install-hint { + margin-top: 12px; + font-family: "Ruda", sans-serif; + font-size: 0.82rem; + letter-spacing: 0.02em; + color: var(--ink-faint); +} + +.hero-ctas { + margin-top: var(--space-content); + justify-content: flex-start; +} + +.hero-microproof { + margin-top: 18px; + font-family: "Ruda", sans-serif; + font-size: 0.82rem; + letter-spacing: 0.04em; + color: rgba(255, 255, 255, 0.55); +} + +.hero-microproof strong { + color: var(--accent-text); +} + +/* ====================== SHARED SECTION HEADINGS ========================== */ +/* Centered heading treatment for the new sections (kept distinct from + main.css's left-aligned .section-title, which the legacy split sections + still use). */ +.sec-head { + text-align: center; + max-width: 760px; + margin: 0 auto clamp(1.6rem, 4vw, 2.6rem); +} + +.sec-eyebrow { + font-family: "Ruda", sans-serif; + font-size: 12.5px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent-text); + margin-bottom: 10px; +} + +.sec-eyebrow.is-teal { + color: var(--accent-teal); +} + +.sec-title { + font-family: "Rubik", sans-serif; + font-size: clamp(1.5rem, 3.6vw, 2.2rem); + font-weight: 700; + color: #ffffff; + letter-spacing: 0.01em; +} + +.sec-title::after { + content: ""; + display: block; + width: 54px; + height: 3px; + margin: 14px auto 0; + border-radius: 3px; + background: linear-gradient( + 130deg, + var(--accent) 0%, + var(--accent-soft) 100% + ); +} + +.sec-lede { + font-family: "Open Sans", sans-serif; + font-size: clamp(0.95rem, 2vw, 1.08rem); + color: var(--ink-soft); + line-height: 1.65; + margin-top: 16px; +} + +.sec-cta { + text-align: center; + margin-top: clamp(1.8rem, 4vw, 2.6rem); +} + +.text-link { + font-family: "Ruda", sans-serif; + font-weight: 700; + font-size: 0.95rem; + color: var(--accent-text); + text-decoration: none; + border-bottom: 1px solid rgba(255, 195, 121, 0.35); + transition: color 0.2s ease; +} + +.text-link:hover, +.text-link:focus-visible { + color: var(--accent-strong); +} + +/* =========================== STATS BAND ================================= */ +.stats-band { + padding: clamp(1.6rem, 4vw, 2.3rem) clamp(1.5rem, 4vw, 2.4rem); +} + +.stat-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: clamp(1.1rem, 4vw, 3rem); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + min-width: 88px; +} + +.stat-num { + font-family: "Rubik", sans-serif; + font-size: clamp(1.5rem, 4vw, 2.15rem); + font-weight: 700; + color: #ffffff; + line-height: 1.1; +} + +.stat-num.is-accent { + color: var(--accent-text); +} + +.stat-label { + font-family: "Ruda", sans-serif; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-faint); + margin-top: 6px; + text-align: center; +} + +.stats-note { + margin-top: 18px; + text-align: center; + font-family: "Open Sans", sans-serif; + font-size: 0.82rem; + color: var(--ink-faint); +} + +/* ===================== QUICKSTART / CODE BLOCK ========================== */ +.quickstart-layout { + align-items: start; +} + +.qs-steps { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 18px; +} + +.qs-step { + display: grid; + grid-template-columns: 40px 1fr; + gap: 14px; + align-items: start; +} + +.qs-num { + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: "Rubik", sans-serif; + font-weight: 700; + color: var(--accent-text); + background: rgba(240, 171, 80, 0.12); + border: 1px solid rgba(240, 171, 80, 0.28); +} + +.qs-step h3 { + font-family: "Rubik", sans-serif; + font-size: 1.02rem; + color: #ffffff; + margin: 6px 0 2px; +} + +.qs-step p { + font-family: "Open Sans", sans-serif; + font-size: 0.92rem; + line-height: 1.55; + color: var(--ink-soft); +} + +.qs-step code { + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + color: var(--accent-teal-soft); + font-size: 0.88em; +} + +.code-block { + position: relative; + background: var(--code-bg); + border: 1px solid var(--surface-border); + border-radius: 14px; + padding: 44px 16px 16px; + overflow-x: auto; +} + +.code-block::before { + content: ""; + position: absolute; + top: 16px; + left: 16px; + width: 42px; + height: 10px; + border-radius: 999px; + background: radial-gradient(circle at 5px 5px, #ff5f57 4px, transparent 5px), + radial-gradient(circle at 21px 5px, #febc2e 4px, transparent 5px), + radial-gradient(circle at 37px 5px, #28c840 4px, transparent 5px); +} + +.code-block code { + display: block; + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 0.86rem; + line-height: 1.7; + color: #e6ebff; + white-space: pre; + tab-size: 4; +} + +.tok-kw { + color: #ff9ec4; +} +.tok-cls { + color: #7fd4ff; +} +.tok-fn { + color: #8ce6b0; +} +.tok-str { + color: #ffd38f; +} +.tok-num { + color: #c3a6ff; +} +.tok-com { + color: rgba(255, 255, 255, 0.5); + font-style: italic; +} +.tok-op { + color: rgba(255, 255, 255, 0.72); +} + +.copy-btn { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 6px; + font-family: "Ruda", sans-serif; + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.82); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--surface-border); + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + transition: + color 0.2s ease, + border-color 0.2s ease, + background 0.2s ease; +} + +.copy-btn:hover, +.copy-btn:focus-visible { + color: #ffffff; + background: rgba(255, 255, 255, 0.12); +} + +.copy-btn.is-copied { + color: var(--accent-teal); + border-color: rgba(45, 212, 191, 0.5); +} + +.install-chip .copy-btn { + position: static; +} + +.qs-ctas { + margin-top: clamp(1.6rem, 4vw, 2.2rem); +} + +/* ============================ SHOWCASE ================================== */ +.showcase-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(258px, 1fr)); + gap: 20px; +} + +.showcase-card { + background: var(--panel); + border: 1px solid var(--hairline); + border-radius: 18px; + overflow: hidden; + transition: + transform 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; +} + +.showcase-card:hover { + transform: translateY(-6px); + border-color: var(--hairline-strong); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.42); +} + +.showcase-figure { + padding: 12px 12px 0; + background: radial-gradient( + circle at 50% 35%, + rgba(240, 171, 80, 0.08), + transparent 70% + ); +} + +.showcase-figure img { + display: block; + width: 100%; + height: auto; +} + +.showcase-cap { + padding: 10px 20px 22px; +} + +.showcase-cap h3 { + font-family: "Rubik", sans-serif; + font-size: 1.04rem; + color: #ffffff; + margin-bottom: 5px; +} + +.showcase-cap p { + font-family: "Open Sans", sans-serif; + font-size: 0.88rem; + line-height: 1.5; + color: var(--ink-soft); +} + +/* ========================= FEATURES GRID ================================ */ +/* Auto-scrolling feature carousel (driven by the marquee script in + index.html). Bleeds to the panel edges; the section's overflow:hidden clips + the scrolling track at the rounded corners. */ +.feat-marquee { + margin: 0 calc(-1 * clamp(1.5rem, 4vw, 2.4rem)); + padding: 10px clamp(1.5rem, 4vw, 2.4rem); + overflow-x: auto; + overflow-y: hidden; +} + +/* Scrollbar hidden only while the auto-scroll is active (JS adds the class); + without JS the scrollbar stays as a manual-scroll affordance. */ +.feat-marquee.is-animating { + scrollbar-width: none; +} + +.feat-marquee.is-animating::-webkit-scrollbar { + display: none; +} + +.marquee-track { + display: flex; + gap: 18px; + width: max-content; +} + +.feat-marquee .feat-card { + flex: 0 0 clamp(240px, 72vw, 288px); +} + +.feat-card { + background: var(--panel); + border: 1px solid var(--hairline); + border-radius: 16px; + padding: 24px 22px; + transition: + transform 0.3s ease, + background 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; +} + +.feat-card:hover { + transform: translateY(-6px); + background: var(--panel-strong); + border-color: var(--hairline-strong); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36); +} + +.feat-icon { + width: 46px; + height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + font-size: 20px; + margin-bottom: 16px; + color: var(--accent-text); + background: rgba(240, 171, 80, 0.12); + border: 1px solid rgba(240, 171, 80, 0.24); +} + +.feat-card h3 { + font-family: "Rubik", sans-serif; + font-size: 1.06rem; + color: #ffffff; + margin-bottom: 8px; +} + +.feat-card p { + font-family: "Open Sans", sans-serif; + font-size: 0.9rem; + line-height: 1.55; + color: var(--ink-soft); +} + +/* ==================== VALIDATION + USED-BY WALL ========================= */ +.validation-layout { + align-items: center; + margin-bottom: clamp(1.8rem, 4vw, 2.6rem); +} + +.validation-head .big-stat { + font-family: "Rubik", sans-serif; + font-size: clamp(2.4rem, 7vw, 3.6rem); + font-weight: 700; + color: var(--accent-text); + line-height: 1; +} + +.validation-head p { + font-family: "Open Sans", sans-serif; + font-size: 1rem; + line-height: 1.65; + color: var(--ink-soft); + margin-top: 12px; +} + +.usedby-label { + text-align: center; + font-family: "Ruda", sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--ink-faint); + margin-bottom: 18px; +} + +.inst-grid { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); + gap: 14px; +} + +.inst-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 18px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.35); + border: 1px solid var(--hairline); + border-left: 3px solid var(--accent); + transition: + transform 0.25s ease, + background 0.25s ease; +} + +.inst-card:hover { + transform: translateY(-3px); + background: rgba(33, 35, 50, 0.55); +} + +.inst-name { + font-family: "Rubik", sans-serif; + font-size: 0.98rem; + font-weight: 700; + color: #ffffff; +} + +.inst-place { + font-family: "Open Sans", sans-serif; + font-size: 0.84rem; + color: var(--ink-faint); +} + +.contrib-note { + font-family: "Open Sans", sans-serif; + font-size: 0.86rem; + color: var(--ink-faint); + text-align: center; + max-width: 640px; + margin: 1.8rem auto 0; + line-height: 1.6; +} + +.contrib-note a { + color: var(--accent-soft); + text-decoration: none; + border-bottom: 1px solid rgba(255, 211, 143, 0.35); +} + +/* =========================== ECOSYSTEM ================================== */ +.eco-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 18px; +} + +.eco-card { + position: relative; + display: flex; + flex-direction: column; + padding: 26px 24px; + border-radius: 16px; + background: var(--panel); + border: 1px solid var(--hairline); + overflow: hidden; + text-decoration: none; + transition: + transform 0.3s ease, + background 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; +} + +.eco-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + opacity: 0.85; + background: linear-gradient(90deg, var(--accent) 0%, var(--accent-soft) 100%); +} + +.eco-card:hover { + transform: translateY(-6px); + background: var(--panel-strong); + border-color: var(--hairline-strong); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4); +} + +.eco-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.eco-icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 11px; + font-size: 18px; + color: var(--accent-text); + background: rgba(240, 171, 80, 0.12); + border: 1px solid rgba(240, 171, 80, 0.24); + flex-shrink: 0; +} + +.eco-name { + font-family: "Rubik", sans-serif; + font-weight: 700; + font-size: 1.1rem; + color: #ffffff; +} + +.eco-badge { + margin-left: auto; + font-family: "Ruda", sans-serif; + font-size: 12px; + font-weight: 700; + color: var(--ink-faint); + white-space: nowrap; +} + +.eco-badge .fa-star { + color: var(--accent-strong); + font-size: 11px; +} + +.eco-desc { + font-family: "Open Sans", sans-serif; + font-size: 0.9rem; + line-height: 1.55; + color: var(--ink-soft); + flex-grow: 1; +} + +.eco-link { + margin-top: 16px; + font-family: "Ruda", sans-serif; + font-weight: 700; + font-size: 13px; + color: var(--accent-text); +} + +.eco-card:hover .eco-link { + color: var(--accent-strong); +} + +/* =========================== COMMUNITY ================================= */ +.community-lede { + max-width: 720px; + margin: 0 auto clamp(1.6rem, 4vw, 2.2rem); + text-align: center; +} + +.community-lede p { + font-family: "Open Sans", sans-serif; + font-size: 1rem; + line-height: 1.7; + color: var(--ink-soft); +} + +.community-lede strong { + color: #ffffff; +} + +.community-lede a { + color: var(--accent-soft); + text-decoration: none; + border-bottom: 1px solid rgba(255, 211, 143, 0.35); +} + +.channels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 14px; + margin-bottom: clamp(1.8rem, 4vw, 2.4rem); +} + +.channel-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 20px 12px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.35); + border: 1px solid var(--hairline); + text-decoration: none; + transition: + transform 0.25s ease, + background 0.25s ease, + border-color 0.25s ease; +} + +.channel-card i { + font-size: 26px; + color: #ffffff; +} + +.channel-card span { + font-family: "Ruda", sans-serif; + font-weight: 700; + font-size: 13px; + color: var(--ink-soft); +} + +.channel-card:hover { + transform: translateY(-4px); + background: rgba(33, 35, 50, 0.6); + border-color: rgba(240, 171, 80, 0.35); +} + +.channel-card:hover span { + color: #ffffff; +} + +/* ============================ CITE ===================================== */ +.cite-grid { + display: grid; + gap: 16px; + max-width: 920px; + margin: 0 auto; +} + +.cite-item { + background: var(--panel); + border: 1px solid var(--hairline); + border-left: 3px solid var(--accent); + border-radius: 14px; + padding: 20px 22px; +} + +.cite-item h3 { + font-family: "Rubik", sans-serif; + font-size: 1rem; + color: #ffffff; + margin-bottom: 6px; +} + +.cite-meta { + font-family: "Open Sans", sans-serif; + color: var(--ink-soft); + font-size: 0.9rem; + line-height: 1.55; +} + +.cite-meta a { + color: var(--accent-text); + text-decoration: none; + border-bottom: 1px solid rgba(255, 195, 121, 0.35); +} + +.cite-bibtex { + max-width: 920px; + margin: 20px auto 0; +} + +.cite-bibtex .code-block { + padding-top: 16px; +} + +.cite-bibtex .code-block::before { + content: none; +} + +/* ============================ FINAL CTA ================================= */ +.final-cta { + text-align: center; + padding: clamp(2.2rem, 6vw, 3.6rem) 1.5rem; +} + +.final-cta .install-chip { + margin: var(--space-content) auto 0; +} + +.final-cta .cta-buttons { + margin-top: var(--space-content); +} + +/* ===================== PORTED CTA CLUSTER (about.css) =================== */ +.cta-buttons { + display: flex; + flex-wrap: wrap; + gap: 14px; + justify-content: center; +} + +.cta-btn { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: "Ruda", sans-serif; + font-size: 15px; + font-weight: 700; + padding: 13px 26px; + border-radius: 999px; + text-decoration: none; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + background 0.2s ease; +} + +.cta-primary { + background: linear-gradient( + 130deg, + var(--accent) 0%, + var(--accent-soft) 100% + ); + color: #030b28; + box-shadow: 0 6px 20px rgba(240, 171, 80, 0.35); +} + +.cta-primary:hover, +.cta-primary:focus-visible { + transform: translateY(-2px); + box-shadow: 0 10px 26px rgba(240, 171, 80, 0.5); +} + +.cta-secondary { + background: rgba(255, 255, 255, 0.06); + color: #ffffff; + border: 1px solid var(--hairline-strong); +} + +.cta-secondary:hover, +.cta-secondary:focus-visible { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.12); +} + +.cta-ghost { + background: transparent; + color: var(--ink-soft); + border: 1px solid transparent; +} + +.cta-ghost:hover, +.cta-ghost:focus-visible { + color: #ffffff; +} + +/* ===================== EXTENDED SCROLL-REVEAL STAGGER =================== */ +/* main.css only staggers children 1–4; grids need more. */ +.reveal-section.is-visible .reveal-item:nth-child(5) { + transition-delay: 0.36s; +} +.reveal-section.is-visible .reveal-item:nth-child(6) { + transition-delay: 0.42s; +} +.reveal-section.is-visible .reveal-item:nth-child(7) { + transition-delay: 0.48s; +} +.reveal-section.is-visible .reveal-item:nth-child(8) { + transition-delay: 0.52s; +} +.reveal-section.is-visible .reveal-item:nth-child(9) { + transition-delay: 0.56s; +} +.reveal-section.is-visible .reveal-item:nth-child(n + 10) { + transition-delay: 0.6s; +} + +/* ============================ RESPONSIVE =============================== */ +/* Header/nav breakpoints live in css/nav.css. */ +@media (max-width: 560px) { + .qs-ctas .cta-btn, + .hero-ctas .cta-btn { + width: 100%; + justify-content: center; + } +} + +/* Legacy centered heading collision guard: the new sections use .sec-title, + so main.css's .section-title is untouched here. */ + +/* ========================= REDUCED MOTION ============================== */ +@media (prefers-reduced-motion: reduce) { + .cta-btn, + .copy-btn, + .feat-card, + .eco-card, + .inst-card, + .showcase-card, + .channel-card { + transition: none; + } + + .cta-btn:hover, + .feat-card:hover, + .eco-card:hover, + .inst-card:hover, + .showcase-card:hover, + .channel-card:hover { + transform: none; + } + + /* No auto-scroll: fall back to a static, wrapping grid of feature cards. */ + .feat-marquee { + overflow: visible; + margin: 0; + padding: 0; + } + + .marquee-track { + flex-wrap: wrap; + width: auto; + justify-content: center; + } + + .feat-marquee .feat-card { + flex: 1 1 240px; + max-width: 320px; + } +} diff --git a/css/main.css b/css/main.css index c7e9d91..17ab1cb 100644 --- a/css/main.css +++ b/css/main.css @@ -240,6 +240,9 @@ body::before { .hero-tagline { font-size: clamp(1.95rem, 4.8vw, 3rem); margin-bottom: 10px; + /* Balance the lines so short words (e.g. "limit!") don't wrap alone. + Ignored gracefully by browsers without text-wrap support. */ + text-wrap: balance; } .section-title { @@ -434,3 +437,80 @@ body::before { max-width: 250px; } } + +/* ---------------------- ACCESSIBILITY UTILITIES ------------------------ */ +/* Visually-hidden helper (screen-reader only). */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Keyboard "skip to content" link — hidden until focused. */ +.skip-link { + position: absolute; + left: 12px; + top: -64px; + z-index: 100; + padding: 10px 16px; + border-radius: 8px; + background: var(--accent-strong); + color: #111a3a; + font-family: "Ruda", sans-serif; + font-weight: 700; + text-decoration: none; + transition: top 0.2s ease; +} + +.skip-link:focus { + top: 12px; +} + +/* Visible focus ring for keyboard users across interactive controls. */ +a:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent-strong); + outline-offset: 2px; + border-radius: 4px; +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +/* ------------------------- REDUCED MOTION ------------------------------ */ +/* Honour users who ask for less motion: freeze the starfield, disable the + scroll-reveal (CSS is the source of truth, so content shows even if JS + never runs), and drop hover lifts. */ +@media (prefers-reduced-motion: reduce) { + body::before { + animation: none; + } + + .reveal-section, + .reveal-item { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + + .simulate-button, + .icon-button { + transition: none; + } + + .simulate-button:hover, + .simulate-button:focus-visible, + .icon-button:hover, + .icon-button:focus-visible { + transform: none; + } +} diff --git a/css/nav.css b/css/nav.css new file mode 100644 index 0000000..d1b7273 --- /dev/null +++ b/css/nav.css @@ -0,0 +1,188 @@ +/* ============================ SITE HEADER (shared) ======================= + The sticky top navigation shared by the landing page (index.html) and the + About page (about.html) so both read as one site. Loaded by BOTH pages via + . CSS variables come from main.css :root, which + is always loaded first. */ + +.site-header { + position: sticky; + top: 8px; + z-index: 50; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 12px 10px 18px; + margin-bottom: var(--space-section); + border-radius: 14px; + background: rgba(6, 13, 38, 0.72); + border: 1px solid var(--surface-border); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); +} + +.site-header .brand-link { + display: inline-flex; + flex-shrink: 0; +} + +.site-header .brand-logo { + height: 40px; + width: 184px; +} + +.nav-links { + display: flex; + align-items: center; + gap: 2px; + margin: 0 auto 0 8px; +} + +.nav-link { + font-family: "Ruda", sans-serif; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.82); + text-decoration: none; + padding: 8px 13px; + border-radius: 8px; + transition: + color 0.2s ease, + background 0.2s ease; +} + +.nav-link:hover, +.nav-link:focus-visible { + color: #ffffff; + background: rgba(255, 255, 255, 0.07); +} + +.nav-actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} + +.star-badge { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: "Ruda", sans-serif; + font-size: 13px; + font-weight: 700; + color: #ffffff; + background: var(--icon-bg); + border: 1px solid var(--surface-border); + border-radius: 999px; + padding: 7px 13px; + text-decoration: none; + white-space: nowrap; + transition: + border-color 0.2s ease, + transform 0.2s ease; +} + +.star-badge:hover, +.star-badge:focus-visible { + border-color: rgba(255, 255, 255, 0.34); + transform: translateY(-1px); +} + +.star-badge .fa-star { + color: var(--accent-strong); + font-size: 12px; +} + +.star-badge .fa-github { + font-size: 15px; +} + +.nav-cta { + min-height: 40px; + padding: 8px 18px; + font-size: 13.5px; +} + +.nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 42px; + height: 40px; + border-radius: 10px; + border: 1px solid var(--surface-border); + background: var(--icon-bg); + color: #ffffff; + font-size: 18px; + cursor: pointer; +} + +/* ------------------------------ responsive ------------------------------ */ +@media (max-width: 900px) { + .nav-links { + display: none; + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + flex-direction: column; + align-items: stretch; + gap: 4px; + padding: 12px; + margin: 0; + border-radius: 14px; + background: rgba(6, 13, 38, 0.96); + border: 1px solid var(--surface-border); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + } + + .site-header.nav-open .nav-links { + display: flex; + } + + .nav-link { + padding: 12px 14px; + } + + .nav-toggle { + display: inline-flex; + } + + .site-header { + position: relative; + top: 0; + flex-wrap: wrap; + } +} + +@media (max-width: 560px) { + .star-badge { + display: none; + } + + .site-header .brand-logo { + width: 150px; + height: 34px; + } + + .nav-cta span:not(.simulate-icon) { + display: inline; + } +} + +/* ------------------------------ reduced motion -------------------------- */ +@media (prefers-reduced-motion: reduce) { + .site-header, + .star-badge, + .nav-link { + transition: none; + } + + .star-badge:hover { + transform: none; + } +} diff --git a/images/showcase/altitude-speed.svg b/images/showcase/altitude-speed.svg new file mode 100644 index 0000000..0c653bb --- /dev/null +++ b/images/showcase/altitude-speed.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/showcase/flight-3d.svg b/images/showcase/flight-3d.svg new file mode 100644 index 0000000..94bf295 --- /dev/null +++ b/images/showcase/flight-3d.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/showcase/stability-margin.svg b/images/showcase/stability-margin.svg new file mode 100644 index 0000000..32c3ebf --- /dev/null +++ b/images/showcase/stability-margin.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html index d625f14..30da701 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,11 @@ @@ -52,11 +52,24 @@ /> + + + + + RocketPy - Advanced Rocket Trajectory Simulator +
-
-
- - - -
- + href="https://app.rocketpy.org/" + class="simulate-button nav-cta" + role="button" + > + + Simulate + + +
-
+
+

- Open-source aerospace simulation + Open source · 6-DOF rocket flight simulation

The sky is not the limit!

-

- RocketPy is an innovative rocket trajectory simulator that - includes features never seen before in the state-of-the-art - rocket trajectory simulators. +

+ RocketPy is the next-generation, open-source simulator for + High-Power Rocketry — a full six-degrees-of-freedom flight built + on real weather data, Monte Carlo-ready, and + validated to under 1% apogee error against real + launches. +

+
+ pip install rocketpy + +
+

+ Python ≥ 3.10 · also on conda-forge · runs in Jupyter & + Google Colab +

+ +

+ MIT licensed · + 1,021 + on GitHub · 170k+ total downloads · used on 6 continents

- - - SIMULATE WITH ROCKETPY -
+ +
+

RocketPy by the numbers

+
+
+ 1,021 + GitHub stars +
+
+ 268 + Forks +
+
+ 75+ + Contributors +
+
+ 25k+ + Downloads (6 mo) +
+
+ <1% + Apogee error +
+
+ 2018 + Building since +
+
+

+ Updated July 2026 · + star us on GitHub +

+
+ + +
+
+

Quickstart

+

+ Your first flight, in four classes +

+

+ Describe the sky, the motor, and the rocket — RocketPy handles the + full 6-DOF physics. Here is a complete simulation of the + Calisto rocket, ready to run in a notebook or Google + Colab. +

+
+
+
    +
  1. + 1 +
    +

    Environment

    +

    + Pull real forecast, reanalysis or sounding data for launch + day. +

    +
    +
  2. +
  3. + 2 +
    +

    Motor

    +

    + Pick a variant — + SolidMotor, HybridMotor, + LiquidMotor or GenericMotor — each + built from its thrust curve and geometry. +

    +
    +
  4. +
  5. + 3 +
    +

    Rocket

    +

    + Add nose cone, fins, tail and parachutes with real + aerodynamics. +

    +
    +
  6. +
  7. + 4 +
    +

    Flight

    +

    Launch it and explore trajectory, forces and recovery.

    +
    +
  8. +
+
+
from rocketpy import Environment, SolidMotor, Rocket, Flight
+
+# 1 · Describe the sky — real forecast weather
+env = Environment(latitude=32.99, longitude=-106.97, elevation=1400)
+env.set_atmospheric_model(type="Forecast", file="GFS")
+
+# 2 · Build the motor (SolidMotor here — also Hybrid/Liquid/Generic)
+motor = SolidMotor(
+    thrust_source="Cesaroni_M1670.eng",
+    dry_mass=1.815, grain_number=5, nozzle_radius=0.033,
+)
+
+# 3 · Assemble the rocket
+calisto = Rocket(radius=0.0635, mass=14.426, inertia=(6.32, 6.32, 0.034))
+calisto.add_motor(motor, position=-1.255)
+calisto.add_nose(length=0.558, kind="von karman", position=1.278)
+calisto.add_trapezoidal_fins(n=4, root_chord=0.12, tip_chord=0.06, span=0.11)
+calisto.add_parachute("Main", cd_s=10.0, trigger=800)
+
+# 4 · Fly it — full 6-DOF simulation
+flight = Flight(rocket=calisto, environment=env, rail_length=5.2)
+flight.all_info()
+
+
+ +
+ + +
+
+

See what it produces

+

+ Publication-grade results +

+

+ One simulation yields the full picture — 3D trajectory, + kinematics, stability, and the statistical landing footprint that + matters for a safe recovery. +

+
+
+
+
+ A three-dimensional flight trajectory arcing over a ground grid +
+
+

3D trajectory

+

+ Full flight path in space — exportable to Google Earth via + KML. +

+
+
+
+
+ Monte Carlo impact dispersion with a confidence ellipse around the target +
+
+

Monte Carlo dispersion

+

+ Thousands of runs give the landing ellipse and its confidence + interval. +

+
+
+
+
+ Altitude and velocity plotted against time, peaking at apogee +
+
+

Altitude & velocity

+

Every flight variable resolved continuously versus time.

+
+
+
+
+ Static stability margin over time staying inside the stable band +
+
+

Stability margin

+

+ Track the static margin through the burn to stay in control. +

+
+
+
+

+ Explore worked examples in the docs → +

+
+ +
-

- Let's be realistic -

-
-
-

- RocketPy's environment construction uses weather data from - several meteorological agencies, providing precise information - about atmospheric conditions and taking into account different - kinds of uncertainties associated with the mathematical models - of the phenomena. - - Read our paper on the Journal of Aerospace Engineering - - to know more about the mathematical models and the software - architecture. +

+

Capabilities

+

+ Everything a serious flight needs +

+

+ High-fidelity physics under the hood, a clean Python API on top — + from propulsion to recovery, weather to control. +

+
+
+
+
+ +

Full 6-DOF flight

+

+ Nonlinear six-degrees-of-freedom dynamics with rigorous + variable-mass effects. +

+
+
+ +

Monte Carlo & sensitivity

+

+ Dispersion analysis and global sensitivity for landing-ellipse + prediction. +

+
+
+ +

Solid, hybrid & liquid motors

+

+ Model any propulsion type straight from a thrust curve or + .eng file. +

+
+
+ +

Aerodynamic surfaces

+

+ Nose cones, fins and tails via Barrowman equations, plus + custom drag curves. +

+
+
+ +

Parachutes & recovery

+

+ Multiple chutes with custom trigger functions and sensor-noise + simulation. +

+
+
+ +

Real weather & atmosphere

+

+ ISA 1976, Wyoming soundings and live NOAA / ECMWF forecast and + ensemble data. +

+
+
+ +

Multi-stage rockets

+

+ Simulate staged vehicles with independent motors, events and + recovery. +

+
+
+ +

Custom control laws

+

+ Inject continuous or discrete control for fins, air-brakes and + active systems. +

+
+
+ +

Stability analysis

+

+ Static margin, damping and out-of-rail conditions resolved + across the flight. +

+
+
+ +

Sensor simulation

+

+ Emulate accelerometers, gyroscopes and barometers with + realistic noise. +

+
+
+ +

MATLAB & Python interop

+

+ Script it in Python or drive it from MATLAB — and export + results anywhere. +

+
+
+ +

KML / Google Earth

+

+ Export trajectories to KML and fly them over real terrain in + Google Earth. +

+
+
+
+
+ + +
+
+
+

Proven in the field

+

<1%

+

+ Apogee error versus real flights +

+

+ Because RocketPy flies on the actual sky — real forecast, + reanalysis and sounding data — its predictions match reality. It + has been validated against + 17 documented real flights from student and + professional teams across four continents.

Atmospheric layers and weather sounding data above a planet horizon />
+

+ Flown & validated by teams worldwide +

+
    +
  • + Projeto Jupiter — USP + São Paulo, Brazil +
  • +
  • + EPFL Rocket Team + Lausanne, Switzerland +
  • +
  • + University of Notre Dame + Indiana, USA +
  • +
  • + Space Enterprise at Berkeley + California, USA +
  • +
  • + U. of Toronto Aerospace + Toronto, Canada +
  • +
  • + TU Wien Space Team + Vienna, Austria +
  • +
  • + Faraday Rocketry UPV + Valencia, Spain +
  • +
  • + Politecnico di Torino + Turin, Italy +
  • +
  • + Aerospace Team Graz + Graz, Austria +
  • +
  • + STAR — UC3M + Madrid, Spain +
  • +
  • + Aristotle Space & Aeronautics + Thessaloniki, Greece +
  • +
  • + BME Suborbitals + Budapest, Hungary +
  • +
+

+ RocketPy is the + official flight simulator of the European Rocketry Challenge + (EuRoC) + since 2022 and of the + Latin American Space Challenge (LASC) since 2025 — + and the RocketPy team actively supports and partners with both. It + is trusted by 100+ university teams worldwide, including at the + Spaceport America Cup. + See all validation examples → +

+
-

- Simulation is all about predictions -

-
+
-

- From rocketeers to rocketeers -

+
+

From rocketeers to rocketeers

+

+ Built by a global community +

+
-
-

- RocketPy is an Open Source software created by rocket - enthusiasts. Since it is open source, each and every one of you - is more than welcome to be a part of it. Check out our project - on +

+

+ RocketPy is free and MIT-licensed. It was born + inside Projeto Jupiter, the student rocketry + team at the University of São Paulo, Brazil — and grew into the + world's leading open-source trajectory simulator, maintained by + contributors on six continents. +

+

+ Everyone is welcome. Ask a question, report an idea, or ship a + pull request. + Meet the people behind RocketPy → +

+
GitHub. -

+ class="cta-btn cta-primary" + > + + Contribute on GitHub + + + + Join our Discord + +
A connected network of RocketPy contributors around a central hub />
+
-
- -
- + +
+
+

For researchers

+

Using RocketPy? Cite it

+

+ RocketPy is peer-reviewed. If it supports your research, please + cite the papers below. +

+
+
+
+

+ RocketPy: Six Degree-of-Freedom Rocket Trajectory Simulator +

+

+ Ceotto, G. H.; Schmitt, R. N.; Alves, G. F.; Pezente, L. A.; + Carmo, B. S. (2021). Journal of Aerospace Engineering, + 34(6). ASCE. + doi:10.1061/(ASCE)AS.1943-5525.0001331 +

+
+
+

RocketPy: Combining Open-Source and Scientific Libraries…

+

+ Soares et al. (2022). + Proceedings of the Python in Science Conference (SciPy). + doi:10.25080/majora-212e5952-020 +

+
+
+
+
@article{RocketPy2021,
+  author  = {Ceotto, Giovani H. and Schmitt, Rodrigo N. and
+             Alves, Guilherme F. and Pezente, Lucas A. and Carmo, Bruno S.},
+  title   = {{RocketPy}: Six Degree-of-Freedom Rocket Trajectory Simulator},
+  journal = {Journal of Aerospace Engineering},
+  volume  = {34},
+  number  = {6},
+  year    = {2021},
+  doi     = {10.1061/(ASCE)AS.1943-5525.0001331}
+}
+
+
-
+ + + diff --git a/package.json b/package.json index 6d63d09..a8787f8 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,16 @@ "version": "1.0.0", "description": "RocketPy landing page - Advanced rocket trajectory simulator", "scripts": { - "format": "prettier --write '**/*.{html,css,js}'", + "format": "prettier --write '**/*.{html,css,js,mjs}'", "build": "npm run build:css && npm run build:html", - "build:css": "npm run minify:css:main && npm run minify:css:footer && npm run minify:css:about", + "build:css": "npm run minify:css:main && npm run minify:css:nav && npm run minify:css:footer && npm run minify:css:about && npm run minify:css:home", "build:html": "npm run minify:html:index && npm run minify:html:about", + "stats": "node scripts/fetch-stats.mjs", "minify:css:main": "lightningcss --minify --bundle css/main.css -o dist/css/main.css", + "minify:css:nav": "lightningcss --minify --bundle css/nav.css -o dist/css/nav.css", "minify:css:footer": "lightningcss --minify --bundle css/footer.css -o dist/css/footer.css", "minify:css:about": "lightningcss --minify --bundle css/about.css -o dist/css/about.css", + "minify:css:home": "lightningcss --minify --bundle css/home.css -o dist/css/home.css", "minify:html:index": "html-minifier --collapse-whitespace --remove-comments index.html -o dist/index.html", "minify:html:about": "html-minifier --collapse-whitespace --remove-comments about.html -o dist/about.html", "prebuild": "node -e \"const fs = require('fs'); fs.rmSync('dist', { recursive: true, force: true }); ['dist', 'dist/css', 'dist/images', 'dist/about-images'].forEach(dir => fs.mkdirSync(dir, { recursive: true }));\"", diff --git a/scripts/fetch-stats.mjs b/scripts/fetch-stats.mjs new file mode 100644 index 0000000..048136e --- /dev/null +++ b/scripts/fetch-stats.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Build-time stats injector. + * + * Fetches live project metrics (GitHub stars/forks/contributors, recent PyPI + * downloads) plus a build date, and rewrites the matching `data-stat="..."` + * anchors inside the ALREADY-BUILT pages (`dist/index.html`, `dist/about.html`). + * It never touches the source HTML, and it is best-effort: any network/parse + * failure falls back to the committed defaults so a deploy can never be broken + * by a flaky API. + * + * Runs after `npm run build`, on the CI runner (see .github/workflows/static.yml), + * authenticated with GITHUB_TOKEN to avoid the 60 req/hr unauthenticated limit. + * Node 20+ (global fetch, no dependencies). + */ +import { readFile, writeFile } from "node:fs/promises"; + +const REPO = "RocketPy-Team/RocketPy"; +// Both built pages carry data-stat anchors (the landing's stats band + the +// star badge shared by the header on both pages), so inject into each. +const HTML_FILES = ["dist/index.html", "dist/about.html"]; + +/* Hard cap per request. `continue-on-error` in CI handles a *failed* fetch, but + not a *hung* one — without this a slow/stalled API would keep the deploy step + waiting up to the job's default timeout. AbortSignal.timeout rejects the fetch + so the surrounding try/catch falls back to committed defaults. */ +const FETCH_TIMEOUT_MS = 8000; + +/* Committed fallbacks — kept in sync with the defaults hard-coded in index.html + so the page still reads sensibly if every request below fails. `downloads` + is a rolling ~180-day figure (see fetchDownloads), not the all-time total. */ +const stats = { stars: 1021, forks: 268, contributors: 75, downloads: 25000 }; + +const gh = (path) => + fetch(`https://api.github.com/${path}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "rocketpy-site-build", + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + }, + }); + +/** Compact human formatting: 1021 -> "1,021", 173210 -> "173k+". */ +const comma = (n) => n.toLocaleString("en-US"); +const kplus = (n) => + n >= 1000 ? Math.floor(n / 1000).toLocaleString("en-US") + "k+" : String(n); + +/** Total contributors via the Link header's last-page number (per_page=1). */ +async function fetchContributors() { + const res = await gh(`repos/${REPO}/contributors?per_page=1&anon=true`); + if (!res.ok) throw new Error(`contributors HTTP ${res.status}`); + const link = res.headers.get("link") || ""; + const m = link.match(/[?&]page=(\d+)>;\s*rel="last"/); + if (m) return Number(m[1]); + // No pagination -> everyone fit on one page; count the array. + const arr = await res.json(); + return Array.isArray(arr) ? arr.length : null; +} + +/** Rolling PyPI downloads over the window pypistats exposes (~180 days) = + sum of the non-mirror daily series. Not the all-time total (pypistats + only keeps ~180 days of history). */ +async function fetchDownloads() { + const res = await fetch( + "https://pypistats.org/api/packages/rocketpy/overall?mirrors=false", + { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { "User-Agent": "rocketpy-site-build" }, + }, + ); + if (!res.ok) throw new Error(`pypistats HTTP ${res.status}`); + const json = await res.json(); + const rows = json?.data; + if (!Array.isArray(rows) || !rows.length) throw new Error("pypistats empty"); + return rows.reduce((sum, r) => sum + (r.downloads || 0), 0); +} + +/* --- gather (each source isolated; one failure keeps the others + fallback) --- */ +try { + const res = await gh(`repos/${REPO}`); + if (!res.ok) throw new Error(`repo HTTP ${res.status}`); + const repo = await res.json(); + if (repo.stargazers_count) stats.stars = repo.stargazers_count; + if (repo.forks_count) stats.forks = repo.forks_count; +} catch (e) { + console.warn("stats: github repo ->", e.message); +} + +try { + const c = await fetchContributors(); + if (c) stats.contributors = c; +} catch (e) { + console.warn("stats: contributors ->", e.message); +} + +try { + const d = await fetchDownloads(); + if (d) stats.downloads = d; +} catch (e) { + console.warn("stats: pypi downloads ->", e.message); +} + +/* --- compute display values, then inject into the built pages --- */ +// Build date shown as the stats "last updated" label, e.g. "July 2026". The +// site redeploys monthly (see static.yml) so this stays current. +const updated = new Date().toLocaleString("en-US", { + month: "long", + year: "numeric", +}); +const values = { + stars: comma(stats.stars), + forks: comma(stats.forks), + contributors: stats.contributors + "+", // shown as a raw "N+", e.g. "75+" + downloads: kplus(stats.downloads), + updated, +}; + +for (const file of HTML_FILES) { + let html; + try { + html = await readFile(file, "utf8"); + } catch (e) { + console.warn(`stats: cannot read ${file} (${e.message}); skipping`); + continue; + } + + let hits = 0; + for (const [key, value] of Object.entries(values)) { + const re = new RegExp( + `(<[^>]*\\bdata-stat="${key}"[^>]*>)([^<]*)( { + hits += 1; + return open + value + close; + }); + } + + if (hits === 0) { + console.warn(`stats: WARNING — no data-stat anchors matched in ${file}`); + } else { + await writeFile(file, html); + } + console.log(`stats: injected ${hits} value(s) into ${file}`); +} + +console.log("stats: values", values); diff --git a/sitemap.xml b/sitemap.xml index c4921aa..21c27f4 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,13 +2,13 @@ https://rocketpy.org/ - 2025-12-04 + 2026-07-19 monthly 1.0 https://rocketpy.org/about.html - 2025-12-04 + 2026-07-19 monthly 0.8