diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1a5322f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + branches: [main, prod] + push: + branches: [main] + +jobs: + build: + name: Build & type-check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Runs `astro check && astro build` (see package.json "build" script). + - name: Build + run: npm run build diff --git a/astro.config.mjs b/astro.config.mjs index 9389d7bb..15d2f159 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -79,8 +79,14 @@ export default defineConfig({ logo: { src: "./src/assets/pythonlogo.png", }, + // Show "Last updated" on content pages, derived from git history. + lastUpdated: true, components: { Footer: "./src/components/Footer.astro", + // Injects per-page JSON-LD structured data. + Head: "./src/components/Head.astro", + // Adds sidebar search + collapse/expand-all with persisted state. + Sidebar: "./src/components/Sidebar.astro", }, favicon: "./src/assets/favicon.ico", social: { @@ -181,13 +187,17 @@ export default defineConfig({ src: "/scripts/main.js", }, }, - { - tag: "meta", - attrs: { - name: "google-adsense-account", - content: import.meta.env.VITE_GOOGLE_ADSENSE, - }, - }, + ...(import.meta.env.VITE_GOOGLE_ADSENSE + ? [ + { + tag: "meta", + attrs: { + name: "google-adsense-account", + content: import.meta.env.VITE_GOOGLE_ADSENSE, + }, + }, + ] + : []), { tag: "meta", attrs: { @@ -263,13 +273,17 @@ export default defineConfig({ "Python, Python Projects, Python Central Hub, Python Central Hub Projects, Python Central Hub Tutorials, Python Central Hub Guides, Python Central Hub Reference, Python Tutorial, Python tutorials, Python programming, Learn Python, Python for beginners, Python code examples, Python development, Python projects,Python programming language, Python tips and tricks,Python resources,Python learning platform,Python coding lessons,Python programming for beginners,Python programming exercises,Python coding practice,Python syntax,Python libraries,Python community,Python best practices,Python coding challenges", }, }, - { - tag: "meta", - attrs: { - name: "monetag", - content: `${import.meta.env.VITE_MONETAG}` - } - }, + ...(import.meta.env.VITE_MONETAG + ? [ + { + tag: "meta", + attrs: { + name: "monetag", + content: `${import.meta.env.VITE_MONETAG}`, + }, + }, + ] + : []), { tag: "meta", attrs: { @@ -299,18 +313,12 @@ export default defineConfig({ }, }, { - tag: "meta", - attrs: { - name: "theme-color", - content: "#e7c384", - }, - }, - { - tag: "script", + tag: "link", attrs: { - async: true, - src: `https://www.googletagmanager.com/gtag/js?id=${import.meta.env.VITE_GOOGLE_ANALYTICS - }`, + rel: "alternate", + type: "application/rss+xml", + title: "Python Central Hub RSS Feed", + href: "/rss.xml", }, }, // @@ -324,12 +332,30 @@ export default defineConfig({ }, }, { - tag: "script", + tag: "meta", attrs: { - async: true, - src: "/scripts/gtag.js", + name: "theme-color", + content: "#e7c384", }, }, + ...(import.meta.env.VITE_GOOGLE_ANALYTICS + ? [ + { + tag: "script", + attrs: { + async: true, + src: `https://www.googletagmanager.com/gtag/js?id=${import.meta.env.VITE_GOOGLE_ANALYTICS}`, + }, + }, + { + tag: "script", + attrs: { + async: true, + src: "/scripts/gtag.js", + }, + }, + ] + : []), { tag: "script", attrs: { @@ -338,16 +364,19 @@ export default defineConfig({ src: "/scripts/mermaid.js", }, }, - { - tag: "script", - attrs: { - async: true, - src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${import.meta.env.VITE_GOOGLE_ADSENSE - }`, - crossorigin: "anonymous", - }, - }, - + ...(import.meta.env.VITE_GOOGLE_ADSENSE + ? [ + { + tag: "script", + attrs: { + async: true, + src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${import.meta.env.VITE_GOOGLE_ADSENSE}`, + crossorigin: "anonymous", + }, + }, + ] + : []), + { tag: "link", attrs: { @@ -381,7 +410,27 @@ export default defineConfig({ tailwind({ applyBaseStyles: false, }), - sitemap(), + sitemap({ + // Give index/guides/tutorials higher priority than deep pages and + // stamp a build-time lastmod so crawlers see fresh dates. + serialize(item) { + const url = item.url; + if (url === `${site}/`) { + item.priority = 1.0; + } else if (url.includes("/guides/")) { + item.priority = 0.9; + } else if (url.includes("/tutorials/")) { + item.priority = 0.8; + } else if (url.includes("/projects/")) { + item.priority = 0.7; + } else { + item.priority = 0.6; + } + item.changefreq = "weekly"; + item.lastmod = new Date().toISOString(); + return item; + }, + }), robotsTxt(), markdownIntegration(), ], diff --git a/firebase/firestore.rules.example b/firebase/firestore.rules.example new file mode 100644 index 00000000..f777afd9 --- /dev/null +++ b/firebase/firestore.rules.example @@ -0,0 +1,20 @@ +rules_version = '2'; + +// EXAMPLE Firestore rules. The public web SDK config in Contact.astro / +// Feedback.astro is client-visible, so without rules anyone can write freely. +// +// Recommended: deny direct client writes and route submissions through the +// `submitForm` Cloud Function (see functions/index.example.js), which validates +// the reCAPTCHA token server-side. The function uses the Admin SDK and bypasses +// these rules. +service cloud.firestore { + match /databases/{database}/documents { + // Block all direct client reads/writes to form collections. + match /contact/{doc} { + allow read, write: if false; + } + match /feedback/{doc} { + allow read, write: if false; + } + } +} diff --git a/firebase/functions/index.example.js b/firebase/functions/index.example.js new file mode 100644 index 00000000..dbf2731f --- /dev/null +++ b/firebase/functions/index.example.js @@ -0,0 +1,56 @@ +/** + * Reference Cloud Function for validating reCAPTCHA v3 tokens before allowing + * a write to Firestore. This is an EXAMPLE — deploy it under your own Firebase + * Functions project and wire the client forms to POST here instead of writing + * to Firestore directly, OR call it from a Firestore `onCreate` trigger. + * + * Setup: + * 1. firebase init functions + * 2. Copy this file to functions/index.js (adjust as needed) + * 3. Set the secret: firebase functions:config:set recaptcha.secret="YOUR_SECRET" + * 4. firebase deploy --only functions + * + * The client forms (Contact.astro / Feedback.astro) attach a `recaptchaToken` + * field. Validate it here; reject low scores / failed verifications. + */ + +const functions = require("firebase-functions"); +const admin = require("firebase-admin"); + +admin.initializeApp(); + +const MIN_SCORE = 0.5; + +async function verifyToken(token) { + const secret = functions.config().recaptcha.secret; + const res = await fetch("https://www.google.com/recaptcha/api/siteverify", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(token)}`, + }); + const data = await res.json(); + return data.success === true && (data.score ?? 0) >= MIN_SCORE; +} + +// HTTPS callable: client posts { collection, payload, recaptchaToken }. +exports.submitForm = functions.https.onCall(async (data) => { + const { collection, payload, recaptchaToken } = data || {}; + + if (!["contact", "feedback"].includes(collection)) { + throw new functions.https.HttpsError("invalid-argument", "Bad collection."); + } + if (!recaptchaToken || !(await verifyToken(recaptchaToken))) { + throw new functions.https.HttpsError( + "permission-denied", + "reCAPTCHA verification failed." + ); + } + + const { recaptchaToken: _omit, ...clean } = payload || {}; + await admin + .firestore() + .collection(collection) + .add({ ...clean, createdAt: admin.firestore.FieldValue.serverTimestamp() }); + + return { ok: true }; +}); diff --git a/improvements.md b/improvements.md new file mode 100644 index 00000000..f4cecbfd --- /dev/null +++ b/improvements.md @@ -0,0 +1,367 @@ +# PythonCentralHub — Improvements, Bugs & Roadmap + +> Comprehensive audit of the PythonCentralHub site (Astro Starlight, https://pythoncentralhub.live). +> Findings grouped by category. Each item lists **file:line where applicable**, **what's wrong**, and **suggested fix**. + +--- + +## 1. Bugs & Errors + +### 1.1 Print button destroys DOM and forces reload +- **File:** `src/components/Footer.astro:142-146` +- **Issue:** `document.body.innerHTML = printContent.innerHTML; window.print(); document.body.innerHTML = mainContent;` then calls `window.location.reload()`. This wipes event listeners, loses form state, and the reload is harsh. +- **Fix:** Use a CSS `@media print` stylesheet that hides non-content elements, then call `window.print()` natively. No DOM mutation, no reload. + +### 1.2 Share button has no fallback +- **File:** `src/components/Footer.astro:111-129` +- **Issue:** `navigator.share` is not supported in desktop Firefox/older browsers. Current path shows "Not supported!" with no alternative. +- **Fix:** Fall back to `navigator.clipboard.writeText(location.href)` and show a toast "Link copied". + +### 1.3 Environment variables interpolated without fallback +- **File:** `astro.config.mjs:140, 222, 264-265, 287-288` +- **Issue:** `import.meta.env.VITE_GOOGLE_ANALYTICS`, `VITE_GOOGLE_ADSENSE`, `VITE_MONETAG` are dropped into script `src` and meta `content` with no guard. If env var is missing, the URL becomes `...?id=undefined` and ad/analytics requests 404 silently. +- **Fix:** Conditionally include those tags only when the env var is truthy. Example: + ```js + ...(import.meta.env.VITE_GOOGLE_ANALYTICS ? [{ tag: "script", attrs: {...} }] : []) + ``` + +### 1.4 DataCamp iframe message listener missing null guards +- **File:** `src/components/DataCampExercise.astro:212-222, 252-254` +- **Issue:** `event.data.type` and `event.data.id` accessed without checking `event.data` is an object. Fullscreen handler queries `originalFrame` then accesses `.sandbox` / `.srcdoc` without a null check. +- **Fix:** Guard with `if (!event.data || typeof event.data !== 'object') return;` and `if (!originalFrame) return;`. + +### 1.5 Mermaid plugin swallows errors +- **File:** `lib/mermaid/remake.ts:58-99` +- **Issue:** `renderDiagram()` calls inside `Promise.all` are not wrapped in try/catch. A single bad diagram silently fails — no fallback SVG, no console warning to the user. +- **Fix:** Wrap each render in try/catch; on failure emit an inline `
` with the original mermaid source plus an error comment.
+
+### 1.6 Weak ID generation in forms
+- **File:** `src/components/Contact.astro:97-99`, `src/components/Feedback.astro:177-179`
+- **Issue:** `Date.now().toString(36) + Math.random().toString(36).substr(2)` — `substr` is deprecated; not collision-safe under high concurrency.
+- **Fix:** Use `crypto.randomUUID()` (supported in all modern browsers).
+
+### 1.7 Toast helper uses `innerHTML`
+- **File:** `public/scripts/toast.js`
+- **Issue:** Title/description passed into `innerHTML`. If toast ever surfaces user-controlled text (form errors, comment input), this is an XSS vector.
+- **Fix:** Use `textContent` for strings; allow markup only via a separate `html: true` opt-in.
+
+### 1.8 Self-referential package dependency
+- **File:** `package.json:21`
+- **Issue:** `"pythoncentralhub": "file:"` references the package itself. Confuses `npm install` on fresh clones.
+- **Fix:** Remove the line unless this is intentional for a monorepo layout (not currently the case).
+
+### 1.9 `astro check` not in build chain everywhere
+- **File:** `package.json:8`
+- **Issue:** Build runs `astro check && astro build` locally, but no CI enforces it.
+- **Fix:** Add a GitHub Action that runs `npm run build` on PR.
+
+### 1.10 Service worker is empty
+- **File:** `public/sw.js`
+- **Issue:** PWA manifest links a service worker but the file is empty — no offline support, no cache strategy.
+- **Fix:** Either implement a real `workbox-window` strategy (cache-first for fonts/assets, stale-while-revalidate for HTML) or remove the SW + manifest reference.
+
+---
+
+## 2. Security
+
+### 2.1 Firebase config hardcoded client-side
+- **File:** `src/components/Contact.astro:82-88`, `src/components/Feedback.astro:161-167`
+- **Issue:** Firebase config is public by design, but exposing apiKey + projectId in source means the security boundary is **entirely** in Firestore Security Rules. If rules are loose, anyone can read/write.
+- **Fix:**
+  1. Audit Firestore Security Rules — restrict writes to authenticated users or to a specific collection with rate-limited fields.
+  2. Add App Check (reCAPTCHA v3 or Play Integrity) to block non-browser traffic.
+  3. Move shared init into `src/lib/firebase.ts` so config lives in one place.
+
+### 2.2 No CSP / no SRI on third-party scripts
+- **File:** `astro.config.mjs:124-128, 262-320`
+- **Issue:** FontAwesome CSS, GTM, AdSense, DataCamp, Mermaid all loaded from external CDNs without `integrity=` hashes or a Content Security Policy.
+- **Fix:**
+  1. Add CSP via deploy headers (Vercel/Netlify `_headers` file or middleware): `script-src 'self' https://www.googletagmanager.com https://pagead2.googlesyndication.com https://cdn.datacamp.com https://cdn.jsdelivr.net;` etc.
+  2. Generate SRI hashes for static third-party assets and add `integrity` + `crossorigin="anonymous"`.
+
+### 2.3 Forms post directly to Firestore — no rate limit, no captcha
+- **File:** `src/components/Contact.astro`, `src/components/Feedback.astro`
+- **Issue:** Spam bots can write to Firestore at will.
+- **Fix:** Add reCAPTCHA v3 to both forms, validate token on a Cloud Function before write. Or front the writes with a server route.
+
+### 2.4 `
` has no `action` and relies entirely on JS +- **File:** `src/components/Contact.astro:14`, `src/components/Feedback.astro:9` +- **Issue:** If JS fails to load, form silently does nothing. +- **Fix:** Either add a server endpoint that handles the POST (graceful degradation) or `e.preventDefault()` + clearly indicate JS requirement. + +### 2.5 External link `target="_blank"` without `rel` +- **File:** `src/components/YoutuberCard.astro:99-101` +- **Issue:** Reverse-tabnabbing risk. +- **Fix:** Add `rel="noopener noreferrer"`. + +--- + +## 3. Performance + +### 3.1 Astro 3.2.3 is two majors behind +- **File:** `package.json:19` +- **Issue:** Astro 5 ships View Transitions, Content Layer API, faster builds, smaller runtime. Starlight 0.12 is also old (current 0.30+). +- **Fix:** Plan a migration: bump Astro → 4 → 5 incrementally, then Starlight. Many breaking changes — schedule a dedicated branch. + +### 3.2 Five render-blocking custom font CSS files +- **File:** `astro.config.mjs:101-107` +- **Issue:** Poppins, Atkinson, Source, Fira, plus global — all blocking. No `font-display: swap`, no preload. +- **Fix:** + 1. Add `font-display: swap;` to every `@font-face` in those CSS files. + 2. Preload only the single primary weight: ``. + 3. Consider self-hosting via Fontsource or dropping a font family. + +### 3.3 FontAwesome `all.min.css` loaded globally +- **File:** `astro.config.mjs:124-128` +- **Issue:** Full FontAwesome (~100KB CSS + huge font files) loaded on every page; site only uses a handful of icons. +- **Fix:** Replace with inline SVGs from a single icon set (Lucide / Heroicons), or import only the icons used. + +### 3.4 DataCamp JS + CSS loaded on every page +- **File:** `astro.config.mjs:312-320` +- **Issue:** `dcl-react.js.gz` and `dcl-react.css` are pulled into the global `` even on pages without exercises. +- **Fix:** Move those tags into the `DataCampExercise.astro` component itself (Astro automatically hoists them) or dynamically inject the script when the component mounts via `IntersectionObserver`. + +### 3.5 Mermaid loaded everywhere with `startOnLoad: true` +- **File:** `public/scripts/mermaid.js` +- **Issue:** Mermaid library is downloaded and initialized on pages with zero diagrams. +- **Fix:** In a `` or layout, check if the page contains `.mermaid` blocks (build-time flag in frontmatter, or set a global) and only inject the script then. + +### 3.6 Pyodide is heavy and downloaded on first click +- **File:** `public/scripts/python-playground.js` +- **Issue:** Pyodide is ~10MB compressed. First-click latency is several seconds with no progress feedback. +- **Fix:** + 1. Use `requestIdleCallback` to start the Pyodide download in the background after page load. + 2. Show a progress bar in the playground modal while loading. + 3. Cache via service worker (see 1.10). + +### 3.7 Images lack `loading="lazy"` and responsive sizing +- **File:** `src/components/YoutuberCard.astro:84-93`, content MDX images +- **Issue:** Eagerly loaded; no `srcset`. +- **Fix:** Add `loading="lazy"` and `decoding="async"`. For author images, use Astro's `` component to auto-generate responsive variants. + +### 3.8 Google Adsense + GTM not deferred behind consent +- **File:** `astro.config.mjs:263-290` +- **Issue:** Marked `async`, but they still contend with critical resource fetching. +- **Fix:** Inject after `DOMContentLoaded` or use Partytown to push them to a web worker. + +--- + +## 4. UI / UX + +### 4.1 Form feedback is `alert()` +- **File:** `src/components/Contact.astro:122-124`, `src/components/Feedback.astro` +- **Issue:** Browser `alert()` is jarring, blocks the page, looks unprofessional. +- **Fix:** Use the existing `window.toast.show(...)` helper for success/error. Add a loading spinner on the submit button while the request is in flight. + +### 4.2 No visible focus styles on icon buttons +- **File:** `src/components/Footer.astro:106-152` +- **Issue:** Share/print buttons only have `:hover`. Keyboard users can't tell which is focused. +- **Fix:** Add `:focus-visible { outline: 2px solid var(--sl-color-accent-high); outline-offset: 2px; }`. + +### 4.3 Required field indicators missing +- **File:** `src/components/Feedback.astro:45-82`, `src/components/Contact.astro` +- **Issue:** `required` attribute set, but no visual marker (no asterisk) and no `aria-required`. +- **Fix:** Add `*` next to required field labels and `aria-required="true"`. + +### 4.4 Placeholder text is generic Flowbite boilerplate +- **File:** `src/components/Contact.astro:26` +- **Issue:** `name@flowbite.com` and similar placeholders look unfinished. +- **Fix:** Replace with project-relevant examples: `you@example.com`, `Your full name`, etc. + +### 4.5 Radio buttons in Feedback hidden with no focus state +- **File:** `src/components/Feedback.astro:11-43` +- **Issue:** Radios are `display: none`; emoji labels are clickable but keyboard users can't see focus. +- **Fix:** Use `position: absolute; opacity: 0;` instead of `display: none` so they remain focusable, then style `input:focus-visible + label` with a ring. + +### 4.6 404 page is a dead end +- **File:** `src/content/docs/404.mdx` +- **Issue:** No search, no popular-pages list, no navigation suggestions. +- **Fix:** Add: a search bar (Starlight `` if extractable), links to Guides / Projects / Tutorials index, an "edit on GitHub" link in case the page should exist. + +### 4.7 No "Copied!" feedback on code blocks +- **File:** Site-wide +- **Issue:** Starlight's copy button exists but the success state may be subtle. +- **Fix:** Verify checkmark/toast appears for ≥1.5s; otherwise add custom feedback. + +--- + +## 5. Accessibility + +### 5.1 Alt text contains image URLs +- **File:** `src/components/YoutuberCard.astro:85-92` +- **Issue:** `alt="${coverImg}-${name}"` — screen readers literally read the URL. +- **Fix:** `alt="${name} channel cover image"` and `alt="${name} profile picture"`. + +### 5.2 No skip-to-content link +- **File:** Site layout +- **Issue:** Keyboard users tab through nav on every page load. +- **Fix:** Add `Skip to content` at the top of the layout. + +### 5.3 Hardcoded text colors break dark mode +- **File:** `src/components/YoutuberCard.astro:56, 63-73` +- **Issue:** `color: #222` on a card that sits on the page background — in dark mode the card is light and the contrast inverts unpredictably. +- **Fix:** Use Starlight CSS variables: `var(--sl-color-text)`, `var(--sl-color-bg-nav)`. + +### 5.4 Form errors lack `aria-live` regions +- **File:** `src/components/Contact.astro`, `src/components/Feedback.astro` +- **Issue:** Validation messages won't be announced to screen readers. +- **Fix:** Add `