From 29eb08de3762203abd950016cbae923622d6a0c5 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:49:51 +1200 Subject: [PATCH 1/3] Add an in-browser playground to the docs site --- pnpm-lock.yaml | 168 ++++++ website/app/(home)/playground/page.tsx | 53 ++ website/app/(home)/playground/versions.ts | 57 ++ website/app/global.css | 26 + website/components/code-editor.tsx | 141 +++++ website/components/playground.tsx | 600 ++++++++++++++++++++++ website/lib/layout.shared.tsx | 12 +- website/package.json | 9 + 8 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 website/app/(home)/playground/page.tsx create mode 100644 website/app/(home)/playground/versions.ts create mode 100644 website/components/code-editor.tsx create mode 100644 website/components/playground.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f7ffc9..0209553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -538,12 +538,36 @@ importers: website: dependencies: + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/language': + specifier: ^6.12.4 + version: 6.12.4 + '@codemirror/state': + specifier: ^6.7.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.43.7 + version: 6.43.7 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 + '@shikijs/langs': + specifier: 4.3.1 + version: 4.3.1 + '@shikijs/themes': + specifier: 4.3.1 + version: 4.3.1 '@takumi-rs/image-response': specifier: ^1.8.7 version: 1.8.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) beautiful-mermaid: specifier: ^1.1.3 version: 1.1.3 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 fumadocs-core: specifier: 16.8.5 version: 16.8.5(@mdx-js/mdx@3.1.1(supports-color@10.2.2))(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.8))(next@16.2.10(@babel/core@8.0.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(zod@4.4.3) @@ -568,6 +592,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + prettier: + specifier: ^3.9.6 + version: 3.9.6 react: specifier: ^19.2.8 version: 19.2.8 @@ -1158,6 +1185,30 @@ packages: '@cloudflare/workers-types@4.20260405.1': resolution: {integrity: sha512-PokTmySa+D6MY01R1UfYH48korsN462NK/fl3aw47Hg7XuLuSo/RTpjT0vtWaJhJoFY5tHGOBBIbDcIc8wltLg==} + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.7': + resolution: {integrity: sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==} + '@commander-js/extra-typings@15.0.0': resolution: {integrity: sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA==} peerDependencies: @@ -2118,12 +2169,27 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -4480,6 +4546,9 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -4552,6 +4621,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6994,6 +7066,9 @@ packages: structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -7479,6 +7554,9 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -8444,6 +8522,62 @@ snapshots: '@cloudflare/workers-types@4.20260405.1': optional: true + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.7': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@commander-js/extra-typings@15.0.0(commander@15.0.0)': dependencies: commander: 15.0.0 @@ -9354,6 +9488,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -9370,6 +9520,8 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@marijn/find-cluster-break@1.0.3': {} + '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': dependencies: '@types/estree': 1.0.9 @@ -11494,6 +11646,16 @@ snapshots: code-block-writer@13.0.3: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.7 + collapse-white-space@2.1.0: {} color-convert@1.9.3: @@ -11565,6 +11727,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crelt@1.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -14561,6 +14725,8 @@ snapshots: structured-headers@0.4.1: {} + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -14938,6 +15104,8 @@ snapshots: vlq@1.0.1: {} + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/website/app/(home)/playground/page.tsx b/website/app/(home)/playground/page.tsx new file mode 100644 index 0000000..992fc5c --- /dev/null +++ b/website/app/(home)/playground/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from 'next'; +import Playground from '@/components/playground'; +import { getVersions } from './versions'; + +export const metadata: Metadata = { + title: 'Playground', + description: + 'Write code and watch SayKit extract its messages and compile it, live in your browser.', +}; + +const DEFAULT_CODE = `import { say } from 'saykit'; +import { Say } from '@saykit/react'; + +const greeting = say\`Hello, $\{name}!\`; + +const farewell = say\`See you soon\`; + +export function Inbox({ count }: { count: number }) { + return ( +
+

+ Inbox +

+

+ +

+
+ ); +} +`; + +export default async function PlaygroundPage() { + const versions = await getVersions(); + + return ( + // Matches the nav bar's content width (fumadocs' HomeLayout sets the var to 1400px). +
+
+

Playground

+

+ Edit the code on the left to see the messages SayKit extracts and the code it compiles to. + Everything runs in your browser, so pick a published version to compare how it behaves. +

+
+ + +
+ ); +} diff --git a/website/app/(home)/playground/versions.ts b/website/app/(home)/playground/versions.ts new file mode 100644 index 0000000..c1ac159 --- /dev/null +++ b/website/app/(home)/playground/versions.ts @@ -0,0 +1,57 @@ +const PACKAGE = '@saykit/transform-jsx'; + +/** Versions below this had version numbers out of sync across the workspace. */ +const MINIMUM = [0, 5, 0]; + +export type VersionOption = { value: string; label: string }; + +/** Fallback so the playground still renders if the registry is unreachable. */ +const FALLBACK: VersionOption[] = [{ value: '0.5.0', label: '0.5.0 (latest)' }]; + +type Registry = { + 'dist-tags'?: Record; + versions?: Record; +}; + +const STABLE = /^(\d+)\.(\d+)\.(\d+)$/; + +function parse(version: string) { + const match = STABLE.exec(version); + return match ? match.slice(1, 4).map(Number) : null; +} + +function compare(a: number[], b: number[]) { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +export async function getVersions(): Promise { + let registry: Registry; + + try { + const response = await fetch(`https://registry.npmjs.org/${PACKAGE}`, { + next: { revalidate: 3600 }, + }); + if (!response.ok) return FALLBACK; + registry = (await response.json()) as Registry; + } catch { + return FALLBACK; + } + + const tags = registry['dist-tags'] ?? {}; + + const stable = Object.keys(registry.versions ?? {}) + .map((version) => ({ version, parsed: parse(version) })) + .filter((entry) => entry.parsed !== null && compare(entry.parsed, MINIMUM) >= 0) + .sort((a, b) => compare(b.parsed as number[], a.parsed as number[])) + .map(({ version }) => ({ + value: version, + label: version === tags.latest ? `${version} (latest)` : version, + })); + + // Beta releases are published as `0.0.0-beta-`, which sorts + // meaninglessly — surface only the one the beta tag currently points at. + const beta = tags.beta ? [{ value: tags.beta, label: `beta (${tags.beta})` }] : []; + + const options = [...beta, ...stable]; + return options.length > 0 ? options : FALLBACK; +} diff --git a/website/app/global.css b/website/app/global.css index 9c3c8d9..e0fd272 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -1,3 +1,29 @@ @import 'tailwindcss'; @import 'fumadocs-ui/css/ruby.css'; @import 'fumadocs-ui/css/preset.css'; + +/* + * Syntax colours for the playground's CodeMirror editor, mirroring the + * github-light / github-dark themes Shiki uses everywhere else on the site. + * They live here rather than in the editor's own theme because the `.dark` + * class sits on an ancestor element, outside CodeMirror's scoped styles. + */ +:root { + --pg-comment: #6e7781; + --pg-keyword: #cf222e; + --pg-string: #0a3069; + --pg-number: #0550ae; + --pg-function: #8250df; + --pg-property: #0550ae; + --pg-tag: #116329; +} + +.dark { + --pg-comment: #8b949e; + --pg-keyword: #ff7b72; + --pg-string: #a5d6ff; + --pg-number: #79c0ff; + --pg-function: #d2a8ff; + --pg-property: #79c0ff; + --pg-tag: #7ee787; +} diff --git a/website/components/code-editor.tsx b/website/components/code-editor.tsx new file mode 100644 index 0000000..e52bc70 --- /dev/null +++ b/website/components/code-editor.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { javascript } from '@codemirror/lang-javascript'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { tags } from '@lezer/highlight'; +import { basicSetup } from 'codemirror'; +import { useEffect, useRef } from 'react'; + +const highlightStyle = HighlightStyle.define([ + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: 'var(--pg-comment)' }, + { + tag: [tags.keyword, tags.controlKeyword, tags.moduleKeyword, tags.operatorKeyword, tags.self], + color: 'var(--pg-keyword)', + }, + { tag: [tags.string, tags.special(tags.string), tags.regexp], color: 'var(--pg-string)' }, + { tag: [tags.number, tags.bool, tags.null, tags.atom], color: 'var(--pg-number)' }, + { + tag: [tags.function(tags.variableName), tags.definition(tags.variableName), tags.className], + color: 'var(--pg-function)', + }, + { tag: [tags.propertyName, tags.attributeName], color: 'var(--pg-property)' }, + { tag: [tags.tagName, tags.angleBracket], color: 'var(--pg-tag)' }, +]); + +const theme = EditorView.theme({ + '&': { + height: '100%', + backgroundColor: 'transparent', + color: 'var(--color-fd-foreground)', + fontSize: '13px', + }, + '&.cm-focused': { outline: 'none' }, + '.cm-scroller': { + fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)', + lineHeight: '1.5rem', + // Thin, themed scrollbars flush to the pane edge. The padding that used to sit + // on the wrapper lives here instead, so the track isn't inset from the border. + scrollbarWidth: 'thin', + scrollbarColor: 'var(--color-fd-border) transparent', + }, + '.cm-scroller::-webkit-scrollbar': { width: '10px', height: '10px' }, + '.cm-scroller::-webkit-scrollbar-track': { background: 'transparent' }, + '.cm-scroller::-webkit-scrollbar-thumb': { + backgroundColor: 'var(--color-fd-border)', + borderRadius: '9999px', + // Inset the thumb without insetting the track. + border: '3px solid transparent', + backgroundClip: 'content-box', + }, + '.cm-scroller::-webkit-scrollbar-thumb:hover': { + backgroundColor: 'var(--color-fd-muted-foreground)', + backgroundClip: 'content-box', + }, + '.cm-scroller::-webkit-scrollbar-corner': { background: 'transparent' }, + // Only vertical padding out here. The horizontal padding goes on the line and + // gutter elements themselves, so the active-line highlight — which paints those + // elements' backgrounds — runs edge to edge instead of stopping short. + '.cm-content': { padding: '0.75rem 0' }, + '.cm-line': { padding: '0 0.75rem 0 0.5rem' }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--color-fd-muted-foreground)', + border: 'none', + }, + '.cm-lineNumbers .cm-gutterElement': { padding: '0 0.5rem 0 0.75rem' }, + '.cm-activeLine': { backgroundColor: 'var(--color-fd-accent)' }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--color-fd-accent)', + color: 'var(--color-fd-foreground)', + }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--color-fd-foreground)' }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': { + backgroundColor: 'var(--color-fd-primary)', + opacity: 0.25, + }, + '.cm-matchingBracket, &.cm-focused .cm-matchingBracket': { + backgroundColor: 'var(--color-fd-secondary)', + outline: '1px solid var(--color-fd-border)', + }, + '.cm-tooltip': { + backgroundColor: 'var(--color-fd-popover)', + border: '1px solid var(--color-fd-border)', + color: 'var(--color-fd-popover-foreground)', + }, +}); + +export default function CodeEditor({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const container = useRef(null); + const view = useRef(null); + // Kept in a ref so swapping the handler never tears down the editor. + const handler = useRef(onChange); + handler.current = onChange; + + useEffect(() => { + if (!container.current) return; + + const editor = new EditorView({ + parent: container.current, + state: EditorState.create({ + doc: value, + extensions: [ + basicSetup, + javascript({ jsx: true, typescript: true }), + syntaxHighlighting(highlightStyle), + theme, + EditorView.lineWrapping, + EditorView.updateListener.of((update) => { + if (update.docChanged) handler.current(update.state.doc.toString()); + }), + ], + }), + }); + + view.current = editor; + return () => { + editor.destroy(); + view.current = null; + }; + // Mount once — `value` is the initial document, later updates flow in below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Accept programmatic changes (e.g. loading an example) without fighting typing. + useEffect(() => { + const editor = view.current; + if (!editor || editor.state.doc.toString() === value) return; + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: value }, + }); + }, [value]); + + return
; +} diff --git a/website/components/playground.tsx b/website/components/playground.tsx new file mode 100644 index 0000000..ee982dd --- /dev/null +++ b/website/components/playground.tsx @@ -0,0 +1,600 @@ +'use client'; + +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; +import { AlertTriangle, Check, ChevronDown, Copy, Link2, Loader2, Wand2 } from 'lucide-react'; +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { VersionOption } from '@/app/(home)/playground/versions'; + +const CodeEditor = dynamic(() => import('@/components/code-editor'), { + ssr: false, + loading: () =>
, +}); + +/** Mirrors the `Message` shape from `@saykit/config`. */ +type Message = { + message: string; + id?: string; + context?: string; + comments: string[]; + references: string[]; +}; + +type Transformer = { + extract: (code: string, id: string) => Message[]; + transform: (code: string, id: string) => string; +}; + +/** The pieces of a given SayKit release the playground drives. */ +type Runtime = { + transformer: Transformer; + generateHash: (input: string, context?: string) => string; +}; + +type Result = { messages: Message[]; output: string; hashOf: Runtime['generateHash'] }; + +/** The JSX transformer also handles the plain-JS forms, so one `.tsx` id covers both. */ +const FILE_ID = 'playground.tsx'; + +const SHIKI_THEMES = { dark: 'github-dark', light: 'github-light' } as const; + +/* ------------------------------------------------------------------ runtime */ + +const runtimes = new Map>(); + +/** + * The workspace packages depend on each other by caret range, and esm.sh cannot + * resolve `^0.0.0-beta-` — the entry loads but its imports 404. Pinning + * the siblings to the exact version being loaded sidesteps that, and is a no-op + * for stable releases. + */ +function transformerUrl(version: string) { + const deps = [`@saykit/config@${version}`, `@saykit/transform-js@${version}`].join(','); + return `https://esm.sh/@saykit/transform-jsx@${version}?deps=${deps}`; +} + +function loadRuntime(version: string): Promise { + let pending = runtimes.get(version); + + if (!pending) { + pending = Promise.all([ + import(/* webpackIgnore: true */ transformerUrl(version)) as Promise<{ + default: () => Transformer; + }>, + // Same version as the transformer, so a displayed id always matches what + // that release would actually write into the catalogue. + import( + /* webpackIgnore: true */ `https://esm.sh/@saykit/config@${version}/features/messages` + ) as Promise<{ generateHash: Runtime['generateHash'] }>, + ]).then(([transform, messages]) => { + const transformer = transform.default(); + if (typeof transformer?.extract !== 'function' || typeof transformer.transform !== 'function') + throw new Error(`@saykit/transform-jsx@${version} does not expose a usable transformer.`); + return { transformer, generateHash: messages.generateHash }; + }); + + // Don't cache a rejection — a transient network failure should be retryable. + pending.catch(() => runtimes.delete(version)); + runtimes.set(version, pending); + } + + return pending; +} + +/* -------------------------------------------------------------- highlighting */ + +let highlighter: Promise<(code: string) => string> | null = null; + +function getHighlighter() { + highlighter ??= (async () => { + const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([ + import('shiki/core'), + import('shiki/engine/javascript'), + ]); + + const core = await createHighlighterCore({ + themes: [import('@shikijs/themes/github-dark'), import('@shikijs/themes/github-light')], + langs: [import('@shikijs/langs/tsx')], + engine: createJavaScriptRegexEngine(), + }); + + return (code: string) => + core.codeToHtml(code, { lang: 'tsx', themes: SHIKI_THEMES, defaultColor: false }); + })(); + + return highlighter; +} + +/* ------------------------------------------------------------------- sharing */ + +/** + * Share state lives in the fragment rather than the query string so the code + * being experimented with is never sent to the server or logged by it. + */ +function encodeState(state: { code: string; version: string }) { + const bytes = new TextEncoder().encode(JSON.stringify(state)); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function decodeState(fragment: string) { + try { + const padded = fragment.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + + if (typeof parsed !== 'object' || parsed === null) return null; + const { code, version } = parsed as { code?: unknown; version?: unknown }; + if (typeof code !== 'string') return null; + + return { code, version: typeof version === 'string' ? version : undefined }; + } catch { + return null; + } +} + +/* ----------------------------------------------------------------- formatting */ + +async function format(code: string) { + const [prettier, estree, typescript] = await Promise.all([ + import('prettier/standalone'), + import('prettier/plugins/estree'), + import('prettier/plugins/typescript'), + ]); + + return prettier.format(code, { + parser: 'typescript', + plugins: [estree.default ?? estree, typescript.default ?? typescript], + singleQuote: true, + printWidth: 90, + }); +} + +function messageOf(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +/* ---------------------------------------------------------------- playground */ + +export default function Playground({ + versions, + defaultCode, +}: { + versions: VersionOption[]; + defaultCode: string; +}) { + const [code, setCode] = useState(defaultCode); + const [version, setVersion] = useState(versions[0]?.value ?? '0.5.0'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(true); + + // Drop results from runs that were superseded while awaiting the CDN. + const run = useRef(0); + + // Applied after hydration so the server-rendered markup stays deterministic. + // Also on `hashchange`, since opening a share link while already on the page is + // a same-document navigation that never remounts this component. + useEffect(() => { + const apply = () => { + const shared = decodeState(window.location.hash.slice(1)); + if (!shared) return; + setCode(shared.code); + if (shared.version && versions.some((option) => option.value === shared.version)) + setVersion(shared.version); + }; + + apply(); + window.addEventListener('hashchange', apply); + return () => window.removeEventListener('hashchange', apply); + }, [versions]); + + useEffect(() => { + const current = ++run.current; + setPending(true); + + const timer = setTimeout(async () => { + try { + const { transformer, generateHash } = await loadRuntime(version); + if (run.current !== current) return; + + const next = { + messages: transformer.extract(code, FILE_ID), + output: transformer.transform(code, FILE_ID), + hashOf: generateHash, + }; + + if (run.current !== current) return; + setResult(next); + setError(null); + } catch (cause) { + if (run.current === current) setError(messageOf(cause)); + } finally { + if (run.current === current) setPending(false); + } + }, 300); + + return () => clearTimeout(timer); + }, [code, version]); + + const onFormat = useCallback(async () => { + try { + setCode(await format(code)); + } catch (cause) { + setError(messageOf(cause)); + } + }, [code]); + + const shareUrl = useCallback( + () => `${window.location.origin}${window.location.pathname}#${encodeState({ code, version })}`, + [code, version], + ); + + return ( +
+
+ + +
+ {pending && ( + + Running + + )} + +
+
+ +
+
+ {/* Formatting only ever applies to the input, so it lives with the editor + rather than in the toolbar shared with the generated output. */} + } + action={ +
+ + {/* Copy rightmost in both panes, so it lands in the same place. */} + +
+ } + /> + {/* No padding here — CodeMirror applies its own inside the scroller, so + the scrollbar can sit flush against the pane edge. */} +
+ +
+
+ +
+
+ + {result.messages.length} + + ) + } + /> +
+ +
+
+ +
+ {/* Copy sits in the header like Format, so the pane needs no title bar + of its own — the filename was never meaningful here anyway. */} + : undefined} + /> + +
+
+
+
+ ); +} + +/* -------------------------------------------------------------------- pieces */ + +/** + * Fixed height so a pane carrying an action button still lines up with the panes + * beside it — the button is taller than the heading text on its own. The height + * also means the `status` slot can come and go without moving anything. + */ +function PaneHeader({ + title, + status, + action, +}: { + title: string; + status?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {status} + {action} +
+ ); +} + +function VersionSelect({ + versions, + value, + onChange, +}: { + versions: VersionOption[]; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+ +
+ + +
+
+ ); +} + +/** The quiet variant used inside a pane header, next to the pane's title. */ +function PaneAction({ + icon: Icon, + label, + onClick, +}: { + icon: React.ElementType; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function CopyAction({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + return ( + void navigator.clipboard.writeText(value).then(() => setCopied(true))} + /> + ); +} + +function Action({ + icon: Icon, + label, + onClick, +}: { + icon: React.ElementType; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ShareButton({ url }: { url: () => string }) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + return ( + { + const link = url(); + // Keep the address bar in sync so a manual copy shares the same state. + window.history.replaceState(null, '', link); + void navigator.clipboard.writeText(link).then(() => setCopied(true)); + }} + /> + ); +} + +/** + * Sits in the pane header rather than over the editor: the header is always in + * view and already a fixed height, so a syntax error that comes and goes while + * typing neither reflows the page nor hides the code being written. Babel puts + * the useful part ("Unterminated template. (2:14)") first, so one truncated line + * carries it, with the rest on hover. + */ +function ErrorNotice({ message }: { message: string }) { + return ( +

+ + {message.split('\n')[0]} +

+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +/** + * A list rather than a table. In a half-width pane five columns leave the message + * — the thing you actually came to read — fighting its own metadata for space, and + * every id, context or comment added makes it narrower. Giving the message its own + * full-width line and demoting the metadata to a line beneath keeps the message at + * 100% no matter how much metadata a message carries. + */ +function MessageList({ + messages, + hashOf, +}: { + messages?: Message[]; + hashOf?: Runtime['generateHash']; +}) { + if (!messages) return Waiting for output…; + + if (messages.length === 0) + return ( + + No messages found. Try a say tagged template or a{' '} + {''} element. + + ); + + return ( +
    + {messages.map((message, index) => { + // An explicit id wins; otherwise show the hash the catalogue would key on. + const id = message.id ?? hashOf?.(message.message, message.context); + + return ( +
  • +

    + {message.message} +

    + + {message.comments.length > 0 && ( +

    + {message.comments.join('\n')} +

    + )} + +
    + {id && ( + + {id} + + )} + {message.context && {message.context}} + {message.references.length > 0 && ( + + {message.references.join(', ')} + + )} +
    +
  • + ); + })} +
+ ); +} + +function Meta({ + label, + children, + mono, + title, +}: { + label: string; + children: React.ReactNode; + mono?: boolean; + title?: string; +}) { + return ( +
+
{label}
+
+ {children} +
+
+ ); +} + +function Highlighted({ code }: { code?: string }) { + const [html, setHtml] = useState(null); + + useEffect(() => { + if (code === undefined) return; + + let active = true; + getHighlighter() + .then((highlight) => { + if (active) setHtml(highlight(code)); + }) + .catch(() => { + if (active) setHtml(null); + }); + + return () => { + active = false; + }; + }, [code]); + + if (code === undefined) + return ( +
+ Waiting for output… +
+ ); + + // Copying is handled by the pane header, so no title bar and no floating button. + return ( + + {html ? ( +
+      ) : (
+        
+          {code}
+        
+ )} + + ); +} diff --git a/website/lib/layout.shared.tsx b/website/lib/layout.shared.tsx index 3d1e255..b5bddf2 100644 --- a/website/lib/layout.shared.tsx +++ b/website/lib/layout.shared.tsx @@ -1,5 +1,5 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import { MessageCircleMore } from 'lucide-react'; +import { BookOpen, FlaskConical, MessageCircleMore } from 'lucide-react'; // fill this with your actual GitHub info, for example: export const gitConfig = { @@ -15,6 +15,16 @@ export function baseOptions(): BaseLayoutProps { }, githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`, links: [ + { + text: 'Documentation', + url: '/getting-started/introduction', + icon: , + }, + { + text: 'Playground', + url: '/playground', + icon: , + }, { text: 'Community', url: 'https://discord.gg/bqgAj65Em5', diff --git a/website/package.json b/website/package.json index 55f653a..4898ed4 100644 --- a/website/package.json +++ b/website/package.json @@ -10,8 +10,16 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.7", + "@lezer/highlight": "^1.2.3", + "@shikijs/langs": "4.3.1", + "@shikijs/themes": "4.3.1", "@takumi-rs/image-response": "^1.8.7", "beautiful-mermaid": "^1.1.3", + "codemirror": "^6.0.2", "fumadocs-core": "16.8.5", "fumadocs-mdx": "14.3.2", "fumadocs-typescript": "^5.3.0", @@ -20,6 +28,7 @@ "mermaid": "^11.16.0", "next": "16.2.10", "next-themes": "^0.4.6", + "prettier": "^3.9.6", "react": "^19.2.8", "react-dom": "^19.2.8", "shiki": "^4.3.1", From 2f4ba3e1d9a9d22e12557c5ab5535114c54bfce0 Mon Sep 17 00:00:00 2001 From: k0d13 <40654585+k0d13@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:54:54 +1200 Subject: [PATCH 2/3] Keep the playground toolbar readable on narrow screens --- website/app/(home)/playground/versions.ts | 11 ++++++++++- website/components/playground.tsx | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/website/app/(home)/playground/versions.ts b/website/app/(home)/playground/versions.ts index c1ac159..e5c6830 100644 --- a/website/app/(home)/playground/versions.ts +++ b/website/app/(home)/playground/versions.ts @@ -24,6 +24,15 @@ function compare(a: number[], b: number[]) { return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; } +/** + * `0.0.0-beta-20260729113209` is a lot of characters that say very little, and it + * sets the width of the whole select. The publish date is the part anyone reads. + */ +function betaLabel(version: string) { + const match = /^\d+\.\d+\.\d+-beta-(\d{4})(\d{2})(\d{2})/.exec(version); + return match ? `beta (${match[1]}-${match[2]}-${match[3]})` : 'beta'; +} + export async function getVersions(): Promise { let registry: Registry; @@ -50,7 +59,7 @@ export async function getVersions(): Promise { // Beta releases are published as `0.0.0-beta-`, which sorts // meaninglessly — surface only the one the beta tag currently points at. - const beta = tags.beta ? [{ value: tags.beta, label: `beta (${tags.beta})` }] : []; + const beta = tags.beta ? [{ value: tags.beta, label: betaLabel(tags.beta) }] : []; const options = [...beta, ...stable]; return options.length > 0 ? options : FALLBACK; diff --git a/website/components/playground.tsx b/website/components/playground.tsx index ee982dd..615cdc9 100644 --- a/website/components/playground.tsx +++ b/website/components/playground.tsx @@ -237,10 +237,12 @@ export default function Playground({
-
+
{pending && ( - Running + + {/* The spinner alone carries the meaning when space is tight. */} + Running )} @@ -338,16 +340,19 @@ function VersionSelect({ onChange: (value: string) => void; }) { return ( -
-