diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fa14dd80..7b707d96d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,24 @@ 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 pnpm typecheck pnpm build + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Test homepage examples on a static server + run: pnpm test:popcorn 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/AGENTS.md b/AGENTS.md index 1f3f55c5c..db673b8bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,18 +16,8 @@ fixes; use `pnpm lint` for the equivalent read-only check. ## Deployment -- GitHub Pages: see `BUNDLE_AVM.md`, `.github/workflows/deploy.yml`, and the - `coi-serviceworker` setup. - -## Popcorn (homepage hero editor) - -- AVM bundle lives at `public/bundle.avm` (~7 MB, committed). Rebuild recipe - in `BUNDLE_AVM.md`. -- Vite plugin is conditionally registered in `astro.config.ts` based on the - bundle's presence so dev setups without it still build. -- The runtime requires `Cross-Origin-Opener-Policy: same-origin` + - `Cross-Origin-Embedder-Policy: require-corp` headers. On hosts that can't - set them, the `coi-serviceworker` workaround is used. +- GitHub Pages and Popcorn setup: see `README.md` and + `.github/workflows/deploy.yml`. ## Reveal animation system 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..d012b84c8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Static Astro site, content authored in Markdown, deployed with GitHub Pages. learning resources and version data - Shiki for code highlighting: prose via the markdown pipeline, the homepage snippets at build time (`src/utilities/elixir-highlight.ts`) -- [Popcorn](https://github.com/software-mansion/popcorn) (AtomVM + Elixir +- [Popcorn](https://github.com/software-mansion/popcorn) (Erlang/OTP + Elixir in WebAssembly) powering the interactive hero code editor - Biome for lint and format - Playwright for smoke, visual and accessibility tests @@ -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` | 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,12 @@ 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 Popcorn's generated runtime assets. +The runtime requires cross-origin isolation. On GitHub Pages, the first Run +registers `public/coi-serviceworker.js` and reloads once, preserving edits and +the requested execution. The worker persists across visits and requires +HTTPS (or localhost). Hosts that set `Cross-Origin-Opener-Policy: same-origin` +and `Cross-Origin-Embedder-Policy: require-corp` need no worker or reload. 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..77cebe83c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "pnpm exec biome check", "typecheck": "astro check", "test": "playwright test", + "test:popcorn": "playwright test --config playwright.popcorn.config.ts", "test:smoke": "playwright test tests/smoke.spec.ts", "test:visual": "playwright test tests/visual.spec.ts", "test:a11y": "playwright test tests/a11y.spec.ts", @@ -27,6 +28,7 @@ "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", + "@swmansion/popcorn": "1.0.2", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.1.14", "astro": "5.16.13", diff --git a/playwright.popcorn.config.ts b/playwright.popcorn.config.ts new file mode 100644 index 000000000..cc35e542a --- /dev/null +++ b/playwright.popcorn.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; +import config from "./playwright.config"; + +const baseURL = process.env.BASE_URL || "http://127.0.0.1:4322"; + +export default defineConfig({ + ...config, + testMatch: "popcorn.spec.ts", + workers: 2, + use: { ...config.use, baseURL }, + projects: config.projects?.filter((project) => + ["chromium-desktop", "mobile-s"].includes(project.name ?? ""), + ), + webServer: process.env.BASE_URL + ? undefined + : { + command: + "python3 -m http.server 4322 --bind 127.0.0.1 --directory dist", + url: baseURL, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f368315c1..4ecbe58f4 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.2 + version: 1.0.2(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.2': + resolution: {integrity: sha512-AtXbJ0w72/SDCCXjkUoe9mGslQhyUwm9rGyjdU+InCWYSuwapf3a8p4JTL4T0HHcg+rSVzQ6dQNpRIhDPyvtrw==} + 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.2(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..a48bcfe00 --- /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, 10_000) || Task.shutdown(task, :brutal_kill) do + {:ok, result} -> result + nil -> %{error: "Execution timed out after 10 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/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/src/assets/icons/popcorn.svg b/src/assets/icons/popcorn.svg new file mode 100644 index 000000000..0c1c673dd --- /dev/null +++ b/src/assets/icons/popcorn.svg @@ -0,0 +1,8 @@ + + + + + + + + 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 79840a6a8..452740c73 100644 --- a/src/components/home/HeroCodeEditor.tsx +++ b/src/components/home/HeroCodeEditor.tsx @@ -1,7 +1,9 @@ -import { Fragment, useState } from "react"; +import popcornLogo from "@/assets/icons/popcorn.svg?url"; import { EXAMPLES } from "@/components/home/hero-examples"; +import { useExampleEditor } from "@/components/home/use-example-editor"; +import { useResultAnimation } from "@/components/home/use-result-animation"; 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 ( @@ -25,15 +27,13 @@ function ArrowIcon({ direction = "right" }: { direction?: "left" | "right" }) { } function ExampleDots({ - count, activeIdx, onSelect, - titles, + disabled, }: { - count: number; activeIdx: number; onSelect: (i: number) => void; - titles: string[]; + disabled: boolean; }) { return (
- {titles.slice(0, count).map((title, i) => { + {EXAMPLES.map(({ title }, i) => { const active = i === activeIdx; + let background = "bg-gray-60 hover:bg-gray-40"; + if (active) { + background = "bg-white-10"; + } return ( + + ); + } + + return ( +
+ {working && !hasError && ( +
+ ); +} + +function ExampleOutput({ + example, + runtimeError, +}: { + example: ReturnType["example"]; + runtimeError: string; +}) { + const { idx, source, output } = example; + const preview = output.source !== source; + const hasError = (output.error ?? "") !== "" && runtimeError === ""; + const hasValue = (output.value ?? "") !== ""; + const hasStdout = (output.stdout ?? "") !== ""; + let previewBorder = "border-transparent"; + if (preview) { + previewBorder = "border-dashed border-gray-60"; + } + const resultColor = preview ? "text-gray-20" : "text-purple-40"; + let runKey = null; + if (hasValue && !preview && output.runId !== undefined) { + runKey = `${idx}-${output.runId}`; + } + const arrow = useResultAnimation(runKey); + + return ( +
+

Output

+
+ {preview && ( + + Preview output. Run the code to update it. + + )} + {hasError && ( +
+            {output.error}
+          
+ )} + {hasValue && ( +
+ +
+              {output.value}
+            
+
+ )} +
+ {hasStdout && ( +
+
+

+ Standard output +

+
+
+            {output.stdout}
+          
+
+ )} +
+ ); +} + export default function HeroCodeEditor({ highlightedExamples, }: { - // Per example, per line: shiki tokens produced at build time. highlightedExamples: HighlightedLine[][]; }) { - const [idx, setIdx] = useState(0); - const example = EXAMPLES[idx]; - const highlightedLines = highlightedExamples[idx]; - const showExample = (nextRaw: number) => { - const next = - ((nextRaw % EXAMPLES.length) + EXAMPLES.length) % EXAMPLES.length; - setIdx(next); - }; + const { example, runtime, actions } = useExampleEditor(); return ( -
- {/* Title + prev/next row (above the editor frame) */} -
+
+

{example.title}

-
+
- {/* Editor frame */} -
-
-
+
+
+
e.title)} + activeIdx={example.idx} + onSelect={actions.select} + disabled={runtime.disabled} /> +
-
-            {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}
-                  
-                ))}
-              
-            ))}
-          
+ +
-
-

- Output -

-
- {example.precanned.stdout && ( -
-              {example.precanned.stdout}
-            
- )} - {example.precanned.value && ( -
- -
-                {example.precanned.value}
-              
-
- )} -
-
+
); } diff --git a/src/components/home/HighlightedCode.tsx b/src/components/home/HighlightedCode.tsx new file mode 100644 index 000000000..0883495c0 --- /dev/null +++ b/src/components/home/HighlightedCode.tsx @@ -0,0 +1,79 @@ +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, + onRun, +}: { + source: string; + originalSource: string; + initialLines: HighlightedLine[]; + readOnly: boolean; + onChange: (source: string) => void; + onRun: () => Promise; +}) { + 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 ( +
+ +