From 5a043de209c4c7e444a5dbbf44518ddb965c3235 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 01:36:48 +0000 Subject: [PATCH 01/11] Record the website elevation strategy Capture where the project stands after rubric saturation and the discoverability round, define the success scorecard, and lay out four thrusts (close the demand loop, cover searched-for topics, deepen the on-page loop, earn distribution) with essence guardrails and sequencing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJDjKbt3EyWPEThak9WQXp --- docs/elevation-strategy.md | 244 +++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/elevation-strategy.md diff --git a/docs/elevation-strategy.md b/docs/elevation-strategy.md new file mode 100644 index 0000000..5aa5833 --- /dev/null +++ b/docs/elevation-strategy.md @@ -0,0 +1,244 @@ +# Website elevation strategy + +Drafted 2026-07-10. Status: proposal. + +This document answers one question: how do we make Python By Example +more successful without disrupting its essence? It builds on +`docs/rubric-saturation.md` (internal quality has plateaued at a high +level), `docs/learner-analytics.md` (the external signal we can already +measure), and `docs/research-similar-sites.md` (the competitive +landscape). + +## The essence (what must not change) + +Everything below is the product. Elevation work that erodes any of it +is a regression, whatever it does for traffic. + +- **One concept per page.** Compact literate walkthroughs: prose beside + the source it explains, output evidence beside the source that + produced it. +- **Everything runs.** The complete program is editable and executes in + an isolated Dynamic Worker. Expected output is verified, not claimed. +- **Anchored to authority.** Every page links the official Python docs + for the current Cloudflare-supported Python version. +- **Low chrome, article-like.** Not a notebook, not a course, not a + reference dump. Reading order and quiet layout are features. +- **Curated craft.** The locked figure grammar, quality rubrics, + registries, and contract tests are the moat. Nothing ships below the + gates. +- **Respectful by default.** No accounts, no ads, privacy-safe wide + events only. + +## Where the project stands + +The last two improvement rounds made the inside excellent: 109 +examples all above the quality gate, 100% figure coverage with a mean +rubric score ≈ 8.7 and nothing below 8.0, journeys with outcome +contracts, zero orphaned examples, and a green steady state that +`docs/lessons-learned.md` explicitly warns against inflating further. +SEO metadata, structured data, sitemap, social cards, dark mode, search, +and learner analytics all landed in the previous elevation pass. + +The constraint has therefore moved. Page quality is no longer what +limits success; **reach, engagement depth, and coverage of what +learners actually look for** are. `docs/lessons-learned.md` already +points the way: steer content work by external signal rather than +internal quality scores. + +## What "more successful" means + +A shared scorecard, so "success" is not vibes. Everything below is +measurable with the existing wide events plus `scripts/learner_report.py` +and a search-console account. + +1. **Reach** — unique learners per week on example pages; search + impressions and clicks. +2. **Engagement** — POST runs per week; edited-run share (the ideal + documented in `docs/learner-analytics.md`: high edited share, low + error share); journey traffic. +3. **Return** — repeat visits per week (approximate is fine; no + accounts). +4. **Authority** — inbound links, listings in "learn Python" + collections, and citations by AI assistants. +5. **Demand conversion** — recurring `/examples/` 404s turned into + shipped pages. + +## Thrust 1 — Close the demand loop + +The instrumentation exists; the ritual does not. The wide events +already record which pages are read, which examples are run and +edited, where edited code errors, and which nonexistent example URLs +are requested. Make consuming that signal a habit: + +- Schedule a Workers Logs export (Logpush or a periodic dashboard + export) and run `scripts/learner_report.py` on a weekly cadence. +- Treat recurring missing-example 404s as the top of the content + backlog — the docs already call this "the strongest possible signal + for what to write next." +- Treat high error-share pages as walkthrough gaps: learners tried + something the page did not prepare them for. Extend the cells or + notes there. +- Register the sitemap with Google Search Console and Bing Webmaster + Tools (if not already done) and fold query/impression data into the + same weekly look. Queries that show impressions without clicks are + title/summary problems; queries with no impressions are coverage + problems. + +## Thrust 2 — Cover what learners actually search for, without sprawl + +The catalog is deep on language semantics (Types 14, Data Model 12, +Basics 12, Functions 11, Collections 11) and comparatively light on the +task-shaped standard-library surfaces that dominate real Python search +demand (Standard Library 9, Text 3, Async 2). Go By Example's +most-linked pages are exactly the task-shaped ones: reading and +writing files, command-line arguments, environment variables. We have +no file-I/O or pathlib page today. + +Candidate additions — each still one mechanism per page, each through +the same rubric gates: + +- **Files and paths**: reading/writing text files, `pathlib`. Pyodide's + in-memory filesystem makes these genuinely runnable in the Dynamic + Worker — a live advantage over static competitors. +- **Process environment**: environment variables; command-line + arguments (`argparse`'s smallest loop). Both fit the proven + runtime-boundary framing already used by `networking` and + `subprocesses`: teach normal Python first, state the sandbox + constraint once. +- **Everyday stdlib**: `random`, `time`/`perf_counter`, `math` and + `statistics`, `hashlib`, `tempfile`, `functools` beyond + `partial` (`lru_cache`, `reduce`), `textwrap`. + +Filter and order this list by the 404 demand from Thrust 1 rather than +shipping it wholesale; cap the pace at what can clear the 9.0 gate. +The scope boundary should be stated once and kept: **language plus +standard library**. Third-party frameworks (pandas, Django, FastAPI) +are off-essence. + +Separately, surface work we already have: the confusable-pair registry +(`is` vs `==`, list vs tuple, shallow vs deep copy, `@staticmethod` vs +`@classmethod`, iterator vs iterable) matches extremely high-intent +searches. A small "X vs Y" contrasts index page — built from the +existing registry, linking to the existing pages — makes that value +visible to search engines and to learners scanning for a specific +confusion. + +## Thrust 3 — Deepen the on-page loop + +Small affordances, all quiet enough to preserve the low-chrome feel, +all aimed at the scorecard's engagement and return lines: + +- **Share this code.** `example.html` already restores code from a + `#code=` base64 fragment on load, but nothing ever writes one. Add a + "copy link to this code" affordance near the runner so every + experiment becomes a shareable artifact. Half the mechanism is + already built; the missing half is a distribution channel. +- **Copy buttons on code blocks.** Learners copy code constantly; + making them select text by hand is friction with no editorial value. +- **"Try it" nudges.** A one-line micro-challenge near the runner + ("change X to Y — predict the output before running") directly + targets the edited-share-up, error-share-down ideal. Start as a + notes convention or an optional block on the ten most-run pages and + measure whether edited share moves. +- **Example → journey orientation.** Journey pages link examples, but + example pages never mention the journeys they belong to. A one-line + "Part of the Iteration journey" link closes that edge of the graph + and gives a learner who landed from search a curated next step + beyond prev/next. +- **Keyboard prev/next** (`←`/`→`) to reinforce the tour feel. +- **Local progress marks.** A localStorage-only check on examples a + learner has read or run, rendered as a quiet dot on home cards and + journey lists. No accounts, no server state — but journeys gain a + sense of completion, which is the cheapest honest return-visit + mechanic available. + +## Thrust 4 — Earn distribution + +- **Publish the method.** An `/about` (or colophon) page telling the + editorial story: literate cells with verified output, the locked + figure grammar, geometry contract tests, quality gates. The `docs/` + folder is already public-grade; no competitor has this story, and it + is exactly the trust signal search engines and human referrers + reward. +- **Be citable by machines.** Publish `/llms.txt` and per-example + Markdown endpoints (`/examples/.md`). The sources are already + Markdown, and `docs/lessons-learned.md` already requires teaching + artifacts to be clean enough to serve as public material. AI + assistants are now a first-class referrer of learners; being the + cleanest citable source for "Python by example" queries is cheap and + fully on-essence. +- **Feeds.** An Atom feed of new and substantially revised examples so + newsletters and aggregators can pick up changes without watching the + repo. +- **Edit this page on GitHub.** A per-example source link converts + readers into contributors (CONTRIBUTING and issue templates already + exist) and is a further trust signal. +- **Launch moments.** The site has never had one. Milestone-shaped + posts — Show HN, r/Python, Python Weekly, an awesome-python listing + PR — with the runnable-literate angle as the hook. Sequence these + *after* the quick loop-deepening wins so spike traffic lands on the + sharpest version of the page. +- **Stay current as an event.** When Cloudflare's Python Workers + runtime moves past 3.13, migrate fast (the tooling exists: + `make verify-python-version`) and ship a small "new in this Python, + by example" set. Version currency is a recurring, newsworthy proof + that the site is alive — most competitor catalogs visibly rot. + +## Guardrails — what we will not do + +- No accounts, no ads, no comments, no gamification beyond quiet local + progress marks. +- No third-party framework or library tutorials; the boundary is the + language and its standard library. +- No course-ification; journeys remain curated arcs over the same + example pages. +- No content that bypasses the gates: every new page passes the same + rubric, formatter, parity, and verified-output checks regardless of + how fast we want to grow. +- No chrome creep: share, copy, progress, and breadcrumb affordances + must stay visually quiet. The reading experience is the product. + +## Sequencing + +**Now (days; mostly small, single-surface changes)** + +1. Share-this-code link + copy buttons on code blocks. +2. `/about` page + edit-on-GitHub links. +3. `/llms.txt`, per-example Markdown endpoints, Atom feed. +4. Search-console registration + the first weekly learner-report + ritual. + +**Next (weeks)** + +5. First demand-driven stdlib tranche: files/pathlib, environment + variables, command-line arguments, `random`, `time` — ordered by + observed 404s, gated by the rubric. +6. Example → journey breadcrumbs; keyboard prev/next. +7. "X vs Y" contrasts index from the confusable-pair registry. +8. "Try it" nudges on the ten most-run pages; measure edited share. +9. First launch moment, once 1–3 are live. + +**Horizon (quarter-plus)** + +10. Local journey progress marks. +11. Python version migration moment (runtime-dependent) plus a + what's-new example set. +12. Translations — only with a sustainable community model, never + machine-dumped. +13. Embeddable example widget for blogs — only if inbound demand + appears. + +## Risks + +- **Distribution before sharpening wastes the spike.** A Show HN that + lands on pages without share/copy affordances converts one visit + into zero artifacts. Sequence quick wins first. +- **Stdlib expansion drifts into reference sprawl.** The 404-demand + filter, the one-mechanism-per-page rule, and the broad-surface-title + lesson in `docs/lessons-learned.md` are the existing defenses; keep + them binding. +- **The analytics ritual decays.** Keep it one command and one short + dated summary; if it takes an hour it will stop happening. +- **Runner cost or abuse under spike traffic.** Turnstile protection + and the smoke bypass already exist; watch turnstile outcomes in the + weekly report during launch weeks. From 2b2cd2b843ecb51571bccb5e0190a26f4b73533d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 08:39:51 +0000 Subject: [PATCH 02/11] Add copy buttons, arrow-key navigation, and an /about page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy buttons inject client-side onto .cell-source wrappers so Shiki's pre replacement leaves them intact and no-JS pages render no dead buttons; Shiki now loads via dynamic import so a CDN failure degrades to plain code while copying keeps working. Left/right arrows follow the existing rel=prev/next links, guarded against modifiers and any editable focus. The /about page explains the verified-output pipeline, runner sandbox, figure grammar, and quality gates, and renders the design language from the live CSS tokens — counts derive from the catalog at render time. Nav links About on every page; /about joins the sitemap. Review fixes folded in: execCommand's boolean failure now routes to the Copy failed state, the copied class clears on failure, the about prose rule no longer overrides cell-label accent, the rail sample's Output label keeps production spacing, and the copy button drops its opacity dimming so idle contrast passes WCAG AA without hover. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJDjKbt3EyWPEThak9WQXp --- CHANGELOG.md | 4 + docs/elevation-strategy.md | 18 +-- ...eddb185f7433.js => runner.09997393b175.js} | 12 ++ public/runner.js | 12 ++ ...400d69f4ef8b.css => site.86899d355c70.css} | 20 ++++ public/site.css | 20 ++++ public/syntax-highlight.305edd86ffcd.js | 23 ---- public/syntax-highlight.95ae4ebe5db8.js | 78 +++++++++++++ public/syntax-highlight.js | 59 +++++++++- src/app.py | 29 ++++- src/asset_manifest.py | 4 +- src/templates/about.html | 71 ++++++++++++ src/templates/layout.html | 2 +- tests/test_app.py | 103 +++++++++++++++++- 14 files changed, 418 insertions(+), 37 deletions(-) rename public/{runner.eddb185f7433.js => runner.09997393b175.js} (86%) rename public/{site.400d69f4ef8b.css => site.86899d355c70.css} (91%) delete mode 100644 public/syntax-highlight.305edd86ffcd.js create mode 100644 public/syntax-highlight.95ae4ebe5db8.js create mode 100644 src/templates/about.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ddef0e7..7ab2fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Added +- `/about` page describing how the site is made — the verified-output pipeline, the runner sandbox, the figure grammar, and the quality gates — with a design-language section that renders the live CSS tokens (color swatches, spacing scale, type specimens, and a real walkthrough cell as a specimen). Linked from the nav on every page and listed in the sitemap. +- Copy buttons on read-only source cells: injected client-side onto `.cell-source` wrappers (so no-JS pages render no dead buttons), clipboard API with a hidden-textarea fallback, and a quiet token-styled treatment that sharpens on hover or focus. +- Keyboard navigation on example pages: `←`/`→` follow the existing `rel="prev"`/`rel="next"` links, ignoring keystrokes with modifiers or focus inside the editor, inputs, or any editable surface. + - Banner position grammar from `docs/visual-explainer-spec.md` is now production: `render_banner(slug, position)` supports `before`, `after-cell-N` (legacy anchor `cell-N`), and `after-walkthrough`, with multiple figures per position rendering as one small-multiple banner. The mutability page ships the canonical two-figure pair (aliased mutation vs. frozen tuple). - Curated pair banners on contrast cells: `positional-only-parameters` shows the `/` and `*` separator twins side by side, `metaclasses` pairs the metaclass triangle with the familiar class triangle, and `tuples` pairs the frozen tuple with the growing list on the intent-contrast cell. `iterator-vs-iterable` gains the one-pass caret figure on the exhaustion cell. diff --git a/docs/elevation-strategy.md b/docs/elevation-strategy.md index 5aa5833..cd40a74 100644 --- a/docs/elevation-strategy.md +++ b/docs/elevation-strategy.md @@ -115,13 +115,17 @@ The scope boundary should be stated once and kept: **language plus standard library**. Third-party frameworks (pandas, Django, FastAPI) are off-essence. -Separately, surface work we already have: the confusable-pair registry -(`is` vs `==`, list vs tuple, shallow vs deep copy, `@staticmethod` vs -`@classmethod`, iterator vs iterable) matches extremely high-intent -searches. A small "X vs Y" contrasts index page — built from the -existing registry, linking to the existing pages — makes that value -visible to search engines and to learners scanning for a specific -confusion. +Separately, sharpen how the existing contrast work meets search +intent. The confusable-pair contrasts already reach visitors: every +owner page is a public example, all of them appear in journey +sections, roughly half the pairs are stated in visitor-facing journey +text (e.g. "distinguish value equality from object identity"), and +iterator vs iterable has a dedicated page. What does not exist is a +surface shaped like the search query: no page enumerates the pairs, +and the registry itself is read only at build time. A small "X vs Y" +contrasts index — generated from the registry, linking to the owning +pages — would target those high-intent queries directly instead of +leaving each contrast mid-page where only an on-page reader finds it. ## Thrust 3 — Deepen the on-page loop diff --git a/public/runner.eddb185f7433.js b/public/runner.09997393b175.js similarity index 86% rename from public/runner.eddb185f7433.js rename to public/runner.09997393b175.js index c2dbf88..56d0846 100644 --- a/public/runner.eddb185f7433.js +++ b/public/runner.09997393b175.js @@ -141,3 +141,15 @@ if (document.readyState === 'loading') { } else { initializeRunner(); } + +// Left/right arrows page through the catalog via the existing +// rel=prev/next links. Modifier keys and any editable surface are +// ignored so the shortcut never interferes with editing. +document.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const target = event.target; + if (target instanceof Element && target.closest('input, textarea, select, button, .cm-editor, [contenteditable="true"]')) return; + const link = document.querySelector(event.key === 'ArrowLeft' ? '.example-nav a[rel="prev"]' : '.example-nav a[rel="next"]'); + if (link) window.location.href = link.href; +}); diff --git a/public/runner.js b/public/runner.js index c2dbf88..56d0846 100644 --- a/public/runner.js +++ b/public/runner.js @@ -141,3 +141,15 @@ if (document.readyState === 'loading') { } else { initializeRunner(); } + +// Left/right arrows page through the catalog via the existing +// rel=prev/next links. Modifier keys and any editable surface are +// ignored so the shortcut never interferes with editing. +document.addEventListener('keydown', (event) => { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const target = event.target; + if (target instanceof Element && target.closest('input, textarea, select, button, .cm-editor, [contenteditable="true"]')) return; + const link = document.querySelector(event.key === 'ArrowLeft' ? '.example-nav a[rel="prev"]' : '.example-nav a[rel="next"]'); + if (link) window.location.href = link.href; +}); diff --git a/public/site.400d69f4ef8b.css b/public/site.86899d355c70.css similarity index 91% rename from public/site.400d69f4ef8b.css rename to public/site.86899d355c70.css index 3745517..9a60a22 100644 --- a/public/site.400d69f4ef8b.css +++ b/public/site.86899d355c70.css @@ -109,6 +109,10 @@ .cell-code-stack code { white-space: pre-wrap; } .cell-source pre, .cell-source .shiki-block, .cell-output pre { margin: 0; padding: 0; border-left: 0; border-radius: 0; background: transparent !important; color: var(--text); box-shadow: none; white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; } .cell-source .shiki-block .line { display: inline; } + .cell-source { position: relative; } + .copy-button { position: absolute; top: -0.2rem; right: 0; padding: .12rem .6rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--surface-2); color: var(--muted); font-size: .74rem; cursor: pointer; transition-property: color, border-color; transition-duration: 150ms; } + .copy-button:hover, .copy-button:focus-visible { color: var(--text); border-color: var(--accent); } + .copy-button.copied { color: var(--accent); border-color: var(--accent); } .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } @@ -159,6 +163,22 @@ .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .site-footer-note { font-size: .82rem; text-align: center; } + /* About page: prose sections plus a design-language section that + renders the live tokens. Swatches composite each token over + --surface so translucent tokens (veils, softs) display honestly; + set the token per swatch with style="--swatch: var(--accent)". */ + .about-page { max-width: 820px; margin-inline: auto; } + .about-section { margin-block: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .about-section h2 { margin-bottom: var(--space-2); } + .about-section p:not(.cell-label) { max-width: 68ch; color: var(--muted); } + .about-section > .cell-label { margin-top: var(--space-4); } + .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); gap: var(--space-2) var(--space-3); margin: 0; padding: 0; list-style: none; } + .token { display: flex; align-items: center; gap: .6rem; } + .token code { font-size: .8rem; color: var(--muted); } + .token-swatch { flex: none; width: 2.1rem; height: 1.4rem; border: 1px solid var(--hairline); border-radius: .35rem; background: linear-gradient(var(--swatch, transparent), var(--swatch, transparent)), var(--surface); } + .token-bar { flex: none; height: .5rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--accent-soft); } + .type-specimen p { margin: 0 0 var(--space-2); } + .type-specimen .meta { font-size: .88rem; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } /* Cell banner: a figure attached to a cell renders in a banner row AFTER that cell, spanning the full content width. Cells always diff --git a/public/site.css b/public/site.css index 3745517..9a60a22 100644 --- a/public/site.css +++ b/public/site.css @@ -109,6 +109,10 @@ .cell-code-stack code { white-space: pre-wrap; } .cell-source pre, .cell-source .shiki-block, .cell-output pre { margin: 0; padding: 0; border-left: 0; border-radius: 0; background: transparent !important; color: var(--text); box-shadow: none; white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: visible; } .cell-source .shiki-block .line { display: inline; } + .cell-source { position: relative; } + .copy-button { position: absolute; top: -0.2rem; right: 0; padding: .12rem .6rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--surface-2); color: var(--muted); font-size: .74rem; cursor: pointer; transition-property: color, border-color; transition-duration: 150ms; } + .copy-button:hover, .copy-button:focus-visible { color: var(--text); border-color: var(--accent); } + .copy-button.copied { color: var(--accent); border-color: var(--accent); } .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } @@ -159,6 +163,22 @@ .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .site-footer-note { font-size: .82rem; text-align: center; } + /* About page: prose sections plus a design-language section that + renders the live tokens. Swatches composite each token over + --surface so translucent tokens (veils, softs) display honestly; + set the token per swatch with style="--swatch: var(--accent)". */ + .about-page { max-width: 820px; margin-inline: auto; } + .about-section { margin-block: var(--space-5); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .about-section h2 { margin-bottom: var(--space-2); } + .about-section p:not(.cell-label) { max-width: 68ch; color: var(--muted); } + .about-section > .cell-label { margin-top: var(--space-4); } + .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr)); gap: var(--space-2) var(--space-3); margin: 0; padding: 0; list-style: none; } + .token { display: flex; align-items: center; gap: .6rem; } + .token code { font-size: .8rem; color: var(--muted); } + .token-swatch { flex: none; width: 2.1rem; height: 1.4rem; border: 1px solid var(--hairline); border-radius: .35rem; background: linear-gradient(var(--swatch, transparent), var(--swatch, transparent)), var(--surface); } + .token-bar { flex: none; height: .5rem; border: 1px solid var(--hairline); border-radius: 9999px; background: var(--accent-soft); } + .type-specimen p { margin: 0 0 var(--space-2); } + .type-specimen .meta { font-size: .88rem; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } /* Cell banner: a figure attached to a cell renders in a banner row AFTER that cell, spanning the full content width. Cells always diff --git a/public/syntax-highlight.305edd86ffcd.js b/public/syntax-highlight.305edd86ffcd.js deleted file mode 100644 index 0cfc842..0000000 --- a/public/syntax-highlight.305edd86ffcd.js +++ /dev/null @@ -1,23 +0,0 @@ -import { codeToHtml } from 'https://esm.sh/shiki@1.29.2'; - -const blocks = document.querySelectorAll('pre code.language-python'); - -for (const block of blocks) { - const source = block.textContent; - try { - const highlighted = await codeToHtml(source, { - lang: 'python', - themes: { light: 'github-light', dark: 'github-dark' }, - defaultColor: 'light', - }); - const wrapper = document.createElement('div'); - wrapper.innerHTML = highlighted; - const shikiPre = wrapper.firstElementChild; - if (!shikiPre) continue; - shikiPre.classList.add('shiki-block'); - const currentPre = block.closest('pre'); - currentPre.replaceWith(shikiPre); - } catch (error) { - block.dataset.highlightFailed = 'true'; - } -} diff --git a/public/syntax-highlight.95ae4ebe5db8.js b/public/syntax-highlight.95ae4ebe5db8.js new file mode 100644 index 0000000..2453137 --- /dev/null +++ b/public/syntax-highlight.95ae4ebe5db8.js @@ -0,0 +1,78 @@ +// Copy affordance for read-only source blocks. The button attaches to +// the .cell-source wrapper (not the
) so the Shiki replacement
+// below leaves it in place, and it is injected client-side so no-JS
+// pages render no dead buttons.
+async function copyText(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(text);
+    return;
+  }
+  const holder = document.createElement('textarea');
+  holder.value = text;
+  holder.setAttribute('readonly', '');
+  holder.style.position = 'fixed';
+  holder.style.left = '-9999px';
+  document.body.appendChild(holder);
+  holder.select();
+  const copied = document.execCommand('copy');
+  holder.remove();
+  if (!copied) throw new Error('execCommand copy failed');
+}
+
+for (const source of document.querySelectorAll('.cell-source')) {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'copy-button';
+  button.textContent = 'Copy';
+  button.setAttribute('aria-label', 'Copy this source fragment');
+  let restore = null;
+  button.addEventListener('click', async () => {
+    const code = source.querySelector('pre code') || source.querySelector('pre');
+    if (!code) return;
+    clearTimeout(restore);
+    try {
+      await copyText(code.textContent);
+      button.textContent = 'Copied';
+      button.classList.add('copied');
+    } catch (error) {
+      button.classList.remove('copied');
+      button.textContent = 'Copy failed';
+    }
+    restore = setTimeout(() => {
+      button.textContent = 'Copy';
+      button.classList.remove('copied');
+    }, 1600);
+  });
+  source.append(button);
+}
+
+// Shiki is imported dynamically so a CDN failure degrades to plain
+// server-rendered code while the copy buttons above keep working.
+let codeToHtml = null;
+try {
+  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+} catch (error) {
+  codeToHtml = null;
+}
+
+const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
+
+for (const block of blocks) {
+  const source = block.textContent;
+  try {
+    const highlighted = await codeToHtml(source, {
+      lang: 'python',
+      themes: { light: 'github-light', dark: 'github-dark' },
+      defaultColor: 'light',
+    });
+    const wrapper = document.createElement('div');
+    wrapper.innerHTML = highlighted;
+    const shikiPre = wrapper.firstElementChild;
+    if (!shikiPre) continue;
+    shikiPre.classList.add('shiki-block');
+    const currentPre = block.closest('pre');
+    currentPre.replaceWith(shikiPre);
+  } catch (error) {
+    block.dataset.highlightFailed = 'true';
+  }
+}
diff --git a/public/syntax-highlight.js b/public/syntax-highlight.js
index 0cfc842..2453137 100644
--- a/public/syntax-highlight.js
+++ b/public/syntax-highlight.js
@@ -1,6 +1,61 @@
-import { codeToHtml } from 'https://esm.sh/shiki@1.29.2';
+// Copy affordance for read-only source blocks. The button attaches to
+// the .cell-source wrapper (not the 
) so the Shiki replacement
+// below leaves it in place, and it is injected client-side so no-JS
+// pages render no dead buttons.
+async function copyText(text) {
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(text);
+    return;
+  }
+  const holder = document.createElement('textarea');
+  holder.value = text;
+  holder.setAttribute('readonly', '');
+  holder.style.position = 'fixed';
+  holder.style.left = '-9999px';
+  document.body.appendChild(holder);
+  holder.select();
+  const copied = document.execCommand('copy');
+  holder.remove();
+  if (!copied) throw new Error('execCommand copy failed');
+}
+
+for (const source of document.querySelectorAll('.cell-source')) {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'copy-button';
+  button.textContent = 'Copy';
+  button.setAttribute('aria-label', 'Copy this source fragment');
+  let restore = null;
+  button.addEventListener('click', async () => {
+    const code = source.querySelector('pre code') || source.querySelector('pre');
+    if (!code) return;
+    clearTimeout(restore);
+    try {
+      await copyText(code.textContent);
+      button.textContent = 'Copied';
+      button.classList.add('copied');
+    } catch (error) {
+      button.classList.remove('copied');
+      button.textContent = 'Copy failed';
+    }
+    restore = setTimeout(() => {
+      button.textContent = 'Copy';
+      button.classList.remove('copied');
+    }, 1600);
+  });
+  source.append(button);
+}
+
+// Shiki is imported dynamically so a CDN failure degrades to plain
+// server-rendered code while the copy buttons above keep working.
+let codeToHtml = null;
+try {
+  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+} catch (error) {
+  codeToHtml = null;
+}
 
-const blocks = document.querySelectorAll('pre code.language-python');
+const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
 
 for (const block of blocks) {
   const source = block.textContent;
diff --git a/src/app.py b/src/app.py
index 4512822..d2687ad 100644
--- a/src/app.py
+++ b/src/app.py
@@ -378,8 +378,33 @@ def render_journey_page(journey):
     )
 
 
+def render_about() -> str:
+    content = _replace(
+        _template("about.html"),
+        {
+            "EXAMPLE_COUNT": str(len(list_examples())),
+            "JOURNEY_COUNT": str(len(JOURNEYS)),
+            "PYTHON_VERSION": html.escape(PYTHON_VERSION),
+        },
+    )
+    return _layout(
+        "About",
+        content,
+        description=f"How Python By Example is made: verified output for every example, an isolated Python {PYTHON_VERSION} runner, a locked figure grammar, and the design tokens behind every page.",
+        path="/about",
+        structured_data={
+            "@context": "https://schema.org",
+            "@type": "AboutPage",
+            "name": "About Python By Example",
+            "url": f"{SITE_URL}/about",
+            "description": f"How Python By Example teaches Python {PYTHON_VERSION}: verified example output, sandboxed execution, a locked figure grammar, and explicit quality gates.",
+            "inLanguage": "en",
+        },
+    )
+
+
 def render_sitemap() -> str:
-    paths = ["/", "/journeys"]
+    paths = ["/", "/about", "/journeys"]
     paths.extend(f'/journeys/{journey["slug"]}' for journey in JOURNEYS)
     paths.extend(f'/examples/{example["slug"]}' for example in list_examples())
     entries = "".join(f"{html.escape(SITE_URL + path)}" for path in paths)
@@ -686,6 +711,8 @@ def route(url: str, method: str = "GET", turnstile_site_key: str | None = None)
             render_cell_output_flow_option(get_example("values")),
             headers={"Content-Type": "text/html; charset=utf-8"},
         )
+    if method == "GET" and path == "/about":
+        return AppResponse(render_about(), headers={"Content-Type": "text/html; charset=utf-8"})
     if method == "GET" and path == "/journeys":
         return AppResponse(render_journeys_index(), headers={"Content-Type": "text/html; charset=utf-8"})
     if method == "GET" and path.startswith("/journeys/"):
diff --git a/src/asset_manifest.py b/src/asset_manifest.py
index 8f63169..d5ceac7 100644
--- a/src/asset_manifest.py
+++ b/src/asset_manifest.py
@@ -1,3 +1,3 @@
 # Generated by scripts/fingerprint_assets.py. Do not edit by hand.
-ASSET_PATHS = {'SITE_CSS': '/site.400d69f4ef8b.css', 'SYNTAX_JS': '/syntax-highlight.305edd86ffcd.js', 'EDITOR_JS': '/editor.32983031ee9a.js', 'RUNNER_JS': '/runner.eddb185f7433.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'}
-HTML_CACHE_VERSION = '12f2e5d32f16'
+ASSET_PATHS = {'SITE_CSS': '/site.86899d355c70.css', 'SYNTAX_JS': '/syntax-highlight.95ae4ebe5db8.js', 'EDITOR_JS': '/editor.32983031ee9a.js', 'RUNNER_JS': '/runner.09997393b175.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'}
+HTML_CACHE_VERSION = '975176d75196'
diff --git a/src/templates/about.html b/src/templates/about.html
new file mode 100644
index 0000000..7bc3113
--- /dev/null
+++ b/src/templates/about.html
@@ -0,0 +1,71 @@
+
+ +
+

About

+

How this site is made

+

__EXAMPLE_COUNT__ examples and __JOURNEY_COUNT__ journeys, all on Python __PYTHON_VERSION__. Those counts are computed from the catalog when this page renders, not typed into this sentence.

+
+
+

One concept per page

+

Python By Example teaches Python through small literate programs. Each page holds one concept. Prose sits beside the source it explains, and each source fragment is followed by the output it produced. Links point at the official Python __PYTHON_VERSION__ documentation instead of restating it. __JOURNEY_COUNT__ journeys arrange the same examples into longer arcs, each section with named learner outcomes.

+
+
+

Every page runs

+

The complete program at the bottom of each example is editable. Run sends it to an isolated Cloudflare Dynamic Worker with outbound network disabled and a one-second CPU budget. The output panel shows the real stdout of the code you ran, and the measured execution time below it.

+
+
+

Output is verified, not promised

+

The expected output beside each fragment is not written by hand. A build step executes every teaching cell and every complete program, then fails if the recorded output differs from what actually printed. Cells the sandbox cannot execute are labelled as standard Python instead of pretending to run.

+
+
+

The figures

+

The small diagrams are composed from a locked drawing grammar: one palette, fixed stroke weights, a short list of shapes, and no escape hatch for raw SVG. Geometry tests check every figure for clipping and collisions before it ships. Some pages have no figure on purpose; each omission carries a recorded reason.

+
+
+

The quality gates

+

A written rubric scores every example, with a target of 9.0 and a hard minimum of 8.5. A page below target must name the work that remains; Hello World carries the only standing waiver, because first programs are traditionally tiny. Separate checks cover confusable pairs (iterator vs iterable, is vs ==), documented footguns, journey outcomes, and the links between examples.

+
+
+

The design language

+

Every page draws from the tokens below, and this section reads them from the same stylesheet it documents. In dark mode the warm palette inverts; --figure-paper stays light, so figures sit on a paper chip instead of being recoloured.

+

Color

+
    +
  • --accent
  • +
  • --accent-hover
  • +
  • --accent-soft
  • +
  • --text
  • +
  • --muted
  • +
  • --page
  • +
  • --surface
  • +
  • --surface-2
  • +
  • --surface-3
  • +
  • --hairline
  • +
  • --hairline-soft
  • +
  • --header-veil
  • +
  • --header-veil-0
  • +
  • --header-veil-solid
  • +
  • --figure-paper
  • +
+

Spacing

+
    +
  • --space-1
  • +
  • --space-2
  • +
  • --space-3
  • +
  • --space-4
  • +
  • --space-5
  • +
  • --space-6
  • +
+

Type

+
+

Names bind to objects; objects carry the type. FT Kunst Grotesk with system fallbacks, 16px/1.6, for prose.

+
+

The rail

+

Source

value = 21 * 2
+print(f"value = {value}")

Output

value = 42
+

The block above is not a screenshot. It is the same markup every walkthrough cell uses: monospace source on the accent rail, output under a hairline, and a copy button added when JavaScript is available.

+
+
+

Lineage and stack

+

The format follows Go by Example's example-per-page teaching and Rust By Example's expectation that examples run. Literate programming supplied the premise that prose and source should explain each other; marimo's source/output adjacency shaped the cells. The site runs on Cloudflare Python Workers with FastAPI; Shiki highlights read-only code and CodeMirror powers the editor. The source is public under the MIT license at github.com/adewale/pythonbyexample.

+
+
diff --git a/src/templates/layout.html b/src/templates/layout.html index 2dcb759..a24c935 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -22,7 +22,7 @@
- +
__CONTENT__
diff --git a/tests/test_app.py b/tests/test_app.py index e32e620..4abf1f3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -761,6 +761,7 @@ def test_sitemap_lists_every_page_with_canonical_urls(self): self.assertEqual(response.headers["Content-Type"], "application/xml; charset=utf-8") self.assertIn('', response.body) self.assertIn("https://www.pythonbyexample.dev/", response.body) + self.assertIn("https://www.pythonbyexample.dev/about", response.body) self.assertIn("https://www.pythonbyexample.dev/journeys", response.body) from src.app import JOURNEYS @@ -770,7 +771,7 @@ def test_sitemap_lists_every_page_with_canonical_urls(self): self.assertIn(f"https://www.pythonbyexample.dev/examples/{example['slug']}", response.body) self.assertEqual( response.body.count(""), - 2 + len(JOURNEYS) + len(list_examples()), + 3 + len(JOURNEYS) + len(list_examples()), ) def test_example_pages_carry_learning_resource_json_ld(self): @@ -807,5 +808,105 @@ def test_robots_txt_references_sitemap(self): self.assertIn("User-agent: *", robots) +class AboutPageTests(unittest.TestCase): + def test_about_page_is_routable_with_canonical_metadata(self): + response = route("https://example.test/about") + self.assertEqual(response.status, 200) + page = response.body + self.assertIn("About · Python By Example", page) + self.assertIn('', page) + self.assertIn('', page) + match = re.search(r'', page, re.S) + self.assertIsNotNone(match) + data = json.loads(match.group(1)) + self.assertEqual(data["@type"], "AboutPage") + self.assertEqual(data["url"], "https://www.pythonbyexample.dev/about") + + def test_about_page_derives_counts_instead_of_hardcoding_them(self): + from src.app import JOURNEYS, render_about + + page = render_about() + self.assertNotIn("__EXAMPLE_COUNT__", page) + self.assertNotIn("__JOURNEY_COUNT__", page) + self.assertIn(f"{len(list_examples())} examples and {len(JOURNEYS)} journeys", page) + + def test_about_page_renders_the_design_language_from_live_tokens(self): + from src.app import render_about + + page = render_about() + self.assertIn('class="token-grid"', page) + self.assertIn("--swatch: var(--accent)", page) + self.assertIn("width: var(--space-6)", page) + self.assertIn('class="cell-code-stack"', page) + + def test_about_page_tokens_all_exist_in_the_stylesheet(self): + css_root = (ROOT / "public" / "site.css").read_text().split("\n", 1)[0] + template = (ROOT / "src" / "templates" / "about.html").read_text() + tokens = set(re.findall(r"var\((--[a-z0-9-]+)\)", template)) + self.assertGreaterEqual(len(tokens), 21) + for token in sorted(tokens): + with self.subTest(token=token): + self.assertIn(f"{token}:", css_root) + + def test_every_page_links_about_in_the_nav(self): + for page in [render_home(), render_example_page(get_example("hello-world"))]: + self.assertIn('About', page) + + def test_about_page_loads_no_editor_or_search_assets(self): + page = route("https://example.test/about").body + self.assertNotIn("/editor.", page) + self.assertNotIn("search-index", page) + + def test_about_page_meets_the_seo_linter_bar(self): + page = route("https://example.test/about").body + self.assertIsNone(re.search(r"__[A-Z][A-Z0-9_]+__", page)) + description = re.search(r'', page) + self.assertIsNotNone(description) + self.assertTrue(80 <= len(description.group(1)) <= 180, len(description.group(1))) + self.assertRegex(page, r'href="/site\.[0-9a-f]{12}\.css"') + self.assertRegex(page, r'src="/syntax-highlight\.[0-9a-f]{12}\.js"') + + +class CopyButtonTests(unittest.TestCase): + def test_syntax_script_injects_copy_buttons_on_source_cells(self): + js = (ROOT / "public" / "syntax-highlight.js").read_text() + self.assertIn("'.cell-source'", js) + self.assertIn("copy-button", js) + self.assertIn("navigator.clipboard", js) + self.assertIn("execCommand('copy')", js) + self.assertIn("aria-label", js) + + def test_copy_button_styles_use_design_tokens(self): + css = (ROOT / "public" / "site.css").read_text() + self.assertIn(".cell-source { position: relative; }", css) + self.assertIn(".copy-button", css) + self.assertIn(".copy-button.copied", css) + + +class KeyboardNavTests(unittest.TestCase): + def test_runner_script_navigates_with_arrow_keys(self): + js = (ROOT / "public" / "runner.js").read_text() + self.assertIn("ArrowLeft", js) + self.assertIn("ArrowRight", js) + self.assertIn('.example-nav a[rel="prev"]', js) + self.assertIn('.example-nav a[rel="next"]', js) + + def test_arrow_navigation_skips_editable_and_modified_keys(self): + js = (ROOT / "public" / "runner.js").read_text() + self.assertIn("'input, textarea, select, button, .cm-editor, [contenteditable=\"true\"]'", js) + self.assertIn("event.metaKey", js) + self.assertIn("event.defaultPrevented", js) + + def test_arrow_navigation_guards_missing_neighbors_at_catalog_edges(self): + examples = list_examples() + first_page = render_example_page(get_example(examples[0]["slug"])) + last_page = render_example_page(get_example(examples[-1]["slug"])) + self.assertNotIn('' + f'' if previous_example else "" ) next_link = ( - f'' + f'' if next_example else "" ) diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 4e9c3f7..2720283 100644 --- a/src/asset_manifest.py +++ b/src/asset_manifest.py @@ -1,3 +1,3 @@ # Generated by scripts/fingerprint_assets.py. Do not edit by hand. -ASSET_PATHS = {'SITE_CSS': '/site.67507d1d482a.css', 'SYNTAX_JS': '/syntax-highlight.d6178fb3eef5.js', 'EDITOR_JS': '/editor.32983031ee9a.js', 'RUNNER_JS': '/runner.71ee2845b59c.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'} -HTML_CACHE_VERSION = '134f97923f9b' +ASSET_PATHS = {'SITE_CSS': '/site.215b3101140a.css', 'SYNTAX_JS': '/syntax-highlight.d6178fb3eef5.js', 'EDITOR_JS': '/editor.32983031ee9a.js', 'RUNNER_JS': '/runner.a5b99ceb274f.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'} +HTML_CACHE_VERSION = '1895cf9b1c6a' diff --git a/tests/test_app.py b/tests/test_app.py index f737761..db5c97e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -288,7 +288,7 @@ def test_example_page_contains_code_docs_and_run_form(self): self.assertIn("Executed in 12.3 ms", render_example_page(get_example("hello-world"), output="hello world\n", execution_time_ms=12.3)) self.assertIn("Execution time appears here after you run the example.", render_example_page(get_example("hello-world"))) self.assertIn("Expected output", render_example_page(get_example("hello-world"))) - self.assertIn('rel="next" href="/examples/values"', html) + self.assertIn('rel="next" title="Next example (right arrow key)" href="/examples/values"', html) self.assertIn('method="post"', html) self.assertIn('