diff --git a/site/scripts/route-checks.mjs b/site/scripts/route-checks.mjs index 7eb35e13..518766a9 100644 --- a/site/scripts/route-checks.mjs +++ b/site/scripts/route-checks.mjs @@ -37,6 +37,27 @@ export function validateSiteRoutes(routes, fail) { if (route.navigationGroup !== undefined && !navigationGroups.has(route.navigationGroup)) { fail(`site route ${route.path} has an invalid navigation group.`); } + if (route.heading !== undefined && (typeof route.heading !== "string" || route.heading.trim() === "")) { + fail(`site route ${route.path} has an invalid heading.`); + } + if (route.relatedDemoLabel !== undefined && (typeof route.relatedDemoLabel !== "string" || route.relatedDemoLabel.trim() === "")) { + fail(`site route ${route.path} has an invalid related demo label.`); + } + if (route.integration !== undefined) { + const expectedGroup = route.integration.kind === "adapter" ? "Adapters" : "Connectors"; + if (!new Set(["adapter", "connector"]).has(route.integration.kind)) { + fail(`site route ${route.path} has an invalid integration kind.`); + } + if (route.navigationGroup !== expectedGroup) { + fail(`site route ${route.path} integration kind does not match its navigation group.`); + } + if (typeof route.integration.packageName !== "string" || !route.integration.packageName.startsWith("@interactive-os/")) { + fail(`site route ${route.path} has an invalid integration package.`); + } + if (!new Set(["available", "planned"]).has(route.integration.status)) { + fail(`site route ${route.path} has an invalid integration status.`); + } + } if (typeof route.path === "string") { if (paths.has(route.path)) fail(`site routes contain duplicate path ${route.path}.`); @@ -61,6 +82,16 @@ export function validateSiteRoutes(routes, fail) { if (routes[0]?.path !== "/") { fail("site routes must start with the overview route."); } + + for (const route of routes) { + if (route.relatedDemoPath !== undefined && !paths.has(route.relatedDemoPath)) { + fail(`site route ${route.path} points to an unknown related demo ${route.relatedDemoPath}.`); + } + const isIntegrationDemo = /^\/(?:adapters|connectors)\/[^/]+$/.test(route.path); + if (isIntegrationDemo && route.integration === undefined) { + fail(`integration demo ${route.path} must declare catalog metadata.`); + } + } } export function routeFile(path) { diff --git a/site/site-routes.json b/site/site-routes.json index a4557b79..658a2ba6 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -9,6 +9,7 @@ "path": "/docs", "label": "Why", "title": "json-document Docs - json-document", + "heading": "왜 json-document인가", "description": "JSON 문서가 읽기, 검사, commit, 구독에 하나의 계약을 사용하는 이유를 설명합니다.", "language": "ko", "navigationGroup": "JSON Document" @@ -17,6 +18,7 @@ "path": "/docs/tutorial", "label": "Quickstart", "title": "Tutorial - json-document", + "heading": "작은 카드 문서 만들기", "description": "JSON 문서를 만들고 읽고 검사해 commit한 뒤 변경을 구독하는 과정을 따라갑니다.", "language": "ko", "navigationGroup": "JSON Document" @@ -33,6 +35,7 @@ "path": "/docs/api", "label": "API Reference", "title": "json-document API - json-document", + "heading": "json-document API", "description": "여섯 가지 JSON Document 진입점과 JSON Patch, Pointer, JSONPath 공개 API를 정리합니다.", "language": "ko", "navigationGroup": "JSON Document" @@ -41,6 +44,7 @@ "path": "/docs/collaboration", "label": "Collaboration", "title": "Collaboration - json-document", + "heading": "Collaboration", "description": "같은 JSON Document 계약을 여러 참여자의 인과 변경으로 수렴시킵니다.", "language": "ko", "navigationGroup": "JSON Document" @@ -49,6 +53,7 @@ "path": "/docs/collaboration/replica", "label": "Replica", "title": "Replica - json-document", + "heading": "Replica", "description": "한 참여자가 소유한 인과 상태와 아직 적용하지 못한 변경을 설명합니다.", "language": "ko", "parentPath": "/docs/collaboration" @@ -57,6 +62,7 @@ "path": "/docs/collaboration/history", "label": "Collaborative History", "title": "Collaborative History - json-document", + "heading": "Collaborative History", "description": "다른 참여자를 덮어쓰지 않고 현재 참여자의 인과 기여를 끄거나 켭니다.", "language": "ko", "parentPath": "/docs/collaboration" @@ -65,6 +71,7 @@ "path": "/docs/collaboration/text", "label": "Text", "title": "Collaborative Text - json-document", + "heading": "Collaborative Text", "description": "같이 쓰는 문자열의 안정된 글자 단위와 상대 위치를 설명합니다.", "language": "ko", "parentPath": "/docs/collaboration" @@ -73,6 +80,7 @@ "path": "/docs/collaboration/text/lease", "label": "Contenteditable lease", "title": "Contenteditable lease - json-document", + "heading": "Contenteditable lease", "description": "협업 문자열이 입력 중에도 인과 변경을 멈추지 않게 하는 native-input DOM lease를 설명합니다.", "language": "ko", "parentPath": "/docs/collaboration/text" @@ -81,6 +89,7 @@ "path": "/docs/collaboration/lifecycle", "label": "Lifecycle", "title": "Collaboration Lifecycle - json-document", + "heading": "Lifecycle", "description": "epoch, checkpoint, restore, compaction이 협업 세대를 접고 되돌리는 방식을 설명합니다.", "language": "ko", "parentPath": "/docs/collaboration" @@ -89,6 +98,7 @@ "path": "/docs/intent-guide", "label": "Intent guide", "title": "Intent guide - json-document", + "heading": "Editor와 Intent 만들기", "description": "Document editor를 만들고 요청을 EditingIntent로 표현해 EditingResult를 처리합니다.", "language": "ko", "navigationGroup": "Editing" @@ -97,6 +107,7 @@ "path": "/docs/intent", "label": "Intent", "title": "Intent reference - json-document", + "heading": "Intent 레퍼런스", "description": "EditingIntent, dispatch, EditingResult와 editor별 Intent의 공개 시그니처를 정리합니다.", "language": "ko", "parentPath": "/docs/intent-guide" @@ -105,9 +116,11 @@ "path": "/docs/selection", "label": "Selection", "title": "Selection - json-document", + "heading": "Selection", "description": "문서 값을 바꾸지 않고 편집 대상을 기억하는 구조적 Selection을 설명합니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "relatedDemoPath": "/demo/selection" }, { "path": "/demo/selection", @@ -121,9 +134,11 @@ "path": "/docs/topology", "label": "Topology", "title": "Topology - json-document", + "heading": "Topology", "description": "Selection과 Clipboard가 공유하는 화면 순서인 LineTopology와 GridTopology를 설명합니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "relatedDemoPath": "/demo/topology" }, { "path": "/demo/topology", @@ -137,9 +152,11 @@ "path": "/docs/clipboard", "label": "Clipboard", "title": "Clipboard - json-document", + "heading": "Clipboard", "description": "현재 구조적 Selection에서 copy, cut, paste에 사용할 Clipboard payload를 만듭니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "relatedDemoPath": "/demo/clipboard" }, { "path": "/demo/clipboard", @@ -153,9 +170,11 @@ "path": "/docs/history", "label": "History", "title": "History - json-document", + "heading": "History", "description": "document value와 구조적 Selection을 함께 복원하는 로컬 undo와 redo를 설명합니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "relatedDemoPath": "/demo/history" }, { "path": "/demo/history", @@ -177,6 +196,7 @@ "path": "/editors", "label": "Editors", "title": "Editors - json-document", + "heading": "Editors", "description": "Document, Canvas, Sheet, Tree, Kanban 최소 완성본을 고릅니다.", "language": "ko", "navigationGroup": "Editors" @@ -227,6 +247,7 @@ "path": "/docs/order", "label": "Order", "title": "Order - json-document", + "heading": "Order", "description": "한 줄 목록을 범위 선택으로 집고 옮기는 Order editor를 설명합니다.", "language": "ko", "parentPath": "/" @@ -242,6 +263,7 @@ "path": "/docs/object", "label": "Object", "title": "Object - json-document", + "heading": "Object", "description": "안정된 객체 ID를 키 가족으로 집는 Object editor를 설명합니다.", "language": "ko", "parentPath": "/" @@ -257,9 +279,11 @@ "path": "/docs/tree", "label": "Tree", "title": "Tree - json-document", + "heading": "Tree", "description": "호스트가 만든 보이는 노드 줄에서 범위를 집는 Tree editor를 설명합니다.", "language": "ko", - "parentPath": "/" + "parentPath": "/", + "relatedDemoPath": "/demo/tree" }, { "path": "/demos", @@ -279,6 +303,7 @@ "path": "/docs/adapters", "label": "Adapter guide", "title": "Adapter Docs - json-document", + "heading": "json-document Adapters", "description": "Keyboard, Clipboard, Contenteditable 공식 adapter 계약을 설명합니다.", "language": "ko", "navigationGroup": "Adapters" @@ -288,21 +313,36 @@ "label": "Keyboard", "title": "Keyboard Adapter Live Demo - json-document", "description": "Official keyboard adapter: conventional chords become commands, then existing Intent, Clipboard, and History doors.", - "navigationGroup": "Adapters" + "navigationGroup": "Adapters", + "integration": { + "kind": "adapter", + "packageName": "@interactive-os/json-document-web", + "status": "available" + } }, { "path": "/adapters/clipboard", "label": "Clipboard", "title": "Clipboard Adapter Live Demo - json-document", "description": "Official clipboard adapter: native ClipboardEvent copy, cut, and paste bind to public editing contracts.", - "navigationGroup": "Adapters" + "navigationGroup": "Adapters", + "integration": { + "kind": "adapter", + "packageName": "@interactive-os/json-document-web", + "status": "available" + } }, { "path": "/adapters/contenteditable", "label": "Contenteditable", "title": "Contenteditable Adapter Live Demo - json-document", "description": "Official DOM adapter: a React contenteditable root leases native input and commits one JSON Document string pointer.", - "navigationGroup": "Adapters" + "navigationGroup": "Adapters", + "integration": { + "kind": "adapter", + "packageName": "@interactive-os/json-document-contenteditable", + "status": "available" + } }, { "path": "/connectors", @@ -315,6 +355,7 @@ "path": "/docs/connectors", "label": "Connector guide", "title": "Connector Docs - json-document", + "heading": "json-document Connectors", "description": "React, React Hook Form, Ajv, Zod, TanStack Table 연결 계약을 설명합니다.", "language": "ko", "navigationGroup": "Connectors" @@ -324,32 +365,53 @@ "label": "React", "title": "React Connector Live Demo - json-document", "description": "Live React integration for JSON Document subscriptions and component-owned editing lifecycle.", - "navigationGroup": "Connectors" + "navigationGroup": "Connectors", + "integration": { + "kind": "connector", + "packageName": "@interactive-os/json-document-react", + "status": "available" + } }, { "path": "/connectors/react-hook-form", "label": "React Hook Form", "title": "React Hook Form Connector Live Demo - json-document", "description": "Live React Hook Form drafts, canonical validation, atomic submit history, and undo/redo reset.", - "navigationGroup": "Connectors" + "navigationGroup": "Connectors", + "integration": { + "kind": "connector", + "packageName": "@interactive-os/json-document-react-hook-form", + "status": "available" + } }, { "path": "/connectors/ajv", "label": "Ajv", "title": "Ajv Connector Live Demo - json-document", "description": "Live Ajv validation with JSON Pointer diagnostics and non-adoption of validator mutations.", - "navigationGroup": "Connectors" + "navigationGroup": "Connectors", + "integration": { + "kind": "connector", + "packageName": "@interactive-os/json-document-ajv", + "status": "available" + } }, { "path": "/connectors/zod", "label": "Zod", "title": "Zod Connector Live Demo - json-document", "description": "A Zod object schema and record array become a Database admin table.", - "navigationGroup": "Connectors" + "navigationGroup": "Connectors", + "integration": { + "kind": "connector", + "packageName": "@interactive-os/json-document-zod", + "status": "available" + } }, { "path": "/connectors/zod/validate", "label": "Validate", + "relatedDemoLabel": "Validate commits", "title": "Zod Validate Live Demo - json-document", "description": "Live Zod validation with JSON Pointer diagnostics and canonical JSON preservation.", "parentPath": "/connectors/zod" @@ -359,6 +421,11 @@ "label": "TanStack Table", "title": "TanStack Table Connector Live Demo - json-document", "description": "Live TanStack Table v8 integration for visible-order Sheet selection, clipboard, history, and canonical JSON editing.", - "navigationGroup": "Connectors" + "navigationGroup": "Connectors", + "integration": { + "kind": "connector", + "packageName": "@interactive-os/json-document-tanstack-table", + "status": "available" + } } ] diff --git a/site/src/app/legacy-page-redirects.ts b/site/src/app/legacy-page-redirects.ts new file mode 100644 index 00000000..2428a023 --- /dev/null +++ b/site/src/app/legacy-page-redirects.ts @@ -0,0 +1,10 @@ +export const legacyPageRedirects = { + contenteditableConnector: { + from: "/connectors/contenteditable", + to: "/adapters/contenteditable", + }, + webConnector: { + from: "/connectors/web", + to: "/adapters", + }, +} as const; diff --git a/site/src/app/page-descriptors.ts b/site/src/app/page-descriptors.ts new file mode 100644 index 00000000..f94d2688 --- /dev/null +++ b/site/src/app/page-descriptors.ts @@ -0,0 +1,35 @@ +import siteRoutesJson from "../../site-routes.json"; +export { legacyPageRedirects } from "./legacy-page-redirects"; + +export type SiteNavigationGroup = "JSON Document" | "Editing" | "Editors" | "Adapters" | "Connectors"; +export type IntegrationKind = "adapter" | "connector"; + +export type SiteRoute = { + readonly path: string; + readonly label: string; + readonly title: string; + readonly heading?: string; + readonly description: string; + readonly language?: "en" | "ko"; + readonly navigationGroup?: SiteNavigationGroup; + readonly parentPath?: string; + readonly relatedDemoPath?: string; + readonly relatedDemoLabel?: string; + readonly integration?: { + readonly kind: IntegrationKind; + readonly packageName: string; + readonly status: "available" | "planned"; + }; +}; + +export const pageDescriptors = siteRoutesJson as ReadonlyArray; + +export function pageDescriptor(path: string): SiteRoute { + const descriptor = pageDescriptors.find((route) => route.path === path); + if (descriptor === undefined) throw new Error(`Unknown public page descriptor: ${path}`); + return descriptor; +} + +export function integrationPageDescriptors(kind: IntegrationKind): ReadonlyArray { + return pageDescriptors.filter((route) => route.integration?.kind === kind); +} diff --git a/site/src/app/router.tsx b/site/src/app/router.tsx index bee056cc..74c3b795 100644 --- a/site/src/app/router.tsx +++ b/site/src/app/router.tsx @@ -1,20 +1,9 @@ import { useEffect } from "react"; import { useRouterState } from "@tanstack/react-router"; -import siteRoutesJson from "../../site-routes.json"; +import { pageDescriptors, type SiteRoute } from "./page-descriptors"; -export type SiteNavigationGroup = "JSON Document" | "Editing" | "Editors" | "Adapters" | "Connectors"; - -export type SiteRoute = { - readonly path: string; - readonly label: string; - readonly title: string; - readonly description: string; - readonly language?: "en" | "ko"; - readonly navigationGroup?: SiteNavigationGroup; - readonly parentPath?: string; -}; - -export const siteRoutes = siteRoutesJson as SiteRoute[]; +export { type SiteNavigationGroup, type SiteRoute } from "./page-descriptors"; +export const siteRoutes = pageDescriptors; const siteUrl = (import.meta.env.VITE_SITE_URL ?? "https://developer-1px.github.io/json-document").replace(/\/$/, ""); diff --git a/site/src/app/routes/_page/adapters/contenteditable.tsx b/site/src/app/routes/_page/adapters/contenteditable.tsx index 9c2ad28b..eab8b6d3 100644 --- a/site/src/app/routes/_page/adapters/contenteditable.tsx +++ b/site/src/app/routes/_page/adapters/contenteditable.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ContentEditableConnectorDemoRoute } from "../../../../routes/connectors/contenteditable/ContentEditableConnectorDemoRoute"; +import { ContentEditableAdapterDemoRoute } from "../../../../routes/adapters/contenteditable/ContentEditableAdapterDemoRoute"; export const Route = createFileRoute("/_page/adapters/contenteditable")({ - component: ContentEditableConnectorDemoRoute, + component: ContentEditableAdapterDemoRoute, }); diff --git a/site/src/app/routes/_page/connectors/contenteditable.tsx b/site/src/app/routes/_page/connectors/contenteditable.tsx index 4cbb93fc..96c32c74 100644 --- a/site/src/app/routes/_page/connectors/contenteditable.tsx +++ b/site/src/app/routes/_page/connectors/contenteditable.tsx @@ -1,5 +1,6 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { legacyPageRedirects } from "../../../page-descriptors"; export const Route = createFileRoute("/_page/connectors/contenteditable")({ - component: () => , + component: () => , }); diff --git a/site/src/app/routes/_page/connectors/web.tsx b/site/src/app/routes/_page/connectors/web.tsx index cd244b82..aeaea1a0 100644 --- a/site/src/app/routes/_page/connectors/web.tsx +++ b/site/src/app/routes/_page/connectors/web.tsx @@ -1,5 +1,6 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { legacyPageRedirects } from "../../../page-descriptors"; export const Route = createFileRoute("/_page/connectors/web")({ - component: () => , + component: () => , }); diff --git a/site/src/routes/adapters/adapter-catalog.ts b/site/src/routes/adapters/adapter-catalog.ts index b6b1471c..90a1cb2f 100644 --- a/site/src/routes/adapters/adapter-catalog.ts +++ b/site/src/routes/adapters/adapter-catalog.ts @@ -1,5 +1,7 @@ +import { integrationPageDescriptors } from "../../app/page-descriptors"; + export type AdapterCatalogEntry = { - readonly id: "keyboard" | "clipboard" | "contenteditable"; + readonly id: string; readonly name: string; readonly packageName: string; readonly description: string; @@ -7,29 +9,12 @@ export type AdapterCatalogEntry = { readonly demoPath: string; }; -export const adapterCatalog: ReadonlyArray = [ - { - id: "keyboard", - name: "Keyboard", - packageName: "@interactive-os/json-document-web", - description: "Official keyboard adapter. Conventional chords become semantic commands, then existing Intent, Clipboard, and History doors.", - status: "available", - demoPath: "/adapters/keyboard", - }, - { - id: "clipboard", - name: "Clipboard", - packageName: "@interactive-os/json-document-web", - description: "Official clipboard adapter. Native ClipboardEvent copy, cut, and paste bind to editor clipboard contracts.", - status: "available", - demoPath: "/adapters/clipboard", - }, - { - id: "contenteditable", - name: "Contenteditable", - packageName: "@interactive-os/json-document-contenteditable", - description: "Official DOM adapter. A React contenteditable root leases native input for one JSON Document string pointer.", - status: "available", - demoPath: "/adapters/contenteditable", - }, -]; +export const adapterCatalog: ReadonlyArray = + integrationPageDescriptors("adapter").map((descriptor) => ({ + id: descriptor.path.slice("/adapters/".length), + name: descriptor.label, + packageName: descriptor.integration!.packageName, + description: descriptor.description, + status: descriptor.integration!.status, + demoPath: descriptor.path, + })); diff --git a/site/src/routes/connectors/contenteditable/ContentEditableConnectorDemoRoute.tsx b/site/src/routes/adapters/contenteditable/ContentEditableAdapterDemoRoute.tsx similarity index 73% rename from site/src/routes/connectors/contenteditable/ContentEditableConnectorDemoRoute.tsx rename to site/src/routes/adapters/contenteditable/ContentEditableAdapterDemoRoute.tsx index 313f26ab..b0a7f988 100644 --- a/site/src/routes/connectors/contenteditable/ContentEditableConnectorDemoRoute.tsx +++ b/site/src/routes/adapters/contenteditable/ContentEditableAdapterDemoRoute.tsx @@ -1,5 +1,5 @@ -import { ContentEditableConnectorLab } from "./ContentEditableConnectorLab"; -import { ConnectorDemoPage } from "../ConnectorDemoPage"; +import { ContentEditableAdapterLab } from "./ContentEditableAdapterLab"; +import { CatalogDemoPage } from "../../../shared/ui/catalog-demo-page"; const connectorCode = `const document = createJSONDocument({ title: "Shared title", @@ -8,9 +8,9 @@ const connectorCode = `const document = createJSONDocument({ `; -export function ContentEditableConnectorDemoRoute() { +export function ContentEditableAdapterDemoRoute() { return ( - - - + + ); } diff --git a/site/src/routes/connectors/contenteditable/ContentEditableConnectorLab.tsx b/site/src/routes/adapters/contenteditable/ContentEditableAdapterLab.tsx similarity index 98% rename from site/src/routes/connectors/contenteditable/ContentEditableConnectorLab.tsx rename to site/src/routes/adapters/contenteditable/ContentEditableAdapterLab.tsx index 02093795..a0b79a1b 100644 --- a/site/src/routes/connectors/contenteditable/ContentEditableConnectorLab.tsx +++ b/site/src/routes/adapters/contenteditable/ContentEditableAdapterLab.tsx @@ -5,7 +5,7 @@ import { ContentEditable } from "@interactive-os/json-document-contenteditable"; import { Inspector } from "../../../shared/ui/inspector"; import { classes, ui } from "../../../shared/ui/styles"; -export function ContentEditableConnectorLab() { +export function ContentEditableAdapterLab() { const [document] = useState(() => createJSONDocument({ title: "Edit this title here.\nAdd a second line.", note: "This note is independent.\nIt stays unchanged.", diff --git a/site/src/routes/connectors/ConnectorCatalogRoute.tsx b/site/src/routes/connectors/ConnectorCatalogRoute.tsx index 588c63e2..88a31846 100644 --- a/site/src/routes/connectors/ConnectorCatalogRoute.tsx +++ b/site/src/routes/connectors/ConnectorCatalogRoute.tsx @@ -22,20 +22,16 @@ export function ConnectorCatalogRoute() { {connector.packageName}

{connector.description}

- {connector.demoPath === null ? ( - Live Demo ships with the implementation. - ) : ( -
- - Open Live Demo +
+ + Open Live Demo + + {connector.moreDemos.map((demo) => ( + + {demo.label} - {connector.moreDemos?.map((demo) => ( - - {demo.label} - - ))} -
- )} + ))} +
))} diff --git a/site/src/routes/connectors/connector-catalog.ts b/site/src/routes/connectors/connector-catalog.ts index 8239dced..bc77a1b2 100644 --- a/site/src/routes/connectors/connector-catalog.ts +++ b/site/src/routes/connectors/connector-catalog.ts @@ -1,56 +1,27 @@ +import { integrationPageDescriptors, pageDescriptors } from "../../app/page-descriptors"; + export type ConnectorCatalogEntry = { - readonly id: "react" | "react-hook-form" | "ajv" | "zod" | "tanstack-table"; + readonly id: string; readonly name: string; readonly packageName: string; readonly description: string; readonly status: "available" | "planned"; - readonly demoPath: string | null; - readonly moreDemos?: ReadonlyArray<{ + readonly demoPath: string; + readonly moreDemos: ReadonlyArray<{ readonly path: string; readonly label: string; }>; }; -export const connectorCatalog: ReadonlyArray = [ - { - id: "react", - name: "React", - packageName: "@interactive-os/json-document-react", - description: "External-store subscription and component-owned editor lifecycle.", - status: "available", - demoPath: "/connectors/react", - }, - { - id: "react-hook-form", - name: "React Hook Form", - packageName: "@interactive-os/json-document-react-hook-form", - description: "Form drafts and field lifecycle connected to canonical commits and history.", - status: "available", - demoPath: "/connectors/react-hook-form", - }, - { - id: "ajv", - name: "Ajv", - packageName: "@interactive-os/json-document-ajv", - description: "Compiled Ajv validators translated into synchronous JSON Document diagnostics.", - status: "available", - demoPath: "/connectors/ajv", - }, - { - id: "zod", - name: "Zod", - packageName: "@interactive-os/json-document-zod", - description: "Zod object schemas translated into Database admin documents.", - status: "available", - demoPath: "/connectors/zod", - moreDemos: [{ path: "/connectors/zod/validate", label: "Validate commits" }], - }, - { - id: "tanstack-table", - name: "TanStack Table", - packageName: "@interactive-os/json-document-tanstack-table", - description: "Headless table rows and cell commits connected to JSON Patch.", - status: "available", - demoPath: "/connectors/tanstack-table", - }, -]; +export const connectorCatalog: ReadonlyArray = + integrationPageDescriptors("connector").map((descriptor) => ({ + id: descriptor.path.slice("/connectors/".length), + name: descriptor.label, + packageName: descriptor.integration!.packageName, + description: descriptor.description, + status: descriptor.integration!.status, + demoPath: descriptor.path, + moreDemos: pageDescriptors + .filter((candidate) => candidate.parentPath === descriptor.path) + .map(({ path, label, relatedDemoLabel }) => ({ path, label: relatedDemoLabel ?? label })), + })); diff --git a/site/src/routes/connectors/web/WebConnectorDemoRoute.tsx b/site/src/routes/connectors/web/WebConnectorDemoRoute.tsx deleted file mode 100644 index fa2cdd9c..00000000 --- a/site/src/routes/connectors/web/WebConnectorDemoRoute.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { WebConnectorLab } from "./WebConnectorLab"; -import { ConnectorDemoPage } from "../ConnectorDemoPage"; - -const connectorCode = `const keyboard = createWebKeyboardAdapter(); -const clipboard = createWebClipboardBinding({ - codec: documentClipboardCodec, - read: () => editor.copy(), - paste: (payload) => editor.dispatch({ - type: "clipboard.paste", - clipboard: payload, - }), -}); - -surface.addEventListener("keydown", (event) => { - const command = keyboard.resolve(event); -}); -surface.addEventListener("copy", (event) => clipboard.copy(event)); -surface.addEventListener("paste", (event) => clipboard.paste(event));`; - -export function WebConnectorDemoRoute() { - return ( - - - - ); -} diff --git a/site/src/routes/connectors/web/WebConnectorLab.tsx b/site/src/routes/connectors/web/WebConnectorLab.tsx deleted file mode 100644 index 4d068761..00000000 --- a/site/src/routes/connectors/web/WebConnectorLab.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent } from "react"; -import { - createDocumentEditor, - type BlockDocument, - type DocumentEditor, - type DocumentIntent, -} from "@interactive-os/json-document-editing"; -import { useEditingSnapshot } from "@interactive-os/json-document-react"; -import { - createWebClipboardBinding, - createWebKeyboardAdapter, - documentClipboardCodec, - lineBoundary, - moveLinePoint, - selectionOperationFromModifiers, - textInputFromControl, - type WebKeyboardCommand, -} from "@interactive-os/json-document-web"; -import { Inspector } from "../../../shared/ui/inspector"; -import { SelectableItem } from "../../../shared/ui/interactive"; -import { classes, ui } from "../../../shared/ui/styles"; - -const initialDocument: BlockDocument = { - blocks: [ - { id: "alpha", text: "Select this block and use the native clipboard." }, - { id: "beta", text: "Paste inserts a canonical block after the selection." }, - { id: "gamma", text: "Text input commits through the same editing history." }, - ], -}; - -export function WebConnectorLab() { - const [editor] = useState(() => createDocumentEditor(initialDocument)); - const [clipboard] = useState(() => createWebClipboardBinding({ - codec: documentClipboardCodec, - read: () => editor.copy(), - cut: () => editor.cut()?.result ?? { ok: false, code: "selection.empty" }, - paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload }), - })); - const [keyboard] = useState(() => createWebKeyboardAdapter()); - const snapshot = useEditingSnapshot(editor); - const [announcement, setAnnouncement] = useState("Click a block, then use arrows, Delete, or Mod+C / Mod+V"); - const [lastCommand, setLastCommand] = useState(null); - const [lastIntent, setLastIntent] = useState(null); - const document = snapshot.value as BlockDocument; - const selected = new Set(editor.selectedBlockIds); - - function focusId(): string | undefined { - const primary = snapshot.selection.primaryIndex; - return primary === null ? editor.selectedBlockIds.at(-1) : snapshot.selection.ranges[primary]?.focus.blockId; - } - - function handleCopy(event: ClipboardEvent) { - const result = clipboard.copy(event); - setAnnouncement(result.ok ? `Copied ${result.payload.blocks.length} structured block` : result.code); - } - - function handlePaste(event: ClipboardEvent) { - const result = clipboard.paste(event); - if (result.ok) setLastIntent({ type: "clipboard.paste", clipboard: result.payload }); - setAnnouncement(result.ok ? `Pasted ${result.payload.blocks.length} structured block` : result.code); - } - - function handleCut(event: ClipboardEvent) { - const result = clipboard.cut(event); - setAnnouncement(result.ok ? `Cut ${result.payload.blocks.length} structured block` : result.code); - } - - function select(event: MouseEvent, blockId: string) { - const intent = { type: "selection.set" as const, blockId, mode: selectionOperationFromModifiers(event) }; - const result = editor.dispatch(intent); - setLastIntent(intent); - setAnnouncement(result.ok ? `Selection ${intent.mode}` : result.code); - } - - function handleKeyDown(event: KeyboardEvent) { - const command = keyboard.resolve(event); - if (command === null) return; - const inField = (event.target as HTMLElement).closest("textarea, input, [contenteditable]"); - if (inField && command.type === "toggle" && event.key === " ") return; - if (inField && (command.type === "move" || command.type === "boundary" || command.type === "delete")) return; - - const ids = document.blocks.map((block) => block.id); - const current = focusId(); - if (command.type === "move" || command.type === "boundary") { - if (current === undefined) return; - const next = command.type === "move" - ? moveLinePoint(ids, current, command.direction) - : lineBoundary(ids, command.edge); - if (next === null) return; - event.preventDefault(); - setLastCommand(command); - const intent = { type: "selection.set" as const, blockId: next, mode: command.operation }; - const result = editor.dispatch(intent); - setLastIntent(intent); - setAnnouncement(result.ok ? `Keyboard ${command.operation}` : result.code); - return; - } - if (command.type === "toggle") { - if (current === undefined) return; - event.preventDefault(); - setLastCommand(command); - const intent = { type: "selection.set" as const, blockId: current, mode: "toggle" as const }; - const result = editor.dispatch(intent); - setLastIntent(intent); - setAnnouncement(result.ok ? "Keyboard toggle" : result.code); - return; - } - if (command.type === "delete") { - event.preventDefault(); - setLastCommand(command); - const intent = { type: "selection.remove" as const }; - const result = editor.dispatch(intent); - setLastIntent(intent); - setAnnouncement(result.ok ? "Keyboard delete" : result.code); - return; - } - event.preventDefault(); - setLastCommand(command); - const result = command.type === "redo" ? editor.redo() : editor.undo(); - setAnnouncement(result.ok ? (command.type === "redo" ? "Redone" : "Undone") : result.code); - } - - return ( -
-
- {announcement} - - revision {snapshot.revision} · {editor.selectedBlockIds.length} selected - {snapshot.canUndo ? " · undo" : ""} - {snapshot.canRedo ? " · redo" : ""} - -
- -
-
- {document.blocks.map((block) => ( - select(event, block.id)} - className={classes("p-3", ui.surface.workspace)} - > -