From 22148e8b5f7128fd4223eece479d048beaaabceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 11:00:28 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20=EC=9B=8C=ED=81=AC=EB=B2=A4=EC=B9=98=EC=97=90=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=86=8C=EC=8A=A4=20=ED=83=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 8 +- .../shared/demo-workbench/DemoWorkbench.tsx | 82 +++++++++++++ .../src/shared/demo-workbench/demo-sources.ts | 109 ++++++++++++++++++ site/tests/browser/demo-workbench.spec.ts | 35 ++++++ site/tests/unit/demo-workbench.test.tsx | 85 ++++++++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 site/src/shared/demo-workbench/DemoWorkbench.tsx create mode 100644 site/src/shared/demo-workbench/demo-sources.ts create mode 100644 site/tests/browser/demo-workbench.spec.ts create mode 100644 site/tests/unit/demo-workbench.test.tsx diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index e5939d0d..3124ea74 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -1,4 +1,6 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { DemoWorkbench } from "../../shared/demo-workbench/DemoWorkbench"; +import { demoSources } from "../../shared/demo-workbench/demo-sources"; import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives"; import { SiteBreadcrumb } from "../breadcrumb"; import { findSiteRoute, siteRoutes, usePathname } from "../router"; @@ -9,10 +11,14 @@ export const Route = createFileRoute("/_page")({ function InteriorPage() { const route = findSiteRoute(usePathname()); + const sources = demoSources(route.path); + const content = ; return ( }> - + {sources === undefined ? content : ( + {content} + )} ); diff --git a/site/src/shared/demo-workbench/DemoWorkbench.tsx b/site/src/shared/demo-workbench/DemoWorkbench.tsx new file mode 100644 index 00000000..4720f19a --- /dev/null +++ b/site/src/shared/demo-workbench/DemoWorkbench.tsx @@ -0,0 +1,82 @@ +import { useId, useState, type KeyboardEvent, type ReactNode } from "react"; +import { CodeBlock } from "../ui/code-block"; +import { classes, ui } from "../ui/styles"; +import type { DemoSourceFile } from "./demo-sources"; + +type WorkbenchTab = "demo" | number; + +export function DemoWorkbench(props: { + readonly children: ReactNode; + readonly sources: ReadonlyArray; +}) { + const [activeTab, setActiveTab] = useState("demo"); + const id = useId(); + const tabs: ReadonlyArray<{ readonly key: WorkbenchTab; readonly label: string }> = [ + { key: "demo", label: "Demo" }, + ...props.sources.map((source, index) => ({ key: index, label: source.path.split("/").at(-1) ?? source.path })), + ]; + const activeSourceIndex = typeof activeTab === "number" ? activeTab : undefined; + const activeSource = activeSourceIndex === undefined ? undefined : props.sources[activeSourceIndex]; + + function selectNeighbor(event: KeyboardEvent, index: number) { + const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (direction === 0) return; + event.preventDefault(); + const nextIndex = (index + direction + tabs.length) % tabs.length; + setActiveTab(tabs[nextIndex]!.key); + event.currentTarget.parentElement?.querySelectorAll("[role=tab]")[nextIndex]?.focus(); + } + + return ( +
+
+ {tabs.map((tab, index) => { + const selected = tab.key === activeTab; + return ( + + ); + })} +
+ + + {activeSourceIndex === undefined || activeSource === undefined ? null : ( +
+
+

{activeSource.path}

+ +
+
+ )} +
+ ); +} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts new file mode 100644 index 00000000..c11f491c --- /dev/null +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -0,0 +1,109 @@ +import type { CodeLanguage } from "../ui/code-tokens"; + +export type DemoSourceFile = { + readonly path: string; + readonly language: CodeLanguage; + readonly source: string; +}; + +const sourceModules = import.meta.glob( + [ + "/src/routes/**/*.{ts,tsx}", + "/src/shared/**/*.{ts,tsx}", + "!/src/shared/ui/**", + "!/src/shared/demo-workbench/**", + ], + { eager: true, import: "default", query: "?raw" }, +); + +const demoSourceEntries: Readonly> = { + "/demo": "routes/document-demo/DocumentDemoRoute.tsx", + "/demo/order": "routes/order-demo/OrderDemoRoute.tsx", + "/demo/object": "routes/object-demo/ObjectDemoRoute.tsx", + "/demo/canvas": "routes/canvas-demo/CanvasDemoRoute.tsx", + "/demo/sheet": "routes/sheet-demo/SheetDemoRoute.tsx", + "/demo/database": "routes/database-demo/DatabaseDemoRoute.tsx", + "/demo/tree": "routes/tree-demo/TreeDemoRoute.tsx", + "/demo/kanban": "routes/kanban-demo/KanbanDemoRoute.tsx", + "/demo/topology": "routes/editing-demos/TopologyDemoRoute.tsx", + "/demo/selection": "routes/editing-demos/SelectionDemoRoute.tsx", + "/demo/clipboard": "routes/editing-demos/ClipboardDemoRoute.tsx", + "/demo/history": "routes/editing-demos/HistoryDemoRoute.tsx", + "/editing/rich-text": "routes/rich-text-demo/RichTextDemoRoute.tsx", + "/widgets/listbox": "routes/widgets/ListboxWidgetRoute.tsx", + "/widgets/grid": "routes/widgets/GridWidgetRoute.tsx", + "/widgets/toolbar": "routes/widgets/ToolbarWidgetRoute.tsx", + "/adapters/clipboard": "routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx", + "/adapters/contenteditable": "routes/adapters/contenteditable/ContentEditableAdapterDemoRoute.tsx", + "/adapters/keyboard": "routes/adapters/keyboard/KeyboardAdapterDemoRoute.tsx", + "/connectors/react": "routes/connectors/react/ReactConnectorDemoRoute.tsx", + "/connectors/react-hook-form": "routes/connectors/react-hook-form/ReactHookFormConnectorDemoRoute.tsx", + "/connectors/tanstack-table": "routes/connectors/tanstack-table/TanStackTableConnectorDemoRoute.tsx", + "/connectors/ajv": "routes/connectors/ajv/AjvConnectorDemoRoute.tsx", + "/connectors/zod": "routes/connectors/zod/ZodConnectorDemoRoute.tsx", + "/connectors/zod/validate": "routes/connectors/zod/ZodValidateDemoRoute.tsx", +}; + +const chromeFiles = new Set([ + "routes/connectors/ConnectorDemoPage.tsx", + "routes/widgets/WidgetDemoFrame.tsx", +]); + +export function demoSources(pathname: string): ReadonlyArray | undefined { + const entry = demoSourceEntries[pathname]; + if (entry === undefined) return undefined; + return sourceClosure(entry).map((path) => ({ + path, + language: path.endsWith(".tsx") ? "tsx" : "typescript", + source: source(path), + })); +} + +function sourceClosure(entry: string): ReadonlyArray { + const paths: string[] = []; + const visited = new Set(); + function visit(path: string) { + if (visited.has(path) || isChrome(path)) return; + visited.add(path); + paths.push(path); + for (const specifier of relativeSpecifiers(source(path))) { + const resolved = resolveSource(path, specifier); + if (resolved !== undefined) visit(resolved); + } + } + visit(entry); + return paths; +} + +function isChrome(path: string): boolean { + return path.startsWith("app/") + || path.startsWith("shared/ui/") + || path.startsWith("shared/demo-workbench/") + || chromeFiles.has(path); +} + +function source(path: string): string { + const value = sourceModules[`/src/${path}`]; + if (value === undefined) throw new Error(`Unknown demo source: ${path}`); + return value; +} + +function relativeSpecifiers(value: string): ReadonlyArray { + const specifiers: string[] = []; + const pattern = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?["'](\.[^"']+)["']/g; + for (const match of value.matchAll(pattern)) specifiers.push(match[1]!); + return specifiers; +} + +function resolveSource(importer: string, specifier: string): string | undefined { + const parts = importer.split("/"); + parts.pop(); + for (const part of specifier.split("/")) { + if (part === "." || part === "") continue; + if (part === "..") parts.pop(); + else parts.push(part); + } + const base = parts.join("/"); + return [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`] + .find((candidate) => sourceModules[`/src/${candidate}`] !== undefined); +} diff --git a/site/tests/browser/demo-workbench.spec.ts b/site/tests/browser/demo-workbench.spec.ts new file mode 100644 index 00000000..39d46e70 --- /dev/null +++ b/site/tests/browser/demo-workbench.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test("switches between the live demo and its actual full source without resetting demo state", async ({ page }) => { + await page.goto("/demo"); + + const workbench = page.getByRole("region", { name: "Demo workbench" }); + const tabs = workbench.getByRole("tab"); + await expect(tabs).toHaveCount(2); + await expect(tabs.nth(0)).toHaveText("Demo"); + await expect(tabs.nth(1)).toHaveText("DocumentDemoRoute.tsx"); + + await page.getByRole("button", { name: "Select block 1" }).click(); + await expect(page.locator('article[data-block-id="welcome"]')).toHaveAttribute("data-selected", "true"); + + await tabs.nth(1).click(); + await expect(workbench.getByText("routes/document-demo/DocumentDemoRoute.tsx")).toBeVisible(); + const source = workbench.getByRole("tabpanel").locator("pre"); + await expect(source).toContainText("export function DocumentDemoRoute()"); + await expect(source).toContainText('from "@interactive-os/json-document-react"'); + + await tabs.nth(0).click(); + await expect(page.locator('article[data-block-id="welcome"]')).toHaveAttribute("data-selected", "true"); +}); + +test("shows every demo-owned database file as a source tab", async ({ page }) => { + await page.goto("/demo/database"); + + const tablist = page.getByRole("tablist", { name: "Demo and source files" }); + await expect(tablist.getByRole("tab")).toHaveText([ + "Demo", + "DatabaseDemoRoute.tsx", + "DatabaseTableDemo.tsx", + "initial-database.ts", + ]); +}); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx new file mode 100644 index 00000000..e28513b9 --- /dev/null +++ b/site/tests/unit/demo-workbench.test.tsx @@ -0,0 +1,85 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { DemoWorkbench } from "../../src/shared/demo-workbench/DemoWorkbench"; +import { demoSources } from "../../src/shared/demo-workbench/demo-sources"; + +afterEach(cleanup); + +describe("DemoWorkbench", () => { + const sources = [ + { + path: "routes/example/ExampleDemoRoute.tsx", + language: "tsx" as const, + source: "export function ExampleDemoRoute() {\n return
;\n}", + }, + { + path: "routes/example/fixture.ts", + language: "typescript" as const, + source: "export const fixture = { ready: true };", + }, + ]; + + test("keeps Demo first and switches between the live demo and full source files", () => { + render(); + + const tablist = screen.getByRole("tablist", { name: "Demo and source files" }); + const tabs = within(tablist).getAllByRole("tab"); + expect(tabs.map((tab) => tab.textContent)).toEqual(["Demo", "ExampleDemoRoute.tsx", "fixture.ts"]); + expect(tabs[0]!.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("button", { name: "Run demo" })).toBeTruthy(); + + fireEvent.click(tabs[1]!); + + expect(screen.queryByRole("button", { name: "Run demo" })).toBeNull(); + expect(screen.getByText("routes/example/ExampleDemoRoute.tsx")).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).toContain(sources[0]!.source); + }); + + test("moves across tabs with editor-style arrow navigation", () => { + render(
Demo
); + const tabs = screen.getAllByRole("tab"); + + tabs[0]!.focus(); + fireEvent.keyDown(tabs[0]!, { key: "ArrowRight" }); + expect(tabs[1]!.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tabs[1]); + + fireEvent.keyDown(tabs[1]!, { key: "ArrowLeft" }); + expect(tabs[0]!.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tabs[0]); + }); +}); + +describe("demoSources", () => { + test("loads actual full demo files without following package or site chrome imports", () => { + const document = demoSources("/demo")!; + expect(document.map((file) => file.path)).toEqual(["routes/document-demo/DocumentDemoRoute.tsx"]); + expect(document[0]!.source).toContain("export function DocumentDemoRoute()"); + expect(document[0]!.source).toContain('from "@interactive-os/json-document-react"'); + expect(document.some((file) => file.path.includes("packages/"))).toBe(false); + expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); + }); + + test("includes demo-owned helpers and shared behavioral glue", () => { + expect(demoSources("/demo/database")!.map((file) => file.path)).toEqual([ + "routes/database-demo/DatabaseDemoRoute.tsx", + "routes/database-demo/DatabaseTableDemo.tsx", + "routes/database-demo/initial-database.ts", + ]); + expect(demoSources("/widgets/listbox")!.map((file) => file.path)).toContain( + "routes/widgets/binding/order.ts", + ); + }); + + test("covers every public interactive demo route", () => { + const paths = [ + "/demo", "/demo/order", "/demo/object", "/demo/canvas", "/demo/sheet", "/demo/database", + "/demo/tree", "/demo/kanban", "/demo/topology", "/demo/selection", "/demo/clipboard", "/demo/history", + "/editing/rich-text", "/widgets/listbox", "/widgets/grid", "/widgets/toolbar", + "/adapters/clipboard", "/adapters/contenteditable", "/adapters/keyboard", + "/connectors/react", "/connectors/react-hook-form", "/connectors/tanstack-table", "/connectors/ajv", + "/connectors/zod", "/connectors/zod/validate", + ]; + for (const path of paths) expect(demoSources(path), path).not.toBeUndefined(); + }); +}); From 670b9c4776cb72db182000ce89a77850152397e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 11:09:10 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=BD=EB=A1=9C=EB=B3=84=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 21 +++++++++++-- .../src/shared/demo-workbench/demo-sources.ts | 31 ++++++++++--------- site/tests/unit/demo-workbench.test.tsx | 16 +++++----- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index 3124ea74..74a0304f 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from "react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { DemoWorkbench } from "../../shared/demo-workbench/DemoWorkbench"; -import { demoSources } from "../../shared/demo-workbench/demo-sources"; +import { loadDemoSources, type DemoSourceFile } from "../../shared/demo-workbench/demo-sources"; import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives"; import { SiteBreadcrumb } from "../breadcrumb"; import { findSiteRoute, siteRoutes, usePathname } from "../router"; @@ -11,7 +12,8 @@ export const Route = createFileRoute("/_page")({ function InteriorPage() { const route = findSiteRoute(usePathname()); - const sources = demoSources(route.path); + const loaded = useDemoSources(route.path); + const sources = loaded?.path === route.path ? loaded.sources : undefined; const content = ; return ( }> @@ -23,3 +25,18 @@ function InteriorPage() { ); } + +function useDemoSources(path: string) { + const [loaded, setLoaded] = useState<{ + readonly path: string; + readonly sources: ReadonlyArray; + }>(); + useEffect(() => { + let current = true; + void loadDemoSources(path).then((sources) => { + if (current && sources !== undefined) setLoaded({ path, sources }); + }); + return () => { current = false; }; + }, [path]); + return loaded; +} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index c11f491c..43aa8fe2 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -6,14 +6,14 @@ export type DemoSourceFile = { readonly source: string; }; -const sourceModules = import.meta.glob( +const sourceModules = import.meta.glob Promise>( [ "/src/routes/**/*.{ts,tsx}", "/src/shared/**/*.{ts,tsx}", "!/src/shared/ui/**", "!/src/shared/demo-workbench/**", ], - { eager: true, import: "default", query: "?raw" }, + { import: "default", query: "?raw" }, ); const demoSourceEntries: Readonly> = { @@ -49,29 +49,30 @@ const chromeFiles = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); -export function demoSources(pathname: string): ReadonlyArray | undefined { +export async function loadDemoSources(pathname: string): Promise | undefined> { const entry = demoSourceEntries[pathname]; if (entry === undefined) return undefined; - return sourceClosure(entry).map((path) => ({ + const paths = await sourceClosure(entry); + return Promise.all(paths.map(async (path) => ({ path, language: path.endsWith(".tsx") ? "tsx" : "typescript", - source: source(path), - })); + source: await source(path), + } as const))); } -function sourceClosure(entry: string): ReadonlyArray { +async function sourceClosure(entry: string): Promise> { const paths: string[] = []; const visited = new Set(); - function visit(path: string) { + async function visit(path: string): Promise { if (visited.has(path) || isChrome(path)) return; visited.add(path); paths.push(path); - for (const specifier of relativeSpecifiers(source(path))) { + for (const specifier of relativeSpecifiers(await source(path))) { const resolved = resolveSource(path, specifier); - if (resolved !== undefined) visit(resolved); + if (resolved !== undefined) await visit(resolved); } } - visit(entry); + await visit(entry); return paths; } @@ -82,10 +83,10 @@ function isChrome(path: string): boolean { || chromeFiles.has(path); } -function source(path: string): string { - const value = sourceModules[`/src/${path}`]; - if (value === undefined) throw new Error(`Unknown demo source: ${path}`); - return value; +async function source(path: string): Promise { + const load = sourceModules[`/src/${path}`]; + if (load === undefined) throw new Error(`Unknown demo source: ${path}`); + return load(); } function relativeSpecifiers(value: string): ReadonlyArray { diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index e28513b9..298a3157 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { DemoWorkbench } from "../../src/shared/demo-workbench/DemoWorkbench"; -import { demoSources } from "../../src/shared/demo-workbench/demo-sources"; +import { loadDemoSources } from "../../src/shared/demo-workbench/demo-sources"; afterEach(cleanup); @@ -51,8 +51,8 @@ describe("DemoWorkbench", () => { }); describe("demoSources", () => { - test("loads actual full demo files without following package or site chrome imports", () => { - const document = demoSources("/demo")!; + test("loads actual full demo files without following package or site chrome imports", async () => { + const document = (await loadDemoSources("/demo"))!; expect(document.map((file) => file.path)).toEqual(["routes/document-demo/DocumentDemoRoute.tsx"]); expect(document[0]!.source).toContain("export function DocumentDemoRoute()"); expect(document[0]!.source).toContain('from "@interactive-os/json-document-react"'); @@ -60,18 +60,18 @@ describe("demoSources", () => { expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); }); - test("includes demo-owned helpers and shared behavioral glue", () => { - expect(demoSources("/demo/database")!.map((file) => file.path)).toEqual([ + test("includes demo-owned helpers and shared behavioral glue", async () => { + expect((await loadDemoSources("/demo/database"))!.map((file) => file.path)).toEqual([ "routes/database-demo/DatabaseDemoRoute.tsx", "routes/database-demo/DatabaseTableDemo.tsx", "routes/database-demo/initial-database.ts", ]); - expect(demoSources("/widgets/listbox")!.map((file) => file.path)).toContain( + expect((await loadDemoSources("/widgets/listbox"))!.map((file) => file.path)).toContain( "routes/widgets/binding/order.ts", ); }); - test("covers every public interactive demo route", () => { + test("covers every public interactive demo route", async () => { const paths = [ "/demo", "/demo/order", "/demo/object", "/demo/canvas", "/demo/sheet", "/demo/database", "/demo/tree", "/demo/kanban", "/demo/topology", "/demo/selection", "/demo/clipboard", "/demo/history", @@ -80,6 +80,6 @@ describe("demoSources", () => { "/connectors/react", "/connectors/react-hook-form", "/connectors/tanstack-table", "/connectors/ajv", "/connectors/zod", "/connectors/zod/validate", ]; - for (const path of paths) expect(demoSources(path), path).not.toBeUndefined(); + for (const path of paths) expect(await loadDemoSources(path), path).not.toBeUndefined(); }); }); From b700295ff7a28c4d75d7acd7ac957fff5644647e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 11:10:50 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=A4=91=20=EC=9B=8C=ED=81=AC=EB=B2=A4=EC=B9=98=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 12 ++++++++---- site/src/shared/demo-workbench/demo-sources.ts | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index 74a0304f..63422b7d 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from "react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { DemoWorkbench } from "../../shared/demo-workbench/DemoWorkbench"; -import { loadDemoSources, type DemoSourceFile } from "../../shared/demo-workbench/demo-sources"; +import { + hasDemoSources, + loadDemoSources, + type DemoSourceFile, +} from "../../shared/demo-workbench/demo-sources"; import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives"; import { SiteBreadcrumb } from "../breadcrumb"; import { findSiteRoute, siteRoutes, usePathname } from "../router"; @@ -18,9 +22,9 @@ function InteriorPage() { return ( }> - {sources === undefined ? content : ( - {content} - )} + {hasDemoSources(route.path) + ? {content} + : content} ); diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 43aa8fe2..d7f8fbe5 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -60,6 +60,10 @@ export async function loadDemoSources(pathname: string): Promise> { const paths: string[] = []; const visited = new Set(); From 59e3c7ed8d271ce8396d5aa32cbe086c5efb8e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 11:14:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=ED=8C=8C=EC=9D=BC=20=ED=83=AD=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 31 +--- .../shared/demo-workbench/DemoWorkbench.tsx | 19 ++- .../src/shared/demo-workbench/demo-sources.ts | 150 ++++++++---------- site/tests/unit/demo-workbench.test.tsx | 28 ++-- 4 files changed, 104 insertions(+), 124 deletions(-) diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index 63422b7d..3124ea74 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -1,11 +1,6 @@ -import { useEffect, useState } from "react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { DemoWorkbench } from "../../shared/demo-workbench/DemoWorkbench"; -import { - hasDemoSources, - loadDemoSources, - type DemoSourceFile, -} from "../../shared/demo-workbench/demo-sources"; +import { demoSources } from "../../shared/demo-workbench/demo-sources"; import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives"; import { SiteBreadcrumb } from "../breadcrumb"; import { findSiteRoute, siteRoutes, usePathname } from "../router"; @@ -16,31 +11,15 @@ export const Route = createFileRoute("/_page")({ function InteriorPage() { const route = findSiteRoute(usePathname()); - const loaded = useDemoSources(route.path); - const sources = loaded?.path === route.path ? loaded.sources : undefined; + const sources = demoSources(route.path); const content = ; return ( }> - {hasDemoSources(route.path) - ? {content} - : content} + {sources === undefined ? content : ( + {content} + )} ); } - -function useDemoSources(path: string) { - const [loaded, setLoaded] = useState<{ - readonly path: string; - readonly sources: ReadonlyArray; - }>(); - useEffect(() => { - let current = true; - void loadDemoSources(path).then((sources) => { - if (current && sources !== undefined) setLoaded({ path, sources }); - }); - return () => { current = false; }; - }, [path]); - return loaded; -} diff --git a/site/src/shared/demo-workbench/DemoWorkbench.tsx b/site/src/shared/demo-workbench/DemoWorkbench.tsx index 4720f19a..06c7c011 100644 --- a/site/src/shared/demo-workbench/DemoWorkbench.tsx +++ b/site/src/shared/demo-workbench/DemoWorkbench.tsx @@ -1,4 +1,4 @@ -import { useId, useState, type KeyboardEvent, type ReactNode } from "react"; +import { useEffect, useId, useState, type KeyboardEvent, type ReactNode } from "react"; import { CodeBlock } from "../ui/code-block"; import { classes, ui } from "../ui/styles"; import type { DemoSourceFile } from "./demo-sources"; @@ -17,6 +17,17 @@ export function DemoWorkbench(props: { ]; const activeSourceIndex = typeof activeTab === "number" ? activeTab : undefined; const activeSource = activeSourceIndex === undefined ? undefined : props.sources[activeSourceIndex]; + const [sourceText, setSourceText] = useState>>({}); + const activeSourceText = activeSource === undefined ? undefined : sourceText[activeSource.path]; + + useEffect(() => { + if (activeSource === undefined || activeSourceText !== undefined) return; + let current = true; + void activeSource.load().then((source) => { + if (current) setSourceText((loaded) => ({ ...loaded, [activeSource.path]: source })); + }); + return () => { current = false; }; + }, [activeSource?.path, activeSourceText]); function selectNeighbor(event: KeyboardEvent, index: number) { const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; @@ -73,7 +84,11 @@ export function DemoWorkbench(props: { >

{activeSource.path}

- + {activeSourceText === undefined ? ( +

Loading source…

+ ) : ( + + )}
)} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index d7f8fbe5..cc5a04d8 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -3,10 +3,10 @@ import type { CodeLanguage } from "../ui/code-tokens"; export type DemoSourceFile = { readonly path: string; readonly language: CodeLanguage; - readonly source: string; + readonly load: () => Promise; }; -const sourceModules = import.meta.glob Promise>( +const sourceModules = import.meta.glob( [ "/src/routes/**/*.{ts,tsx}", "/src/shared/**/*.{ts,tsx}", @@ -16,99 +16,83 @@ const sourceModules = import.meta.glob Promise>( { import: "default", query: "?raw" }, ); -const demoSourceEntries: Readonly> = { - "/demo": "routes/document-demo/DocumentDemoRoute.tsx", - "/demo/order": "routes/order-demo/OrderDemoRoute.tsx", - "/demo/object": "routes/object-demo/ObjectDemoRoute.tsx", - "/demo/canvas": "routes/canvas-demo/CanvasDemoRoute.tsx", - "/demo/sheet": "routes/sheet-demo/SheetDemoRoute.tsx", - "/demo/database": "routes/database-demo/DatabaseDemoRoute.tsx", - "/demo/tree": "routes/tree-demo/TreeDemoRoute.tsx", - "/demo/kanban": "routes/kanban-demo/KanbanDemoRoute.tsx", - "/demo/topology": "routes/editing-demos/TopologyDemoRoute.tsx", - "/demo/selection": "routes/editing-demos/SelectionDemoRoute.tsx", - "/demo/clipboard": "routes/editing-demos/ClipboardDemoRoute.tsx", - "/demo/history": "routes/editing-demos/HistoryDemoRoute.tsx", - "/editing/rich-text": "routes/rich-text-demo/RichTextDemoRoute.tsx", - "/widgets/listbox": "routes/widgets/ListboxWidgetRoute.tsx", - "/widgets/grid": "routes/widgets/GridWidgetRoute.tsx", - "/widgets/toolbar": "routes/widgets/ToolbarWidgetRoute.tsx", - "/adapters/clipboard": "routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx", - "/adapters/contenteditable": "routes/adapters/contenteditable/ContentEditableAdapterDemoRoute.tsx", - "/adapters/keyboard": "routes/adapters/keyboard/KeyboardAdapterDemoRoute.tsx", - "/connectors/react": "routes/connectors/react/ReactConnectorDemoRoute.tsx", - "/connectors/react-hook-form": "routes/connectors/react-hook-form/ReactHookFormConnectorDemoRoute.tsx", - "/connectors/tanstack-table": "routes/connectors/tanstack-table/TanStackTableConnectorDemoRoute.tsx", - "/connectors/ajv": "routes/connectors/ajv/AjvConnectorDemoRoute.tsx", - "/connectors/zod": "routes/connectors/zod/ZodConnectorDemoRoute.tsx", - "/connectors/zod/validate": "routes/connectors/zod/ZodValidateDemoRoute.tsx", +const demoSourcePaths: Readonly>> = { + "/demo": ["routes/document-demo/DocumentDemoRoute.tsx"], + "/demo/order": ["routes/order-demo/OrderDemoRoute.tsx"], + "/demo/object": ["routes/object-demo/ObjectDemoRoute.tsx"], + "/demo/canvas": ["routes/canvas-demo/CanvasDemoRoute.tsx"], + "/demo/sheet": ["routes/sheet-demo/SheetDemoRoute.tsx", "routes/sheet-demo/SheetDemo.tsx"], + "/demo/database": [ + "routes/database-demo/DatabaseDemoRoute.tsx", + "routes/database-demo/DatabaseTableDemo.tsx", + "routes/database-demo/initial-database.ts", + ], + "/demo/tree": ["routes/tree-demo/TreeDemoRoute.tsx"], + "/demo/kanban": ["routes/kanban-demo/KanbanDemoRoute.tsx"], + "/demo/topology": ["routes/editing-demos/TopologyDemoRoute.tsx"], + "/demo/selection": ["routes/editing-demos/SelectionDemoRoute.tsx"], + "/demo/clipboard": ["routes/editing-demos/ClipboardDemoRoute.tsx"], + "/demo/history": ["routes/editing-demos/HistoryDemoRoute.tsx"], + "/editing/rich-text": [ + "routes/rich-text-demo/RichTextDemoRoute.tsx", + "routes/rich-text-demo/rich-text-styles.ts", + ], + "/widgets/listbox": widgetSources("ListboxWidgetRoute.tsx"), + "/widgets/grid": widgetSources("GridWidgetRoute.tsx"), + "/widgets/toolbar": widgetSources("ToolbarWidgetRoute.tsx"), + "/adapters/clipboard": adapterSources("clipboard", "Clipboard"), + "/adapters/contenteditable": adapterSources("contenteditable", "ContentEditable"), + "/adapters/keyboard": adapterSources("keyboard", "Keyboard"), + "/connectors/react": connectorSources("react", "ReactConnector"), + "/connectors/react-hook-form": connectorSources("react-hook-form", "ReactHookFormConnector"), + "/connectors/tanstack-table": connectorSources("tanstack-table", "TanStackTableConnector"), + "/connectors/ajv": connectorSources("ajv", "AjvConnector"), + "/connectors/zod": [ + "routes/connectors/zod/ZodConnectorDemoRoute.tsx", + "routes/connectors/zod/ZodAdminLab.tsx", + ], + "/connectors/zod/validate": [ + "routes/connectors/zod/ZodValidateDemoRoute.tsx", + "routes/connectors/zod/ZodConnectorLab.tsx", + ], }; -const chromeFiles = new Set([ - "routes/connectors/ConnectorDemoPage.tsx", - "routes/widgets/WidgetDemoFrame.tsx", -]); - -export async function loadDemoSources(pathname: string): Promise | undefined> { - const entry = demoSourceEntries[pathname]; - if (entry === undefined) return undefined; - const paths = await sourceClosure(entry); - return Promise.all(paths.map(async (path) => ({ +export function demoSources(pathname: string): ReadonlyArray | undefined { + return demoSourcePaths[pathname]?.map((path) => ({ path, language: path.endsWith(".tsx") ? "tsx" : "typescript", - source: await source(path), - } as const))); + load: sourceLoader(path), + })); } -export function hasDemoSources(pathname: string): boolean { - return demoSourceEntries[pathname] !== undefined; +function widgetSources(route: string): ReadonlyArray { + return [ + `routes/widgets/${route}`, + "routes/widgets/binding/index.ts", + "routes/widgets/binding/order.ts", + "routes/widgets/binding/sheet.ts", + "routes/widgets/binding/keyboard.ts", + "routes/widgets/binding/history.ts", + "routes/widgets/binding/option.ts", + ]; } -async function sourceClosure(entry: string): Promise> { - const paths: string[] = []; - const visited = new Set(); - async function visit(path: string): Promise { - if (visited.has(path) || isChrome(path)) return; - visited.add(path); - paths.push(path); - for (const specifier of relativeSpecifiers(await source(path))) { - const resolved = resolveSource(path, specifier); - if (resolved !== undefined) await visit(resolved); - } - } - await visit(entry); - return paths; +function adapterSources(folder: string, name: string): ReadonlyArray { + return [ + `routes/adapters/${folder}/${name}AdapterDemoRoute.tsx`, + `routes/adapters/${folder}/${name}AdapterLab.tsx`, + ]; } -function isChrome(path: string): boolean { - return path.startsWith("app/") - || path.startsWith("shared/ui/") - || path.startsWith("shared/demo-workbench/") - || chromeFiles.has(path); +function connectorSources(folder: string, name: string): ReadonlyArray { + return [ + `routes/connectors/${folder}/${name}DemoRoute.tsx`, + `routes/connectors/${folder}/${name}Lab.tsx`, + ]; } -async function source(path: string): Promise { +function sourceLoader(path: string): () => Promise { const load = sourceModules[`/src/${path}`]; if (load === undefined) throw new Error(`Unknown demo source: ${path}`); - return load(); -} - -function relativeSpecifiers(value: string): ReadonlyArray { - const specifiers: string[] = []; - const pattern = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?["'](\.[^"']+)["']/g; - for (const match of value.matchAll(pattern)) specifiers.push(match[1]!); - return specifiers; -} - -function resolveSource(importer: string, specifier: string): string | undefined { - const parts = importer.split("/"); - parts.pop(); - for (const part of specifier.split("/")) { - if (part === "." || part === "") continue; - if (part === "..") parts.pop(); - else parts.push(part); - } - const base = parts.join("/"); - return [base, `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`] - .find((candidate) => sourceModules[`/src/${candidate}`] !== undefined); + return load; } diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 298a3157..e8259d7a 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { DemoWorkbench } from "../../src/shared/demo-workbench/DemoWorkbench"; -import { loadDemoSources } from "../../src/shared/demo-workbench/demo-sources"; +import { demoSources } from "../../src/shared/demo-workbench/demo-sources"; afterEach(cleanup); @@ -10,16 +10,16 @@ describe("DemoWorkbench", () => { { path: "routes/example/ExampleDemoRoute.tsx", language: "tsx" as const, - source: "export function ExampleDemoRoute() {\n return
;\n}", + load: async () => "export function ExampleDemoRoute() {\n return
;\n}", }, { path: "routes/example/fixture.ts", language: "typescript" as const, - source: "export const fixture = { ready: true };", + load: async () => "export const fixture = { ready: true };", }, ]; - test("keeps Demo first and switches between the live demo and full source files", () => { + test("keeps Demo first and switches between the live demo and full source files", async () => { render(); const tablist = screen.getByRole("tablist", { name: "Demo and source files" }); @@ -32,7 +32,8 @@ describe("DemoWorkbench", () => { expect(screen.queryByRole("button", { name: "Run demo" })).toBeNull(); expect(screen.getByText("routes/example/ExampleDemoRoute.tsx")).toBeTruthy(); - expect(screen.getByRole("tabpanel").textContent).toContain(sources[0]!.source); + expect(await screen.findByText("export", { selector: '[data-code-token="keyword"]' })).toBeTruthy(); + expect(screen.getByRole("tabpanel").textContent).toContain(await sources[0]!.load()); }); test("moves across tabs with editor-style arrow navigation", () => { @@ -52,26 +53,27 @@ describe("DemoWorkbench", () => { describe("demoSources", () => { test("loads actual full demo files without following package or site chrome imports", async () => { - const document = (await loadDemoSources("/demo"))!; + const document = demoSources("/demo")!; expect(document.map((file) => file.path)).toEqual(["routes/document-demo/DocumentDemoRoute.tsx"]); - expect(document[0]!.source).toContain("export function DocumentDemoRoute()"); - expect(document[0]!.source).toContain('from "@interactive-os/json-document-react"'); + const source = await document[0]!.load(); + expect(source).toContain("export function DocumentDemoRoute()"); + expect(source).toContain('from "@interactive-os/json-document-react"'); expect(document.some((file) => file.path.includes("packages/"))).toBe(false); expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); }); - test("includes demo-owned helpers and shared behavioral glue", async () => { - expect((await loadDemoSources("/demo/database"))!.map((file) => file.path)).toEqual([ + test("includes demo-owned helpers and shared behavioral glue", () => { + expect(demoSources("/demo/database")!.map((file) => file.path)).toEqual([ "routes/database-demo/DatabaseDemoRoute.tsx", "routes/database-demo/DatabaseTableDemo.tsx", "routes/database-demo/initial-database.ts", ]); - expect((await loadDemoSources("/widgets/listbox"))!.map((file) => file.path)).toContain( + expect(demoSources("/widgets/listbox")!.map((file) => file.path)).toContain( "routes/widgets/binding/order.ts", ); }); - test("covers every public interactive demo route", async () => { + test("covers every public interactive demo route", () => { const paths = [ "/demo", "/demo/order", "/demo/object", "/demo/canvas", "/demo/sheet", "/demo/database", "/demo/tree", "/demo/kanban", "/demo/topology", "/demo/selection", "/demo/clipboard", "/demo/history", @@ -80,6 +82,6 @@ describe("demoSources", () => { "/connectors/react", "/connectors/react-hook-form", "/connectors/tanstack-table", "/connectors/ajv", "/connectors/zod", "/connectors/zod/validate", ]; - for (const path of paths) expect(await loadDemoSources(path), path).not.toBeUndefined(); + for (const path of paths) expect(demoSources(path), path).not.toBeUndefined(); }); }); From 5523e560a7f3c5f9a0de43d0af21644d98e1c63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 11:16:33 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20route=20=EA=B2=80=EC=A6=9D=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/tests/unit/docs-route.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/tests/unit/docs-route.test.tsx b/site/tests/unit/docs-route.test.tsx index e643081f..9fcef67d 100644 --- a/site/tests/unit/docs-route.test.tsx +++ b/site/tests/unit/docs-route.test.tsx @@ -41,17 +41,17 @@ describe("documentation routes", () => { await user.click(nav.getByRole("button", { name: "Connector" })); await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "React", exact: true })); await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document")); - expect(await screen.findByRole("heading", { level: 1, name: "React Connector" })).toBeTruthy(); + expect(await screen.findByRole("heading", { level: 1, name: "React Connector" }, { timeout: 10000 })).toBeTruthy(); await user.click(nav.getByRole("button", { name: "Editing" })); await user.click(within(nav.getByRole("group", { name: "Editing" })).getByRole("link", { name: "Topology" })); await waitFor(() => expect(document.title).toBe("Topology - json-document")); - expect(await screen.findByRole("heading", { level: 1, name: "Topology" })).toBeTruthy(); + expect(await screen.findByRole("heading", { level: 1, name: "Topology" }, { timeout: 10000 })).toBeTruthy(); await user.click(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "API Reference" })); await waitFor(() => expect(document.title).toBe("json-document API - json-document")); expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("여섯 가지 JSON Document 진입점과 JSON Patch, Pointer, JSONPath 공개 API를 정리합니다."); - expect(await screen.findByRole("heading", { level: 1, name: "json-document API" })).toBeTruthy(); + expect(await screen.findByRole("heading", { level: 1, name: "json-document API" }, { timeout: 10000 })).toBeTruthy(); expect(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "Why" }).getAttribute("aria-current")).toBeNull(); expect(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "API Reference" }).getAttribute("aria-current")).toBe("page"); const mobileSections = within(screen.getByRole("navigation", { name: "Documentation sections" })); From 0c36619edacec5a0a80add3628f730aee42d1f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 13:22:16 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=ED=83=AD=EC=9D=84=20b?= =?UTF-8?q?readcrumb=20=EC=95=84=EB=9E=98=EC=97=90=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 12 +++-- .../shared/demo-workbench/DemoWorkbench.tsx | 52 ++++++++++--------- site/tests/browser/demo-workbench.spec.ts | 20 +++++++ site/tests/unit/app-shell.test.tsx | 3 +- 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index 3124ea74..e013d0e0 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -13,12 +13,18 @@ function InteriorPage() { const route = findSiteRoute(usePathname()); const sources = demoSources(route.path); const content = ; + if (sources !== undefined) { + return ( + + + {content} + + ); + } return ( }> - {sources === undefined ? content : ( - {content} - )} + {content} ); diff --git a/site/src/shared/demo-workbench/DemoWorkbench.tsx b/site/src/shared/demo-workbench/DemoWorkbench.tsx index 06c7c011..210bd565 100644 --- a/site/src/shared/demo-workbench/DemoWorkbench.tsx +++ b/site/src/shared/demo-workbench/DemoWorkbench.tsx @@ -39,31 +39,33 @@ export function DemoWorkbench(props: { } return ( -
-
- {tabs.map((tab, index) => { - const selected = tab.key === activeTab; - return ( - - ); - })} +
+
+
+ {tabs.map((tab, index) => { + const selected = tab.key === activeTab; + return ( + + ); + })} +
"initial-database.ts", ]); }); + +test("keeps the page breadcrumb above sticky workbench tabs", async ({ page }) => { + await page.goto("/demo"); + + const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + const tablist = page.getByRole("tablist", { name: "Demo and source files" }); + const initialBreadcrumb = await breadcrumb.boundingBox(); + const initialTabs = await tablist.boundingBox(); + expect(initialBreadcrumb).not.toBeNull(); + expect(initialTabs).not.toBeNull(); + expect(initialBreadcrumb!.y + initialBreadcrumb!.height).toBeLessThanOrEqual(initialTabs!.y); + await expect(page.locator("[data-page-header] >> nav[aria-label='Breadcrumb']")).toHaveCount(0); + + await tablist.getByRole("tab", { name: "DocumentDemoRoute.tsx" }).click(); + await expect(page.getByText("routes/document-demo/DocumentDemoRoute.tsx")).toBeVisible(); + await page.evaluate(() => window.scrollTo(0, 900)); + const stickyTabs = await tablist.boundingBox(); + expect(stickyTabs).not.toBeNull(); + expect(stickyTabs!.y).toBeLessThanOrEqual(1); +}); diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx index 7e961e04..c1c3b1b9 100644 --- a/site/tests/unit/app-shell.test.tsx +++ b/site/tests/unit/app-shell.test.tsx @@ -119,7 +119,8 @@ describe("official site shell", () => { await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "Zod", exact: true })); const adminHeader = document.querySelector("[data-page-header]"); - expect(adminHeader?.querySelector('[aria-label="Breadcrumb"]')).toBeTruthy(); + expect(adminHeader?.querySelector('[aria-label="Breadcrumb"]')).toBeNull(); + expect(await screen.findByRole("region", { name: "Demo workbench" })).toBeTruthy(); const adminCrumb = within(await screen.findByRole("navigation", { name: "Breadcrumb" })); expect(adminCrumb.getByRole("link", { name: "Overview" }).getAttribute("href")).toBe("/"); expect(adminCrumb.getByRole("link", { name: "Connector" }).getAttribute("href")).toBe("/connectors"); From e9c1557685f8b73440e6bd2855893351fd2b3346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 14:46:41 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EC=A0=9C=ED=92=88=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=EA=B3=BC=20=EB=AC=B8=EC=84=9C=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/app/routes/_page.tsx | 4 ++-- .../routes/canvas-demo/CanvasDemoRoute.tsx | 3 +++ .../database-demo/DatabaseDemoRoute.tsx | 5 ++++- .../document-demo/DocumentDemoRoute.tsx | 3 +++ .../editing-demos/ClipboardDemoRoute.tsx | 3 +++ .../routes/editing-demos/HistoryDemoRoute.tsx | 3 +++ .../editing-demos/SelectionDemoRoute.tsx | 3 +++ .../editing-demos/TopologyDemoRoute.tsx | 3 +++ .../routes/kanban-demo/KanbanDemoRoute.tsx | 3 +++ .../routes/object-demo/ObjectDemoRoute.tsx | 3 +++ site/src/routes/order-demo/OrderDemoRoute.tsx | 3 +++ .../rich-text-demo/RichTextDemoRoute.tsx | 3 +++ site/src/routes/sheet-demo/SheetDemo.tsx | 3 +++ site/src/routes/tree-demo/TreeDemoRoute.tsx | 3 +++ site/src/routes/widgets/WidgetDemoFrame.tsx | 3 +++ .../src/shared/demo-workbench/DemoSurface.tsx | 22 +++++++++++++++++++ site/src/shared/ui/catalog-demo-page.tsx | 9 ++++---- site/tests/browser/demo-workbench.spec.ts | 11 ++++++++-- 18 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 site/src/shared/demo-workbench/DemoSurface.tsx diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx index e013d0e0..c21f09aa 100644 --- a/site/src/app/routes/_page.tsx +++ b/site/src/app/routes/_page.tsx @@ -1,5 +1,5 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; -import { DemoWorkbench } from "../../shared/demo-workbench/DemoWorkbench"; +import { DemoSourcesProvider } from "../../shared/demo-workbench/DemoSurface"; import { demoSources } from "../../shared/demo-workbench/demo-sources"; import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives"; import { SiteBreadcrumb } from "../breadcrumb"; @@ -17,7 +17,7 @@ function InteriorPage() { return ( - {content} + {content} ); } diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index d0193592..a2f52c4e 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState, type PointerEvent } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createObjectEditor, type ObjectDocument, @@ -76,6 +77,7 @@ export function CanvasDemoRoute() { Pick a box, drag it, then fill the selection. The board is the editor. + + ); } diff --git a/site/src/routes/database-demo/DatabaseDemoRoute.tsx b/site/src/routes/database-demo/DatabaseDemoRoute.tsx index 6c813649..3ad552a1 100644 --- a/site/src/routes/database-demo/DatabaseDemoRoute.tsx +++ b/site/src/routes/database-demo/DatabaseDemoRoute.tsx @@ -1,3 +1,4 @@ +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { PageFrame, PageHeader } from "../../shared/ui/primitives"; import { DatabaseTableDemo } from "./DatabaseTableDemo"; @@ -7,7 +8,9 @@ export function DatabaseDemoRoute() { One canonical database, typed property editors, persistent view configuration, structural selection, and native text leases. - + + + ); } diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx index df44d146..c6fe436f 100644 --- a/site/src/routes/document-demo/DocumentDemoRoute.tsx +++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx @@ -1,4 +1,5 @@ import { useRef, useState, type ClipboardEvent } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { type BlockDocument, type DocumentClipboard, @@ -169,6 +170,7 @@ export function DocumentDemoRoute() { )} >A deliberately small interface for selection, clipboard, history, keyboard input, and canonical JSON publication. + Shift-click selects a range. Mod-click adds or removes a block. Arrow keys move the selection when focus is on the surface.

+ ); } diff --git a/site/src/routes/editing-demos/ClipboardDemoRoute.tsx b/site/src/routes/editing-demos/ClipboardDemoRoute.tsx index f0759b84..da14f3ca 100644 --- a/site/src/routes/editing-demos/ClipboardDemoRoute.tsx +++ b/site/src/routes/editing-demos/ClipboardDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { type BlockDocument, type DocumentClipboard } from "@interactive-os/json-document-editing"; import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -56,6 +57,7 @@ export function ClipboardDemoRoute() { Selection에서 시작해 copy 또는 cut으로 구조화된 payload를 만들고 paste에 넘깁니다. +

1 · Selection

@@ -98,6 +100,7 @@ export function ClipboardDemoRoute() { ]} />
+
); } diff --git a/site/src/routes/editing-demos/HistoryDemoRoute.tsx b/site/src/routes/editing-demos/HistoryDemoRoute.tsx index 14e41914..fe23ffcd 100644 --- a/site/src/routes/editing-demos/HistoryDemoRoute.tsx +++ b/site/src/routes/editing-demos/HistoryDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { type BlockDocument } from "@interactive-os/json-document-editing"; import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -50,6 +51,7 @@ export function HistoryDemoRoute() { 편집을 한 번 commit하고, 만들어진 History 항목으로 document.value와 Selection을 함께 복원합니다. +

1 · 편집

@@ -104,6 +106,7 @@ export function HistoryDemoRoute() { ]} />
+
); } diff --git a/site/src/routes/editing-demos/SelectionDemoRoute.tsx b/site/src/routes/editing-demos/SelectionDemoRoute.tsx index 832a8333..2b7dd5b9 100644 --- a/site/src/routes/editing-demos/SelectionDemoRoute.tsx +++ b/site/src/routes/editing-demos/SelectionDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { type BlockDocument, type DocumentIntent } from "@interactive-os/json-document-editing"; import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -39,6 +40,7 @@ export function SelectionDemoRoute() { Selection 입력을 dispatch한 뒤 바뀐 Selection을 그대로인 document.value와 History 옆에서 비교합니다. +

1 · 입력

@@ -95,6 +97,7 @@ export function SelectionDemoRoute() { ]} />
+
); } diff --git a/site/src/routes/editing-demos/TopologyDemoRoute.tsx b/site/src/routes/editing-demos/TopologyDemoRoute.tsx index 2ae63ba8..b1df5179 100644 --- a/site/src/routes/editing-demos/TopologyDemoRoute.tsx +++ b/site/src/routes/editing-demos/TopologyDemoRoute.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { lineInterval, lineTopology } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -44,6 +45,7 @@ export function TopologyDemoRoute() { anchor와 focus를 유지한 채 화면 순서를 바꾸고, 그 순서에서 계산된 범위를 확인합니다. +

1 · 입력

@@ -105,6 +107,7 @@ export function TopologyDemoRoute() {

+
); } diff --git a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx index d7bc6460..1a4d9e3c 100644 --- a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx +++ b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState, type DragEvent } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createKanbanEditor, type KanbanDocument, @@ -49,6 +50,7 @@ export function KanbanDemoRoute() { Drag a card into another column. One JSON document keeps the board. + + ); } diff --git a/site/src/routes/object-demo/ObjectDemoRoute.tsx b/site/src/routes/object-demo/ObjectDemoRoute.tsx index eed3a70a..9134ba0b 100644 --- a/site/src/routes/object-demo/ObjectDemoRoute.tsx +++ b/site/src/routes/object-demo/ObjectDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createObjectEditor, type ObjectClipboard, @@ -78,6 +79,7 @@ export function ObjectDemoRoute() { A key-family board. The host hit-tests boxes and sends only stable IDs. Fill changes color without moving geometry. + Click a box. Mod-click toggles. Fill uses the selected IDs only.

+ ); } diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index 36a6980d..6e58e8a8 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createOrderEditor, type OrderClipboard, @@ -73,6 +74,7 @@ export function OrderDemoRoute() { A one-line list with range selection, structured clipboard, delete, and local history. + Shift-click selects a range. Mod-click adds or removes an item.

+
); } diff --git a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx index ea0df7a5..6fe3b815 100644 --- a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx +++ b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; import { useEditing } from "@interactive-os/json-document-react"; import { @@ -201,6 +202,7 @@ export function RichTextDemoRoute() { DOM은 입력 경계일 뿐입니다. 아래 편집은 Rich Text intent, Selection mapping, EditingSession과 atomic JSON Patch를 차례로 통과합니다. +
Apply sample intent runHistory("undo")} disabled={!snapshot.canUndo}>Undo @@ -283,6 +285,7 @@ export function RichTextDemoRoute() {
+
); } diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 78cee813..3913920f 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -1,4 +1,5 @@ import { useRef, useState, type ClipboardEvent } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createSheetEditor, type EditingResult, @@ -180,6 +181,7 @@ export function SheetDemo() { )} >A small editable grid for rectangular selection, TSV clipboard, history, and canonical JSON publication. + Click replaces selection. Shift-click extends the primary rectangle. Mod-click or Mod+Space toggles a cell. Arrows move by the visible grid; Shift+arrows extend it. Delete clears selected cells. Fill selected changes every selected cell in one transaction.

+
); } diff --git a/site/src/routes/tree-demo/TreeDemoRoute.tsx b/site/src/routes/tree-demo/TreeDemoRoute.tsx index 52684f1f..17631aa7 100644 --- a/site/src/routes/tree-demo/TreeDemoRoute.tsx +++ b/site/src/routes/tree-demo/TreeDemoRoute.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { createTreeEditor, type TreeClipboard, @@ -91,6 +92,7 @@ export function TreeDemoRoute() { A folded tree. The host owns expand state and sends only the visible ID line to the editor. + Fold a branch to take it out of the visible line. Selection and clipboard read that line only.

+
); } diff --git a/site/src/routes/widgets/WidgetDemoFrame.tsx b/site/src/routes/widgets/WidgetDemoFrame.tsx index f9bd6102..0bf2f79c 100644 --- a/site/src/routes/widgets/WidgetDemoFrame.tsx +++ b/site/src/routes/widgets/WidgetDemoFrame.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { DemoSurface } from "../../shared/demo-workbench/DemoSurface"; import { type InspectorItem } from "../../shared/ui/inspector"; import { JsonInspector } from "../../shared/ui/json-inspector"; import { PageFrame, PageHeader, type PetiteCatIllustration } from "../../shared/ui/primitives"; @@ -20,6 +21,7 @@ export function WidgetDemoFrame(props: { {props.description} +

Widget

@@ -42,6 +44,7 @@ export function WidgetDemoFrame(props: {
+
); } diff --git a/site/src/shared/demo-workbench/DemoSurface.tsx b/site/src/shared/demo-workbench/DemoSurface.tsx new file mode 100644 index 00000000..50d1d441 --- /dev/null +++ b/site/src/shared/demo-workbench/DemoSurface.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { DemoWorkbench } from "./DemoWorkbench"; +import type { DemoSource } from "./demo-sources"; + +const DemoSourcesContext = createContext | undefined>(undefined); + +export function DemoSourcesProvider(props: { + readonly sources: ReadonlyArray | undefined; + readonly children: ReactNode; +}) { + return {props.children}; +} + +export function DemoSurface(props: { readonly children: ReactNode }) { + const sources = useContext(DemoSourcesContext); + + if (sources === undefined) { + return props.children; + } + + return {props.children}; +} diff --git a/site/src/shared/ui/catalog-demo-page.tsx b/site/src/shared/ui/catalog-demo-page.tsx index b63d6527..c7731d12 100644 --- a/site/src/shared/ui/catalog-demo-page.tsx +++ b/site/src/shared/ui/catalog-demo-page.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { DemoSurface } from "../demo-workbench/DemoSurface"; import { CodeBlock, InlineCode } from "./code-block"; import type { CodeLanguage } from "./code-tokens"; import { PageFrame, PageHeader, type PetiteCatIllustration } from "./primitives"; @@ -55,10 +56,10 @@ export function CatalogDemoPage(props: { ) : null} -
-

Live demo

- {props.children} -
+

Live demo

+ +
{props.children}
+
); } diff --git a/site/tests/browser/demo-workbench.spec.ts b/site/tests/browser/demo-workbench.spec.ts index b36126ba..d6435a3e 100644 --- a/site/tests/browser/demo-workbench.spec.ts +++ b/site/tests/browser/demo-workbench.spec.ts @@ -34,17 +34,24 @@ test("shows every demo-owned database file as a source tab", async ({ page }) => ]); }); -test("keeps the page breadcrumb above sticky workbench tabs", async ({ page }) => { +test("keeps documentation above the sticky product workbench", async ({ page }) => { await page.goto("/demo"); const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" }); + const pageHeader = page.locator("[data-page-header]"); + const workbench = page.getByRole("region", { name: "Demo workbench" }); const tablist = page.getByRole("tablist", { name: "Demo and source files" }); const initialBreadcrumb = await breadcrumb.boundingBox(); + const initialHeader = await pageHeader.boundingBox(); const initialTabs = await tablist.boundingBox(); expect(initialBreadcrumb).not.toBeNull(); + expect(initialHeader).not.toBeNull(); expect(initialTabs).not.toBeNull(); - expect(initialBreadcrumb!.y + initialBreadcrumb!.height).toBeLessThanOrEqual(initialTabs!.y); + expect(initialBreadcrumb!.y + initialBreadcrumb!.height).toBeLessThanOrEqual(initialHeader!.y); + expect(initialHeader!.y + initialHeader!.height).toBeLessThanOrEqual(initialTabs!.y); await expect(page.locator("[data-page-header] >> nav[aria-label='Breadcrumb']")).toHaveCount(0); + await expect(workbench.getByRole("heading", { name: "Document" })).toHaveCount(0); + await expect(workbench.locator("[data-page-header]")).toHaveCount(0); await tablist.getByRole("tab", { name: "DocumentDemoRoute.tsx" }).click(); await expect(page.getByText("routes/document-demo/DocumentDemoRoute.tsx")).toBeVisible(); From 5c6258919d4a8b749572b53b1d586afa8319f6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 14:48:02 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EC=86=8C=EC=8A=A4=20C?= =?UTF-8?q?ontext=20=ED=83=80=EC=9E=85=EC=9D=84=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EC=9E=A1=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/shared/demo-workbench/DemoSurface.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/shared/demo-workbench/DemoSurface.tsx b/site/src/shared/demo-workbench/DemoSurface.tsx index 50d1d441..b40db3fc 100644 --- a/site/src/shared/demo-workbench/DemoSurface.tsx +++ b/site/src/shared/demo-workbench/DemoSurface.tsx @@ -1,11 +1,11 @@ import { createContext, useContext, type ReactNode } from "react"; import { DemoWorkbench } from "./DemoWorkbench"; -import type { DemoSource } from "./demo-sources"; +import type { DemoSourceFile } from "./demo-sources"; -const DemoSourcesContext = createContext | undefined>(undefined); +const DemoSourcesContext = createContext | undefined>(undefined); export function DemoSourcesProvider(props: { - readonly sources: ReadonlyArray | undefined; + readonly sources: ReadonlyArray | undefined; readonly children: ReactNode; }) { return {props.children}; From a5f271a8ee3fbe016681200c06422531a42447c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 21 Aug 2026 17:18:52 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=ED=83=AD=EC=97=90=20S?= =?UTF-8?q?hiki=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 198 +++++++++++++++++- site/package.json | 1 + .../shared/demo-workbench/DemoWorkbench.tsx | 4 +- .../demo-workbench/ShikiSourceCodeBlock.tsx | 33 +++ .../demo-workbench/shiki-highlighter.ts | 25 +++ site/src/shared/ui/code-block.tsx | 12 +- site/tests/browser/demo-workbench.spec.ts | 2 + 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx create mode 100644 site/src/shared/demo-workbench/shiki-highlighter.ts diff --git a/package-lock.json b/package-lock.json index 4ef8d301..2b8abdf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1119,6 +1119,106 @@ "linux" ] }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "dev": true, @@ -1524,7 +1624,9 @@ } }, "node_modules/@types/hast": { - "version": "3.0.4", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -2849,6 +2951,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "license": "MIT", @@ -2915,6 +3040,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "license": "MIT" @@ -4023,6 +4158,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "license": "MIT", @@ -4453,6 +4605,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/rehype-slug": { "version": "6.0.0", "license": "MIT", @@ -4671,6 +4847,25 @@ "seroval": "^1.0" } }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/siginfo": { "version": "2.0.0", "dev": true, @@ -5695,6 +5890,7 @@ "react-markdown": "^10.1.0", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "shiki": "^4.4.3", "zod": "^4.0.0" }, "devDependencies": { diff --git a/site/package.json b/site/package.json index aaed0a4d..4deec67e 100644 --- a/site/package.json +++ b/site/package.json @@ -32,6 +32,7 @@ "react-markdown": "^10.1.0", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", + "shiki": "^4.4.3", "zod": "^4.0.0" }, "devDependencies": { diff --git a/site/src/shared/demo-workbench/DemoWorkbench.tsx b/site/src/shared/demo-workbench/DemoWorkbench.tsx index 210bd565..b7203d2c 100644 --- a/site/src/shared/demo-workbench/DemoWorkbench.tsx +++ b/site/src/shared/demo-workbench/DemoWorkbench.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useState, type KeyboardEvent, type ReactNode } from "react"; -import { CodeBlock } from "../ui/code-block"; import { classes, ui } from "../ui/styles"; +import { ShikiSourceCodeBlock } from "./ShikiSourceCodeBlock"; import type { DemoSourceFile } from "./demo-sources"; type WorkbenchTab = "demo" | number; @@ -89,7 +89,7 @@ export function DemoWorkbench(props: { {activeSourceText === undefined ? (

Loading source…

) : ( - + )} diff --git a/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx b/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx new file mode 100644 index 00000000..11862a32 --- /dev/null +++ b/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { CodeBlock, type HighlightedCodeToken } from "../ui/code-block"; +import type { CodeLanguage } from "../ui/code-tokens"; + +export function ShikiSourceCodeBlock(props: { + readonly language: CodeLanguage; + readonly source: string; +}) { + const [highlightedLines, setHighlightedLines] = useState>>(); + + useEffect(() => { + let current = true; + setHighlightedLines(undefined); + void import("./shiki-highlighter") + .then(({ highlightSource }) => highlightSource(props.source, props.language)) + .then((lines) => { + if (current) setHighlightedLines(lines); + }) + .catch(() => undefined); + return () => { current = false; }; + }, [props.language, props.source]); + + return ( +
+ +
+ ); +} diff --git a/site/src/shared/demo-workbench/shiki-highlighter.ts b/site/src/shared/demo-workbench/shiki-highlighter.ts new file mode 100644 index 00000000..5a3b8fd9 --- /dev/null +++ b/site/src/shared/demo-workbench/shiki-highlighter.ts @@ -0,0 +1,25 @@ +import typescript from "@shikijs/langs/typescript"; +import tsx from "@shikijs/langs/tsx"; +import githubLightDefault from "@shikijs/themes/github-light-default"; +import { createHighlighterCore } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import type { HighlightedCodeToken } from "../ui/code-block"; +import type { CodeLanguage } from "../ui/code-tokens"; + +const highlighter = createHighlighterCore({ + themes: [githubLightDefault], + langs: [typescript, tsx], + engine: createJavaScriptRegexEngine(), +}); + +export async function highlightSource( + source: string, + language: CodeLanguage, +): Promise>> { + if (language !== "typescript" && language !== "tsx") return []; + const instance = await highlighter; + return instance.codeToTokensBase(source, { + lang: language, + theme: "github-light-default", + }); +} diff --git a/site/src/shared/ui/code-block.tsx b/site/src/shared/ui/code-block.tsx index b42fb8e5..f863c482 100644 --- a/site/src/shared/ui/code-block.tsx +++ b/site/src/shared/ui/code-block.tsx @@ -5,6 +5,11 @@ import { classes, ui } from "./styles"; type CodeBlockSize = "compact" | "content" | "standard" | "tall"; +export type HighlightedCodeToken = { + readonly content: string; + readonly color?: string; +}; + export function CodeBlock(props: { readonly source: string; readonly language: CodeLanguage; @@ -14,6 +19,7 @@ export function CodeBlock(props: { readonly size?: CodeBlockSize; readonly className?: string; readonly testId?: string; + readonly highlightedLines?: ReadonlyArray>; }) { const source = withoutTerminalLineBreak(props.source.replace(/\r\n/g, "\n")); const label = props.label ?? codeLanguageLabel(props.language); @@ -61,7 +67,11 @@ export function CodeBlock(props: { data-code-line {...(props.language === "json" ? { "data-json-line": true } : {})} > - {tokenizeCodeLine(line, props.language).map((token, tokenIndex) => token.kind ? ( + {props.highlightedLines?.[lineIndex]?.map((token, tokenIndex) => ( + + {token.content} + + )) ?? tokenizeCodeLine(line, props.language).map((token, tokenIndex) => token.kind ? (