From b31261f64955604f883dd4e91d6c5e6b8f3281b8 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Nov 2026 13:59:26 +0100 Subject: [PATCH 01/16] Add Popcorn evaluator and build toolchain --- .github/workflows/ci.yml | 10 +++++ .github/workflows/deploy.yml | 10 +++++ .gitignore | 4 ++ BUNDLE_AVM.md | 80 ------------------------------------ README.md | 55 ++++++++++++++++++++----- astro.config.ts | 3 +- package.json | 1 + pnpm-lock.yaml | 23 +++++++++++ popcorn/.formatter.exs | 3 ++ popcorn/lib/application.ex | 10 +++++ popcorn/lib/evaluator.ex | 39 ++++++++++++++++++ popcorn/mise.toml | 3 ++ popcorn/mix.exs | 16 ++++++++ popcorn/mix.lock | 5 +++ tsconfig.json | 3 ++ 15 files changed, 173 insertions(+), 92 deletions(-) delete mode 100644 BUNDLE_AVM.md create mode 100644 popcorn/.formatter.exs create mode 100644 popcorn/lib/application.ex create mode 100644 popcorn/lib/evaluator.ex create mode 100644 popcorn/mise.toml create mode 100644 popcorn/mix.exs create mode 100644 popcorn/mix.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fa14dd80..fc0088b01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: Setup Elixir and OTP + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.20.4" + otp-version: "29.0.6" + + - name: Install evaluator dependencies + working-directory: popcorn + run: mix deps.get --check-locked + - name: Check run: | pnpm lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 615c1f82d..d28f21f22 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: Setup Elixir and OTP + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.20.4" + otp-version: "29.0.6" + + - name: Install evaluator dependencies + working-directory: popcorn + run: mix deps.get --check-locked + - name: Build Astro site run: pnpm run build diff --git a/.gitignore b/.gitignore index d868e490d..eff922749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # build output dist/ +.llm/ +popcorn/_build/ +popcorn/deps/ +popcorn/public/ # generated types .astro/ diff --git a/BUNDLE_AVM.md b/BUNDLE_AVM.md deleted file mode 100644 index 4f8a72a12..000000000 --- a/BUNDLE_AVM.md +++ /dev/null @@ -1,80 +0,0 @@ -# Popcorn WASM bundle - -The homepage hero ships an interactive Elixir editor backed by -[Software Mansion's Popcorn](https://github.com/software-mansion/popcorn), -which compiles AtomVM + an Elixir handler module to WebAssembly. - -The runtime is loaded from `public/bundle.avm` and served beneath Astro's -configured base path. The file is ~7 MB and **is committed** to the repo so -cloning gives you a working interactive demo out of the box. - -When `public/bundle.avm` is missing the editor still works: it loads the -example snippets and shows their precanned outputs, and `astro.config.ts` -skips registering the Popcorn Vite plugin. Drop a valid `bundle.avm` -back into `public/` and the live runtime turns on automatically — no -code changes needed. - -## How to regenerate `bundle.avm` - -Popcorn v0.3.0 hard-pins **OTP 26.0.2** and **Elixir 1.17.3**. macOS -ships much newer toolchains, so the safest way to reproduce the build -is a one-shot Docker container. - -1. Clone the Popcorn repository (anywhere on disk): - - ```bash - git clone https://github.com/software-mansion/popcorn.git ~/popcorn - ``` - -2. From the elixir-website repo root, run the pinned-toolchain build: - - ```bash - docker run --rm \ - -v "$HOME/popcorn:/popcorn" \ - -v "$PWD/public:/out" \ - -w /popcorn/examples/eval-in-wasm \ - -e MIX_HOME=/tmp/.mix -e HEX_HOME=/tmp/.hex \ - -e LANG=C.UTF-8 -e LC_ALL=C.UTF-8 -e ELIXIR_ERL_OPTIONS="+fnu" \ - erlang:26.0.2-slim bash -lc ' - set -euo pipefail - apt-get update -qq && apt-get install -y -qq curl unzip git ca-certificates >/dev/null - curl -fsSL -o /tmp/elixir.zip https://github.com/elixir-lang/elixir/releases/download/v1.17.3/elixir-otp-26.zip - mkdir -p /opt/elixir && unzip -q /tmp/elixir.zip -d /opt/elixir - export PATH=/opt/elixir/bin:$PATH - mix local.hex --force >/dev/null && mix local.rebar --force >/dev/null - mix deps.get - mix popcorn.cook - cp dist/wasm/bundle.avm /out/bundle.avm - ' - ``` - -3. The rebuilt `bundle.avm` lands at `public/bundle.avm`. Commit it - when the upstream Popcorn version changes or the handler module is - updated. - -## How the editor wires up - -- The Vite plugin (`@swmansion/popcorn/vite`) is registered - conditionally in `astro.config.ts` — only when `public/bundle.avm` - exists. It excludes Popcorn from Vite's dep prebundling (so the - internal worker iframe import resolves) and serves the bundle with - the SharedArrayBuffer-required headers in dev/preview. -- GitHub Pages uses `public/coi-serviceworker.js` to provide the cross-origin - isolation headers that the static host cannot configure itself. -- The hero React island (`src/components/home/HeroCodeEditor.tsx`) - HEAD-probes the base-path-aware bundle URL; if the response is 200, it - dynamically imports `@swmansion/popcorn` and calls - `popcorn.call(["eval_elixir", code], { timeoutMs: 10_000 })`. If - anything fails it falls back to the precanned output for the active - example. - -## Notes - -- Popcorn v0.3.0 is described upstream as "early stages — may break"; - the graceful fallback keeps the page demoable if the runtime fails - to boot for any reason. -- The handler action name is `eval_elixir`. If you customise the - example Mix app (see `~/popcorn/examples/eval-in-wasm/lib/eval_in_wasm.ex`), - keep the action name in sync with `HeroCodeEditor.tsx`. -- The bundle includes the full Elixir + stdlib + logger BEAM files for - the WASM target — that's why it weighs ~7 MB. diff --git a/README.md b/README.md index 40ea442fd..1f4f86939 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,15 @@ Static Astro site, content authored in Markdown, deployed with GitHub Pages. ```bash pnpm install -pnpm dev # http://localhost:4321 +mise install --cd popcorn +mise exec --cd popcorn -- mix deps.get --check-locked +mise exec elixir@1.20.4-otp-29 erlang@29.0.6 -- pnpm dev ``` -The Popcorn hero editor uses the AVM bundle at `public/bundle.avm`. The -file is committed (~7 MB) so the editor "just works" after `pnpm install`. -If it's missing the editor gracefully falls back to pre-canned outputs. -Regeneration instructions live in [`BUNDLE_AVM.md`](./BUNDLE_AVM.md). +Use the same Elixir/OTP toolchain for `pnpm build`, `pnpm preview`, and +`pnpm typecheck`. Popcorn's Vite plugin compiles and packages the evaluator +in `popcorn/`; no generated runtime binaries are committed. Building its +Brotli dependency also requires `make` and a C compiler. ## Scripts @@ -43,6 +45,7 @@ Regeneration instructions live in [`BUNDLE_AVM.md`](./BUNDLE_AVM.md). | `pnpm lint` | Biome lint only | | `pnpm typecheck` | `astro check` (TypeScript across `.astro` and `.ts(x)`) | | `pnpm test` | All Playwright specs | +| `pnpm test:popcorn` | Service-worker checks and built homepage examples | | `pnpm test:smoke` | Route smoke checks | | `pnpm test:visual` | Visual regression snapshots | | `pnpm test:a11y` | axe-core accessibility scan | @@ -56,10 +59,9 @@ elixir-website/ ├── biome.json ├── tsconfig.json ├── package.json -├── BUNDLE_AVM.md # how to (re)build public/bundle.avm ├── AGENTS.md # conventions for AI assistants -├── public/ # favicon, OG fallback, robots.txt, -│ # bundle.avm (Popcorn runtime) +├── popcorn/ # evaluator, locked dependencies and toolchain +├── public/ # favicon, OG fallback, robots.txt, isolation worker ├── scripts/ # Deployment and SVG optimization utilities ├── src/ │ ├── assets/ # fonts, icons, logos, images @@ -180,9 +182,40 @@ GitHub Pages builds the site through `.github/workflows/deploy.yml`. The workflow builds the canonical `https://elixir-lang.org` site and deploys the generated `dist` directory. -The Popcorn WASM hero editor needs cross-origin isolation. Because GitHub -Pages cannot configure COOP/COEP response headers, production builds load -`public/coi-serviceworker.js` from `BaseLayout.astro`. +Publish the whole `dist/` tree, including the generated workers, Wasm and +`_astro/otp/` archives. Popcorn 1.0.1 is pinned in npm and Mix. Its threaded +OTP runtime needs `SharedArrayBuffer` and these response headers: + +```http +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +GitHub Pages cannot set these headers. The first Run registers +`public/coi-serviceworker.js` and reloads once to enable isolation, preserving +edits and the requested execution. The worker persists across later visits. +Production requires HTTPS; localhost is a secure-context exception. Hosts +that supply the headers directly need no worker or reload. + +### React lifecycle hook + +`src/hooks/use-popcorn.ts` owns the VM lifecycle. `start(options)` replaces any +previous VM and returns the booted instance, or `null` on failure or cancellation. +Options apply to that start only. The hook exposes status and failures, stops +the VM on unmount, and provides `stop()` for explicit shutdown. Isolation, +evaluation, output formatting and UI timing stay in the homepage hooks. + +### Homepage examples + +Code is editable without autofocus. Editing loads syntax highlighting, not +the runtime. Run, Ctrl+Enter or Cmd+Enter boots Popcorn and evaluates with +fresh bindings. Loading and execution feedback appears after 350 ms; previous +output stays visible until execution completes. Results and stdout are separate, and terminal output is +limited to 16,000 characters. A task enforces a ten-second execution timeout; +JavaScript waits up to fifteen seconds for its reply. Cancel appears after +three seconds and stops the VM without discarding edits or previous output. +Run starts a fresh VM after cancellation. Retry restarts a failed runtime +without executing the source. Leaving the homepage stops the VM. Preview builds emit `noindex, nofollow`. Set `PROD=true` only in the official deployment workflow to allow search engines to index the site. diff --git a/astro.config.ts b/astro.config.ts index bd6d9b072..9028b884f 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,6 +1,7 @@ import mdx from "@astrojs/mdx"; import react from "@astrojs/react"; import sitemap from "@astrojs/sitemap"; +import { popcorn } from "@swmansion/popcorn/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, fontProviders } from "astro/config"; import { toAstroRedirects } from "./src/data/redirects"; @@ -50,6 +51,6 @@ export default defineConfig({ }, integrations: [react(), mdx(), sitemap()], vite: { - plugins: [tailwindcss()], + plugins: [tailwindcss(), popcorn({ rootDir: "./popcorn" })], }, }); diff --git a/package.json b/package.json index 734361f6f..d1e57ba3a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", + "@swmansion/popcorn": "1.0.1", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.1.14", "astro": "5.16.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f368315c1..1de8f0a54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@shikijs/types': specifier: 3.23.0 version: 3.23.0 + '@swmansion/popcorn': + specifier: 1.0.1 + version: 1.0.1(esbuild@0.25.12)(rollup@4.60.3)(vite@6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0)) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19(tailwindcss@4.1.14) @@ -951,6 +954,20 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@swmansion/popcorn@1.0.1': + resolution: {integrity: sha512-oROTyb3SIABkwl5dzHFawerbFIzC7TZTnukYOowS2oPtdkRkzWKTQ76em7pvOIdMekrYizroiY0qkL1lUHfyGw==} + peerDependencies: + esbuild: '>=0.17.0' + rollup: '>=4.0.0' + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + '@tailwindcss/node@4.1.14': resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} @@ -3516,6 +3533,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@swmansion/popcorn@1.0.1(esbuild@0.25.12)(rollup@4.60.3)(vite@6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0))': + optionalDependencies: + esbuild: 0.25.12 + rollup: 4.60.3 + vite: 6.4.2(@types/node@24.7.2)(jiti@2.7.0)(lightningcss@1.30.1)(yaml@2.9.0) + '@tailwindcss/node@4.1.14': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/popcorn/.formatter.exs b/popcorn/.formatter.exs new file mode 100644 index 000000000..728d88368 --- /dev/null +++ b/popcorn/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["mix.exs", "{config,lib}/**/*.{ex,exs}"] +] diff --git a/popcorn/lib/application.ex b/popcorn/lib/application.ex new file mode 100644 index 000000000..05b02d8ca --- /dev/null +++ b/popcorn/lib/application.ex @@ -0,0 +1,10 @@ +defmodule WebsiteExamples.Application do + use Application + + @impl true + def start(_type, _args) do + # The output pane displays plain text, not terminal escape sequences. + Application.put_env(:elixir, :ansi_enabled, false) + Supervisor.start_link([WebsiteExamples.Evaluator], strategy: :one_for_one) + end +end diff --git a/popcorn/lib/evaluator.ex b/popcorn/lib/evaluator.ex new file mode 100644 index 000000000..78f280eea --- /dev/null +++ b/popcorn/lib/evaluator.ex @@ -0,0 +1,39 @@ +defmodule WebsiteExamples.Evaluator do + use GenServer + + def start_link(_args), do: GenServer.start_link(__MODULE__, nil, name: :evaluator) + + @impl true + def init(_args), do: {:ok, nil} + + @impl true + def handle_call(["eval", source], _from, state) do + task = Task.async(fn -> evaluate(source) end) + + result = + case Task.yield(task, 5_000) || Task.shutdown(task, :brutal_kill) do + {:ok, result} -> result + nil -> %{error: "Execution timed out after 5 seconds."} + end + + {:reply, result, state} + end + + defp evaluate(source) do + {value, _bindings} = Code.eval_string(source, [], file: "example.exs") + %{value: inspect(value, pretty: true, width: 50)} + rescue + error -> %{error: Exception.format(:error, error, user_stacktrace(__STACKTRACE__))} + catch + kind, reason -> %{error: Exception.format(kind, reason, user_stacktrace(__STACKTRACE__))} + end + + defp user_stacktrace(stacktrace) do + Enum.take_while(stacktrace, fn + {:elixir, _, _, _} -> false + {Code, :validated_eval_string, _, _} -> false + {__MODULE__, _, _, _} -> false + _ -> true + end) + end +end diff --git a/popcorn/mise.toml b/popcorn/mise.toml new file mode 100644 index 000000000..f3c4ae04d --- /dev/null +++ b/popcorn/mise.toml @@ -0,0 +1,3 @@ +[tools] +elixir = "1.20.4-otp-29" +erlang = "29.0.6" diff --git a/popcorn/mix.exs b/popcorn/mix.exs new file mode 100644 index 000000000..0d48ba46c --- /dev/null +++ b/popcorn/mix.exs @@ -0,0 +1,16 @@ +defmodule WebsiteExamples.MixProject do + use Mix.Project + + def project do + [ + app: :website_examples, + version: "0.1.0", + elixir: "~> 1.20", + deps: [{:popcorn, "== 1.0.1"}] + ] + end + + def application do + [mod: {WebsiteExamples.Application, []}] + end +end diff --git a/popcorn/mix.lock b/popcorn/mix.lock new file mode 100644 index 000000000..4dc2c73cf --- /dev/null +++ b/popcorn/mix.lock @@ -0,0 +1,5 @@ +%{ + "brotli": {:hex, :brotli, "0.3.4", "da8b4486b55ac937fc8c24142a25f740e13681a7c3702e82ded9123a9c2e0afd", [:rebar3], [], "hexpm", "1439bf0d635c7818cb531054ddbbf2e223d49a79350b7ace26a2965ae0aecb25"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "popcorn": {:hex, :popcorn, "1.0.1", "4ca654138b9210b56676b4f903772293b2fe981ded9d6e2e761e55c712365ab7", [:mix], [{:brotli, "~> 0.3.3", [hex: :brotli, repo: "hexpm", optional: false]}, {:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0 or 0.8.0-rc.0", [hex: :req, repo: "hexpm", optional: true]}], "hexpm", "d17914bf6394684096bf1939fdc6d5008ab06597616f694b4e10464868ba62b0"}, +} diff --git a/tsconfig.json b/tsconfig.json index 714d5c7de..67a09c012 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ } }, "exclude": [ + "popcorn/_build", + "popcorn/deps", + "popcorn/public", "dist", ".astro/collections", ".astro/fonts", From e3e0cb713285bf039754ee34572dbe1ffe13dac3 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Nov 2026 13:59:26 +0100 Subject: [PATCH 02/16] Enable cross-origin isolation when running examples --- package.json | 1 + public/coi-serviceworker.js | 6 ++++-- tests/coi-serviceworker.mjs | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/coi-serviceworker.mjs diff --git a/package.json b/package.json index d1e57ba3a..44fdd5b9b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "pnpm exec biome check", "typecheck": "astro check", "test": "playwright test", + "test:coi": "node --test tests/coi-serviceworker.mjs", "test:smoke": "playwright test tests/smoke.spec.ts", "test:visual": "playwright test tests/visual.spec.ts", "test:a11y": "playwright test tests/a11y.spec.ts", diff --git a/public/coi-serviceworker.js b/public/coi-serviceworker.js index 97aef7013..b754e023f 100644 --- a/public/coi-serviceworker.js +++ b/public/coi-serviceworker.js @@ -1,7 +1,9 @@ /*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */ let coepCredentialless = false; if (typeof window === "undefined") { - self.addEventListener("install", () => self.skipWaiting()); + self.addEventListener("install", (event) => + event.waitUntil(self.skipWaiting()), + ); self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()), ); @@ -60,7 +62,7 @@ if (typeof window === "undefined") { headers: newHeaders, }); }) - .catch((e) => console.error(e)), + .catch(() => Response.error()), ); }); } else { diff --git a/tests/coi-serviceworker.mjs b/tests/coi-serviceworker.mjs new file mode 100644 index 000000000..8f9c0f662 --- /dev/null +++ b/tests/coi-serviceworker.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +test("service-worker responses", async () => { + const listeners = new Map(); + const worker = { + self: { + addEventListener: (name, listener) => listeners.set(name, listener), + }, + fetch: () => Promise.reject(new TypeError("Network request failed")), + Response, + Headers, + Request, + console, + }; + runInNewContext(readFileSync("public/coi-serviceworker.js", "utf8"), worker); + + let response; + const event = { + request: new Request("https://example.test/asset"), + respondWith: (result) => { + response = result; + }, + }; + listeners.get("fetch")(event); + const failed = await response; + assert(failed instanceof Response); + assert.equal(failed.type, "error"); + + worker.fetch = () => Promise.resolve(new Response("asset")); + listeners.get("fetch")(event); + const served = await response; + assert.equal(await served.text(), "asset"); + assert.equal(served.headers.get("Cross-Origin-Opener-Policy"), "same-origin"); + assert.equal( + served.headers.get("Cross-Origin-Embedder-Policy"), + "require-corp", + ); +}); From b6b1072db0a4e666f4ffb2f337a92aa4b3242676 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Nov 2026 13:59:58 +0100 Subject: [PATCH 03/16] Extract example selection into an editor hook --- src/components/home/HeroCodeEditor.tsx | 18 +++++++----------- src/components/home/use-example-editor.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 src/components/home/use-example-editor.ts diff --git a/src/components/home/HeroCodeEditor.tsx b/src/components/home/HeroCodeEditor.tsx index 79840a6a8..4dca21661 100644 --- a/src/components/home/HeroCodeEditor.tsx +++ b/src/components/home/HeroCodeEditor.tsx @@ -1,5 +1,6 @@ -import { Fragment, useState } from "react"; +import { Fragment } from "react"; import { EXAMPLES } from "@/components/home/hero-examples"; +import { useExampleEditor } from "@/components/home/use-example-editor"; import type { HighlightedLine } from "@/utilities/elixir-highlight"; import { tokenStyle } from "@/utilities/highlight-style"; @@ -70,14 +71,9 @@ export default function HeroCodeEditor({ // Per example, per line: shiki tokens produced at build time. highlightedExamples: HighlightedLine[][]; }) { - const [idx, setIdx] = useState(0); - const example = EXAMPLES[idx]; + const { example, actions } = useExampleEditor(); + const { idx } = example; const highlightedLines = highlightedExamples[idx]; - const showExample = (nextRaw: number) => { - const next = - ((nextRaw % EXAMPLES.length) + EXAMPLES.length) % EXAMPLES.length; - setIdx(next); - }; return (
@@ -92,7 +88,7 @@ export default function HeroCodeEditor({
diff --git a/src/components/home/use-example-editor.ts b/src/components/home/use-example-editor.ts new file mode 100644 index 000000000..bece8993b --- /dev/null +++ b/src/components/home/use-example-editor.ts @@ -0,0 +1,18 @@ +import { useState } from "react"; +import { EXAMPLES } from "./hero-examples"; + +export function useExampleEditor() { + const [idx, setIdx] = useState(0); + const select = (next: number) => { + setIdx(((next % EXAMPLES.length) + EXAMPLES.length) % EXAMPLES.length); + }; + + return { + example: { ...EXAMPLES[idx], idx }, + actions: { + select, + previous: () => select(idx - 1), + next: () => select(idx + 1), + }, + }; +} From 085b8885c085aa14dda8b53e57e37d1a664bd2de Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Thu, 5 Nov 2026 14:00:07 +0100 Subject: [PATCH 04/16] Make homepage examples editable with lazy highlighting --- src/components/home/Hero.astro | 4 +- src/components/home/HeroCodeEditor.tsx | 27 +++------ src/components/home/HighlightedCode.tsx | 70 +++++++++++++++++++++++ src/components/home/use-example-editor.ts | 16 +++++- src/components/home/use-highlighter.ts | 40 +++++++++++++ src/styles/style.css | 7 +++ src/utilities/elixir-highlight.ts | 12 ++-- 7 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 src/components/home/HighlightedCode.tsx create mode 100644 src/components/home/use-highlighter.ts diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro index a0495df28..353d28dcc 100644 --- a/src/components/home/Hero.astro +++ b/src/components/home/Hero.astro @@ -8,9 +8,7 @@ import { highlightElixir, } from "@/utilities/elixir-highlight"; -// The editor island highlights the pristine examples from this -// build-time output; shiki is only loaded in the browser once the -// user edits the code. +// Highlight pristine examples at build time; editing uses a native textarea. const highlighter = await getElixirHighlighter(); const highlightedExamples = EXAMPLES.map((example) => highlightElixir(highlighter, example.code), diff --git a/src/components/home/HeroCodeEditor.tsx b/src/components/home/HeroCodeEditor.tsx index 4dca21661..02433d971 100644 --- a/src/components/home/HeroCodeEditor.tsx +++ b/src/components/home/HeroCodeEditor.tsx @@ -1,8 +1,7 @@ -import { Fragment } from "react"; import { EXAMPLES } from "@/components/home/hero-examples"; import { useExampleEditor } from "@/components/home/use-example-editor"; import type { HighlightedLine } from "@/utilities/elixir-highlight"; -import { tokenStyle } from "@/utilities/highlight-style"; +import HighlightedCode from "./HighlightedCode"; function ArrowIcon({ direction = "right" }: { direction?: "left" | "right" }) { return ( @@ -71,9 +70,8 @@ export default function HeroCodeEditor({ // Per example, per line: shiki tokens produced at build time. highlightedExamples: HighlightedLine[][]; }) { - const { example, actions } = useExampleEditor(); + const { example, runtime, actions } = useExampleEditor(); const { idx } = example; - const highlightedLines = highlightedExamples[idx]; return (
@@ -117,20 +115,13 @@ export default function HeroCodeEditor({ />
-
-            {highlightedLines.map((line, li) => (
-              // biome-ignore lint/suspicious/noArrayIndexKey: static highlighted lines have no stateful children
-              
-                {li > 0 && "\n"}
-                {line.map((token, ti) => (
-                  // biome-ignore lint/suspicious/noArrayIndexKey: see above
-                  
-                    {token.text}
-                  
-                ))}
-              
-            ))}
-          
+
diff --git a/src/components/home/HighlightedCode.tsx b/src/components/home/HighlightedCode.tsx new file mode 100644 index 000000000..d683ca416 --- /dev/null +++ b/src/components/home/HighlightedCode.tsx @@ -0,0 +1,70 @@ +import { Fragment, useMemo, useRef } from "react"; +import type { HighlightedLine } from "@/utilities/elixir-highlight"; +import { useHighlighter } from "./use-highlighter"; + +export default function HighlightedCode({ + source, + originalSource, + initialLines, + readOnly, + onChange, +}: { + source: string; + originalSource: string; + initialLines: HighlightedLine[]; + readOnly: boolean; + onChange: (source: string) => void; +}) { + const node = useRef(null); + const display = useRef(null); + const { highlight } = useHighlighter(node, source !== originalSource); + + const lines = useMemo(() => { + if (source === originalSource) return initialLines; + return highlight(source); + }, [highlight, source, originalSource, initialLines]); + + const typography = + "min-h-52 whitespace-pre-wrap break-words px-4 py-2 font-mono text-body font-light leading-code sm:py-3"; + + return ( +
+ +