diff --git a/docs/docs/api/appkit/Variable.design.md b/docs/docs/api/appkit/Variable.design.md new file mode 100644 index 000000000..755e09049 --- /dev/null +++ b/docs/docs/api/appkit/Variable.design.md @@ -0,0 +1,5 @@ +# Variable: design + +```ts +const design: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ee6db8b67..fab63f199 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -190,6 +190,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | ------ | ------ | | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | +| [design](Variable.design.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [SUPERVISOR\_EXTENSION\_KEY](Variable.SUPERVISOR_EXTENSION_KEY.md) | Namespace key under which the adapter reads its hosted-tool payload from [AgentInput.extensions](Interface.AgentInput.md#extensions). Exported so the agents plugin and standalone `runAgent` (the producers) can write under the same key the adapter reads. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index dca9af23b..8f7b03f36 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -847,6 +847,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.aiSearch", label: "aiSearch" }, + { + type: "doc", + id: "api/appkit/Variable.design", + label: "design" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index b144a5c08..61bbe1ba5 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -694,6 +694,13 @@ When multiple series share one value axis, its ticks use the first `yKey`; each The `selected` prop adds declarative emphasis only to bar, pie, and donut charts. Line, area, scatter, heatmap, and radar charts ignore it because category-selection semantics are not defined for those chart types. +**Chart size.** By default a chart fills its container's height, so it fits the card or grid cell it's placed in, and is at least 160px tall when the container's height comes from its content. In a fixed-height card, give the chart's parent the remaining space (e.g. `className="flex-1 min-h-0"` on `CardContent` in a `flex flex-col` card). Pass `height` (pixels) for an exact size instead: + +```tsx + {/* fills its container */} + {/* exactly 208px */} +``` + **[Plotly](https://plotly.com/javascript/)** — pass the numeric specifier as `tickformat` and the literal currency symbol as `tickprefix`. Keeping them separate is necessary because d3's `$` marker is locale-driven and cannot represent arbitrary symbols: ```tsx diff --git a/docs/docs/plugins/design.md b/docs/docs/plugins/design.md new file mode 100644 index 000000000..56c874fe7 --- /dev/null +++ b/docs/docs/plugins/design.md @@ -0,0 +1,286 @@ +--- +sidebar_position: 20 +--- + +# Design plugin + + +:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). +::: + + +Dev-only design pages, for building a layout before it becomes part of the real app. The plugin only runs with `NODE_ENV=development`. In any other environment it does nothing, and `/design` returns 404. + +## Basic usage + +```ts +import { createApp, server } from "@databricks/appkit"; +import { design } from "@databricks/appkit/beta"; + +await createApp({ + plugins: [server(), design({ builderOrigin: "https://builder.example.com" })], +}); +``` + +## Configuration + +| Option | Type | Description | +| ------ | ---- | ----------- | +| `builderOrigin` | `string \| string[]` | Origin(s) of the app builder that embeds the app. The [design](#readiness) and [app](#app-readiness) ready messages and [draft errors](#draft-errors) are posted only to these origins. Must be a bare origin like `"https://builder.example.com"`; `"*"` is rejected. Defaults to the `APPKIT_DESIGN_BUILDER_ORIGIN` environment variable (one origin, or several separated by commas), so a builder's runtime can set it for a scaffolded `design()` with no options; an explicit `builderOrigin` wins. Optional: without either, no message is posted, and `/design/__status` still works. | + +## Pages + +| URL | File | +| --- | ---- | +| `/design` | `client/src/design/index.tsx` | +| `/design/` | `client/src/design/pages/.tsx` | + +Each file's default export is the page, rendered on its own at full size with the app's styles (`client/src/index.css`). Anything else under `/design` returns 404. The page names `index` and anything starting with `__` are reserved (`pages/index.tsx` would collide with `/design`, and `__status` is the plugin's own endpoint), so such files aren't served or listed. Pages hot-reload like the rest of the app. The requested page is loaded directly and other pages only when navigated to, so an unfinished draft only breaks its own URL. + +When the app has `react-router` (the app template does), design pages share a router whose basename is `/design`. Links between them stay inside `/design`, and keep working when the pages are later promoted into the app's own routes: + +```tsx +import { Link } from "react-router"; + +Orders; // goes to /design/orders +``` + +Without `react-router`, each page renders on its own; link with ``. + +```tsx +// client/src/design/index.tsx +import { Button, Card, CardContent, CardHeader, CardTitle } from "@databricks/appkit-ui/react"; + +export default function DesignPage() { + return ( + + + Orders + + + + + + ); +} +``` + +## Mapping elements to source + +In development, the dev server adds `data-source="file:line:col"` to every native JSX element, on design pages and app pages alike. A builder's element inspector can use it to map what was clicked back to the code that renders it. + +## Readiness + +A builder that embeds `/design` can wait until there is a real design to show, instead of revealing an empty page. `/design` is **ready** once `client/src/design/index.tsx` exists, isn't the template placeholder, and has painted in a browser since it last changed. **`ready` needs a browser**: nothing turns it true until a client loads `/design` and paints it (a hidden iframe is enough). To wait without a browser, use `compiled` and `waitingFor` (below). A page that throws while rendering (in the page file or any component it uses) shows the error in place and turns `ready` back to `false` until a render succeeds again. + +### What "ready" waits for + +After a successful render the page waits until it shows its real content before reporting: + +- web fonts have loaded (`document.fonts.ready`); +- no `Suspense` fallback is on screen (lazy components and data that suspend); +- no `fetch` started by the page is still in flight, for example chart data; +- the page's DOM hasn't changed for 300 ms (charts drawing, lists filling in); +- then two animation frames, so it has painted. + +It never reports while the page is the template placeholder or is showing a render error. The automatic waits give up after 10 seconds, so a stream that never ends can't hold the page back forever. + +### Holding readiness back: `useDesignReady` + +For content the page can't detect on its own (a chart that draws on a timer, an animation, data from a source that isn't `fetch`), hold readiness back explicitly. While any mounted component passes `false`, the page isn't ready, with no time limit: + +```tsx +import { useDesignReady } from "@databricks/appkit-ui/react/beta"; + +function TripsPerDay() { + const { data, loading } = useAnalyticsQuery("trips_per_day", {}); + useDesignReady(!loading && data != null); + return ; +} +``` + +It also holds back [app readiness](#app-readiness) on the app's own pages in development. Elsewhere (production, tests) it does nothing, so it can stay in components that are later promoted into the app. + +### Placeholder + +The app template's starter page is marked as a placeholder by this export. Delete it when you write the real page: + +```tsx +export const designPlaceholder = true; +``` + +### `GET /design/__status` + +```json +{ + "ready": true, + "pages": [ + { "name": "index", "path": "/design" }, + { "name": "orders", "path": "/design/orders" } + ] +} +``` + +`pages` lists every design page, index first, so a builder can show a page switcher. The status also carries: + +| Field | Meaning | +| ----- | ------- | +| `compiled` | The index page and every module of the app it imports, transitively, build (compiled server-side as a browser would load them, so no browser is needed; dependencies in `node_modules` aren't recompiled). | +| `compileError` | Why it doesn't, prefixed with the failing module (`src/design/Card.tsx: ...`). | +| `waitingFor` | What `ready` is waiting on: `"index"` (no `index.tsx`), `"placeholder"` (still the template page), `"compile"` (build error), `"paint"` (no browser has painted it yet), or `null` when ready. | +| `placeholder` | `index.tsx` is still the template placeholder. | +| `drafts`, `blockSchema`, `theme` | See [HTML drafts](#html-drafts). | + +An agent writing the page waits for `compiled: true`; a builder that wants to reveal it loads `/design` (hidden is fine) and waits for `ready` or the `appkit:design-ready` message. + +### `appkit:design-ready` message + +Once a design page is ready (see above) and again after each hot update that leaves it ready, the page posts this to `window.parent` (or `window.opener`), only to the configured `builderOrigin`s: + +```ts +interface DesignReadyMessage { + type: "appkit:design-ready"; + version: 1; + page: string; // name of the page that painted ("index" or ) + pages: { name: string; path: string }[]; +} +``` + +```ts +// In the app builder: +window.addEventListener("message", (event) => { + if (event.origin !== appOrigin) return; + if (event.data?.type === "appkit:design-ready" && event.data.version === 1) { + showDesign(event.data.pages); + } +}); +``` + +## App readiness + +The same idea for the real app, so a builder never reveals the template's default home page. In development, when `builderOrigin` is set, the plugin adds a small script to the app's own pages (not `/design`). Once a page has settled (the same waits as [design readiness](#what-ready-waits-for), including `useDesignReady`), and again after client-side navigation and hot updates, it posts: + +```ts +interface AppReadyMessage { + type: "appkit:app-ready"; + version: 1; + path: string; // location.pathname of the page that is ready +} +``` + +This is the signal for an app that skips `/design` (e.g. one that needs no data): the builder can load the app's `/` in a hidden frame and reveal it when `appkit:app-ready` arrives. Hidden frames get no animation frames, so on the app's pages the final wait for a paint gives up after 100 ms instead of waiting for frames forever. `/design` keeps its two-frame wait. + +It isn't posted while an element with the `data-appkit-template-home` attribute is on screen. The app template's default home page (`HomePage` in `client/src/App.tsx`) carries it; a builder can also look for it directly. Remove the attribute, or the page, when the app gets its real home page, and the next settle (a hot update counts) posts `appkit:app-ready`. + +```ts +// In the app builder: +window.addEventListener("message", (event) => { + if (event.origin !== appOrigin) return; + if (event.data?.type === "appkit:app-ready" && event.data.version === 1) { + showApp(event.data.path); + } +}); +``` + +## HTML drafts + +Quick layout drafts are plain HTML files in the app's `drafts/` folder, served in development at `/design/drafts/` (`drafts/dashboard.html` → `/design/drafts/dashboard`). A missing draft returns 404. + +Drafts share the app template's theme by linking `/design/drafts/theme.css` instead of inlining tokens: appkit-ui's CSS variables (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, `--chart-1` … ) for light, and for dark via `prefers-color-scheme`, plus the page background and text color at zero specificity, so the draft's own styles win: + +```html + +``` + +A draft is streamed while it's still being written: the plugin sends what the file holds and keeps appending what's added, until the file ends with `` (or stops changing for 8 seconds). So a draft fills in on screen as the agent writes it. Editing a finished draft reloads it. + +### Drafts streamed by an app builder + +A builder that streams a draft itself (from its own URL, while the agent is still writing it) can use the same theme and hydrator by linking them from the app's dev server: + +```html + + + + + + + + +``` + +- `/design/drafts/__hydrator.js?draft=` is a stable URL for the hydrator. `draft` names the draft in error reports (it defaults to `stream`). +- `/design/drafts/theme.css` is the theme stylesheet described above. +- Both URLs are listed in `/design/__status` (`hydrator`, `theme`). The plugin's endpoints and the modules the hydrator loads answer CORS for the configured `builderOrigin`s, so the draft page can be on the builder's own origin. +- Errors of a streamed draft are reported like any other; in `/design/__status` such drafts are listed with `path: null` and `streamed: true`. + +Each block shows a skeleton while its JSON is still arriving, and the rendered component replaces it in place with a short fade-in (instant with `prefers-reduced-motion`). + +### `data-appkit` blocks + +Costly parts of a draft (charts, tables, KPI cards) are written as JSON blocks instead of hand-built HTML. The plugin injects one script into every draft that renders each block with the real appkit-ui component as soon as its JSON is complete, even while the rest of the file is still streaming. It uses no CDN and no network, and theme colors follow the app's styles and the system light/dark preference. Each block renders in its own shadow root, so the draft's own CSS and the components' styles don't affect each other. + +```html + +``` + +Data the drafts share can live in one `drafts/mock.json` instead of inline: a JSON object keyed by name, served at `GET /design/drafts/mock.json` (linked from `/design/__status` as `mock`). A block with `data-src="mock#"` takes the key from `mock.datasets` when it's there, else from the top level (so `{"datasets": {"trips": [...]}}` and `{"trips": [...]}` both work): an array becomes its `data` (`rows` for a `DataTable`), an object is merged into its props. Props written in the block win, and the block may hold no JSON at all: + +```html + + +``` + +The page fetches `mock.json` once. Until it's there and parses (the agent may write it after the drafts), those blocks show their skeleton and the page asks again every second; after 30 seconds they show an error. A key that isn't in the file fails just that block, reported like any other draft error. + +The exact props of each component are published as JSON Schema (2020-12), with an example per component, in the package as `@databricks/appkit-ui/design/components.schema.json` (`node_modules/@databricks/appkit-ui/dist/design/components.schema.json`), and served at `GET /design/__data-appkit.schema.json` (linked from `/design/__status` as `blockSchema`). Read it to write valid blocks. In short: + +| Component | Required | Optional | +| --------- | -------- | -------- | +| `BarChart` | either `x` (non-empty array of labels) and `series` (non-empty array of `{ name: string, data: (number \| null)[] }`, one value per label), or `data` (array of row objects) | `xKey`, `yKey` (with `data`), `title`, `height`, `showLegend`, `colors`, `ariaLabel`, `orientation` (`"vertical"` \| `"horizontal"`), `stacked` | +| `LineChart` | same as `BarChart` | as `BarChart` without `stacked`, plus `smooth`, `showSymbol` | +| `AreaChart` | same as `BarChart` | as `LineChart`, plus `stacked` | +| `PieChart`, `DonutChart` | same as `BarChart`; one series, `x` are the slice names | `title`, `height`, `showLegend`, `colors`, `ariaLabel`, `innerRadius` (0-100), `showLabels`, `labelPosition` (`"outside"` \| `"inside"` \| `"center"`) | +| `DataTable` | `columns`: non-empty array of column names or `{ key: string, label?: string }`; `rows`: array of objects keyed by column key, or arrays in column order | | +| `KpiCard` | `label`: string; `value`: string or number | `delta` (string or number), `hint` (string) | + +`DataTable` renders with appkit-ui's `Table` primitives (the `DataTable` component itself loads its rows through a query), and `KpiCard` with appkit-ui's `Card` parts. Charts take their colors from the app's theme tokens, which follow the system light/dark preference, also in a draft with a dark background. + +Each block renders in place, in the element that holds its ` blocks in drafts. Each block is one JSON object matching its component's schema. A block with data-src=\"mock#\" takes its data from drafts/mock.json (one JSON object; the key is looked up in its \"datasets\" object first, then at the top level): an array becomes the block's data (rows for a DataTable), an object is merged into its props. Props written in the block win; the block may hold no JSON at all.", + "components": { + "BarChart": { + "description": "Bar chart (appkit-ui BarChart).", + "schema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["x", "series"], + "properties": { + "x": { + "type": "array", + "minItems": 1, + "items": { + "type": ["string", "number"] + }, + "description": "Category or x-axis labels." + }, + "series": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": ["number", "null"] + }, + "description": "One value per x label (same length as x)." + } + } + } + } + } + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows, e.g. [{\"day\":\"Mon\",\"trips\":12}]." + }, + "xKey": { + "type": "string", + "description": "Row field for x / categories. Auto-detected." + }, + "yKey": { + "type": ["string", "array"], + "items": { + "type": "string" + }, + "description": "Row field(s) for values. Auto-detected." + } + } + } + ], + "properties": { + "title": { + "type": "string", + "description": "Heading shown above the chart. Leave it out when the block's container already has its own heading." + }, + "height": { + "type": "number", + "description": "Height in pixels. By default the chart fills its container's height (at least 160px)." + }, + "showLegend": { + "type": "boolean", + "description": "Show the legend." + }, + "colors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series colors (CSS colors); defaults to the theme palette." + }, + "ariaLabel": { + "type": "string" + }, + "orientation": { + "enum": ["vertical", "horizontal"], + "description": "Default vertical." + }, + "stacked": { + "type": "boolean" + } + } + }, + "example": { + "title": "Trips per day", + "x": ["Mon", "Tue", "Wed"], + "series": [ + { + "name": "Trips", + "data": [12, 19, 7] + } + ] + } + }, + "LineChart": { + "description": "Line chart (appkit-ui LineChart).", + "schema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["x", "series"], + "properties": { + "x": { + "type": "array", + "minItems": 1, + "items": { + "type": ["string", "number"] + }, + "description": "Category or x-axis labels." + }, + "series": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": ["number", "null"] + }, + "description": "One value per x label (same length as x)." + } + } + } + } + } + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows, e.g. [{\"day\":\"Mon\",\"trips\":12}]." + }, + "xKey": { + "type": "string", + "description": "Row field for x / categories. Auto-detected." + }, + "yKey": { + "type": ["string", "array"], + "items": { + "type": "string" + }, + "description": "Row field(s) for values. Auto-detected." + } + } + } + ], + "properties": { + "title": { + "type": "string", + "description": "Heading shown above the chart. Leave it out when the block's container already has its own heading." + }, + "height": { + "type": "number", + "description": "Height in pixels. By default the chart fills its container's height (at least 160px)." + }, + "showLegend": { + "type": "boolean", + "description": "Show the legend." + }, + "colors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series colors (CSS colors); defaults to the theme palette." + }, + "ariaLabel": { + "type": "string" + }, + "orientation": { + "enum": ["vertical", "horizontal"], + "description": "Default vertical." + }, + "smooth": { + "type": "boolean" + }, + "showSymbol": { + "type": "boolean" + } + } + }, + "example": { + "title": "Revenue", + "x": ["Jan", "Feb", "Mar"], + "series": [ + { + "name": "2025", + "data": [10, 14, 13] + }, + { + "name": "2026", + "data": [12, 18, 21] + } + ] + } + }, + "AreaChart": { + "description": "Area chart (appkit-ui AreaChart).", + "schema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["x", "series"], + "properties": { + "x": { + "type": "array", + "minItems": 1, + "items": { + "type": ["string", "number"] + }, + "description": "Category or x-axis labels." + }, + "series": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": ["number", "null"] + }, + "description": "One value per x label (same length as x)." + } + } + } + } + } + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows, e.g. [{\"day\":\"Mon\",\"trips\":12}]." + }, + "xKey": { + "type": "string", + "description": "Row field for x / categories. Auto-detected." + }, + "yKey": { + "type": ["string", "array"], + "items": { + "type": "string" + }, + "description": "Row field(s) for values. Auto-detected." + } + } + } + ], + "properties": { + "title": { + "type": "string", + "description": "Heading shown above the chart. Leave it out when the block's container already has its own heading." + }, + "height": { + "type": "number", + "description": "Height in pixels. By default the chart fills its container's height (at least 160px)." + }, + "showLegend": { + "type": "boolean", + "description": "Show the legend." + }, + "colors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series colors (CSS colors); defaults to the theme palette." + }, + "ariaLabel": { + "type": "string" + }, + "orientation": { + "enum": ["vertical", "horizontal"], + "description": "Default vertical." + }, + "smooth": { + "type": "boolean" + }, + "showSymbol": { + "type": "boolean" + }, + "stacked": { + "type": "boolean" + } + } + }, + "example": { + "title": "Signups", + "data": [ + { + "week": "W1", + "signups": 40 + }, + { + "week": "W2", + "signups": 55 + } + ], + "xKey": "week", + "yKey": "signups" + } + }, + "PieChart": { + "description": "Pie chart (appkit-ui PieChart). One series; x are the slice names.", + "schema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["x", "series"], + "properties": { + "x": { + "type": "array", + "minItems": 1, + "items": { + "type": ["string", "number"] + }, + "description": "Category or x-axis labels." + }, + "series": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": ["number", "null"] + }, + "description": "One value per x label (same length as x)." + } + } + } + } + } + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows, e.g. [{\"day\":\"Mon\",\"trips\":12}]." + }, + "xKey": { + "type": "string", + "description": "Row field for x / categories. Auto-detected." + }, + "yKey": { + "type": ["string", "array"], + "items": { + "type": "string" + }, + "description": "Row field(s) for values. Auto-detected." + } + } + } + ], + "properties": { + "title": { + "type": "string", + "description": "Heading shown above the chart. Leave it out when the block's container already has its own heading." + }, + "height": { + "type": "number", + "description": "Height in pixels. By default the chart fills its container's height (at least 160px)." + }, + "showLegend": { + "type": "boolean", + "description": "Show the legend." + }, + "colors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series colors (CSS colors); defaults to the theme palette." + }, + "ariaLabel": { + "type": "string" + }, + "innerRadius": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Inner radius in %." + }, + "showLabels": { + "type": "boolean", + "description": "Labels on slices. Default true." + }, + "labelPosition": { + "enum": ["outside", "inside", "center"] + } + } + }, + "example": { + "title": "Trips by city", + "x": ["Madrid", "Rome", "Paris"], + "series": [ + { + "name": "Trips", + "data": [320, 280, 150] + } + ] + } + }, + "DonutChart": { + "description": "Donut chart (appkit-ui DonutChart). One series; x are the slice names.", + "schema": { + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["x", "series"], + "properties": { + "x": { + "type": "array", + "minItems": 1, + "items": { + "type": ["string", "number"] + }, + "description": "Category or x-axis labels." + }, + "series": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": ["number", "null"] + }, + "description": "One value per x label (same length as x)." + } + } + } + } + } + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Rows, e.g. [{\"day\":\"Mon\",\"trips\":12}]." + }, + "xKey": { + "type": "string", + "description": "Row field for x / categories. Auto-detected." + }, + "yKey": { + "type": ["string", "array"], + "items": { + "type": "string" + }, + "description": "Row field(s) for values. Auto-detected." + } + } + } + ], + "properties": { + "title": { + "type": "string", + "description": "Heading shown above the chart. Leave it out when the block's container already has its own heading." + }, + "height": { + "type": "number", + "description": "Height in pixels. By default the chart fills its container's height (at least 160px)." + }, + "showLegend": { + "type": "boolean", + "description": "Show the legend." + }, + "colors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series colors (CSS colors); defaults to the theme palette." + }, + "ariaLabel": { + "type": "string" + }, + "innerRadius": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Inner radius in %." + }, + "showLabels": { + "type": "boolean", + "description": "Labels on slices. Default true." + }, + "labelPosition": { + "enum": ["outside", "inside", "center"] + } + } + }, + "example": { + "title": "Trips by city", + "x": ["Madrid", "Rome", "Paris"], + "series": [ + { + "name": "Trips", + "data": [320, 280, 150] + } + ] + } + }, + "DataTable": { + "description": "Static table (appkit-ui Table).", + "schema": { + "type": "object", + "required": ["columns", "rows"], + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "string", + "description": "Field name, also used as the header." + }, + { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "type": "string", + "description": "Row field." + }, + "label": { + "type": "string", + "description": "Header; defaults to key." + } + } + } + ] + } + }, + "rows": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "description": "Keyed by column key." + }, + { + "type": "array", + "description": "Values in column order." + } + ] + } + } + } + }, + "example": { + "columns": [ + { + "key": "city", + "label": "City" + }, + { + "key": "trips", + "label": "Trips" + } + ], + "rows": [ + { + "city": "Madrid", + "trips": 320 + }, + ["Rome", 280] + ] + } + }, + "KpiCard": { + "description": "KPI card (appkit-ui Card).", + "schema": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": ["string", "number"] + }, + "delta": { + "type": ["string", "number"], + "description": "E.g. \"+4% vs last week\"." + }, + "hint": { + "type": "string" + } + } + }, + "example": { + "label": "Total trips", + "value": "1,204", + "delta": "+4% vs last week" + } + } + } +} diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 5b1053b14..6b5f6d0dd 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -75,3 +75,7 @@ export type { UseDatabaseWriteOptions, UseDatabaseWriteState, } from "./hooks/use-database-write"; + +// Readiness hint for pages served by the dev-only `design` plugin, which +// ships at beta from '@databricks/appkit/beta'. +export { useDesignReady } from "./hooks/use-design-ready"; diff --git a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx index 06439bee2..49daa4668 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx +++ b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx @@ -294,3 +294,40 @@ describe("BaseChart ECharts registration", () => { expect(registrationErrors).toEqual([]); }); }); + +describe("BaseChart sizing", () => { + test("fills its parent's height by default, with a minimum height", async () => { + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("canvas")).not.toBeNull(), + ); + const root = container.firstElementChild as HTMLElement; + expect(root.style.height).toBe("100%"); + expect(root.style.minHeight).toBe("160px"); + }); + + test("an explicit height is exactly that tall", async () => { + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("canvas")).not.toBeNull(), + ); + const root = container.firstElementChild as HTMLElement; + expect(root.style.height).toBe("208px"); + expect(root.style.minHeight).toBe(""); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts b/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts new file mode 100644 index 000000000..9bddcfbc2 --- /dev/null +++ b/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts @@ -0,0 +1,28 @@ +import { describe, expectTypeOf, test } from "vitest"; + +import type { BarChartProps, ChartDataInput, ChartRow } from "../types"; + +// A pure data module, typed with an interface (no index signature) and kept +// readonly: it must be usable as chart data without refactoring. +interface Trip { + day: string; + trips: number; +} +const trips: readonly Trip[] = [ + { day: "Mon", trips: 12 }, + { day: "Tue", trips: 19 }, +] as const; + +describe("chart data input types", () => { + test("interface-typed and readonly rows are valid chart data", () => { + expectTypeOf(trips).toMatchTypeOf(); + expectTypeOf<{ data: readonly Trip[] }>().toMatchTypeOf< + Pick, "data"> + >(); + }); + + test("ChartRow has an index signature for annotating data modules", () => { + const rows: ChartRow[] = [{ day: "Mon", trips: 12 }]; + expectTypeOf(rows[0]).toEqualTypeOf>(); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx b/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx new file mode 100644 index 000000000..c99f86454 --- /dev/null +++ b/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx @@ -0,0 +1,44 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; + +import { CHART_MIN_HEIGHT, chartSizeStyle } from "../constants"; +import { ChartWrapper } from "../wrapper"; + +afterEach(cleanup); + +describe("chartSizeStyle", () => { + test("fills the parent by default, never below the minimum", () => { + expect(chartSizeStyle()).toEqual({ + height: "100%", + minHeight: CHART_MIN_HEIGHT, + }); + }); + + test("an explicit height wins", () => { + expect(chartSizeStyle(208)).toEqual({ height: 208 }); + }); +}); + +describe("ChartWrapper sizing", () => { + test("its box fills the parent by default", () => { + render( + + {() => chart} + , + ); + const box = screen.getByTestId("w"); + expect(box.style.height).toBe("100%"); + expect(box.style.minHeight).toBe(`${CHART_MIN_HEIGHT}px`); + }); + + test("and is exactly `height` tall when given", () => { + render( + + {() => chart} + , + ); + const box = screen.getByTestId("w"); + expect(box.style.height).toBe("208px"); + expect(box.style.minHeight).toBe(""); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 814fd4b67..3530eac6f 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -28,6 +28,7 @@ import { LegacyGridContainLabel } from "echarts/features"; import { CanvasRenderer } from "echarts/renderers"; import { useCallback, useMemo, useRef } from "react"; +import { chartSizeStyle } from "./constants"; import { normalizeChartData, normalizeHeatmapData } from "./normalize"; import { applySelectionEmphasis, @@ -123,7 +124,10 @@ export interface BaseChartProps { yKey?: string | string[]; /** Chart orientation @default "vertical" */ orientation?: Orientation; - /** Chart height in pixels @default 300 */ + /** + * Chart height in pixels. By default the chart fills its parent's height + * (at least 160px). + */ height?: number; /** Chart title */ title?: string; @@ -204,7 +208,7 @@ export function BaseChart({ xKey, yKey, orientation, - height = 300, + height, title, showLegend = true, colorPalette, @@ -433,7 +437,10 @@ export function BaseChart({ if (!option) { return ( -
+
No data
); @@ -444,7 +451,7 @@ export function BaseChart({ ref={chartRefCallback} echarts={echarts} option={option} - style={{ height }} + style={chartSizeStyle(height)} className={className} opts={{ renderer: "canvas" }} notMerge={false} diff --git a/packages/appkit-ui/src/react/charts/constants.ts b/packages/appkit-ui/src/react/charts/constants.ts index 83674251a..57ba0813d 100644 --- a/packages/appkit-ui/src/react/charts/constants.ts +++ b/packages/appkit-ui/src/react/charts/constants.ts @@ -122,3 +122,21 @@ export const FALLBACK_UI_TOKENS: ChartUITokens = { grid: "hsla(240, 5%, 90%, 1)", // ≈ --border tooltipBg: "hsla(0, 0%, 100%, 1)", // ≈ --popover }; + +/** Smallest height a chart takes when it fills its parent (no `height` prop). */ +export const CHART_MIN_HEIGHT = 160; + +/** + * Size of a chart's box. With an explicit `height` (pixels) the chart is + * exactly that tall. Without one it fills its parent's height, so it fits the + * card or grid cell it's placed in, and falls back to {@link CHART_MIN_HEIGHT} + * when the parent's height comes from its content. + */ +export function chartSizeStyle(height?: number): { + height: number | string; + minHeight?: number; +} { + return height !== undefined + ? { height } + : { height: "100%", minHeight: CHART_MIN_HEIGHT }; +} diff --git a/packages/appkit-ui/src/react/charts/create-chart.tsx b/packages/appkit-ui/src/react/charts/create-chart.tsx index bca331947..d040257ba 100644 --- a/packages/appkit-ui/src/react/charts/create-chart.tsx +++ b/packages/appkit-ui/src/react/charts/create-chart.tsx @@ -1,5 +1,5 @@ import { BaseChart } from "./base"; -import type { ChartType, UnifiedChartProps } from "./types"; +import type { ChartData, ChartType, UnifiedChartProps } from "./types"; import { ChartWrapper } from "./wrapper"; /** @@ -30,7 +30,7 @@ export function createChart( // Data props data, // Common props - height = 300, + height, className, ariaLabel, testId, @@ -50,7 +50,9 @@ export function createChart( const wrapperProps = data !== undefined - ? { data, height, className, ariaLabel, testId } + ? // ChartDataInput also admits interface-typed and readonly rows; at + // runtime they're the same plain objects the chart reads. + { data: data as ChartData, height, className, ariaLabel, testId } : { queryKey: queryKey as string, parameters, diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index 589a9cd90..0cd6723b4 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -108,6 +108,8 @@ export type { ChartClickDatum, ChartColorPalette, ChartData, + ChartDataInput, + ChartRow, ChartType, ChartUITokens, ChartValueFormatter, diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 3def1b913..75ffa762b 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -38,6 +38,19 @@ export type ChartType = /** Data that can be passed to unified charts */ export type ChartData = Table | Record[]; +/** + * One row of chart data. Has an index signature, so it can annotate plain data + * modules (`export const trips: ChartRow[] = [...]`). + */ +export type ChartRow = Record; + +/** + * What a chart's `data` prop accepts: an Arrow Table or rows. Rows may be + * typed with an interface (no index signature needed) and may be readonly + * (`as const`), so existing data modules work as they are. + */ +export type ChartDataInput = Table | readonly object[]; + /** Formats a measure value for chart axes, tooltips, and value labels. */ export type ChartValueFormatter = ( value: string | number, @@ -78,7 +91,10 @@ export interface ChartBaseProps { colorPalette?: ChartColorPalette; /** Custom colors for series (overrides colorPalette) */ colors?: string[]; - /** Chart height in pixels @default 300 */ + /** + * Chart height in pixels. By default the chart fills its parent's height + * (at least 160px), so it fits the card or grid cell it's in. + */ height?: number; /** Additional CSS classes */ className?: string; @@ -188,7 +204,7 @@ export interface QueryProps extends ChartBaseProps { /** Props for direct data injection */ export interface DataProps extends ChartBaseProps { /** Arrow Table or JSON array */ - data: ChartData; + data: ChartDataInput; // Discriminator: cannot use query props with direct data queryKey?: never; diff --git a/packages/appkit-ui/src/react/charts/wrapper.tsx b/packages/appkit-ui/src/react/charts/wrapper.tsx index ead715c6c..3b5ef24a0 100644 --- a/packages/appkit-ui/src/react/charts/wrapper.tsx +++ b/packages/appkit-ui/src/react/charts/wrapper.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import type { WarehouseStatus } from "../hooks/types"; import { useChartData } from "../hooks/use-chart-data"; import { ChartErrorBoundary } from "./chart-error-boundary"; +import { chartSizeStyle } from "./constants"; import { EmptyState } from "./empty"; import { ErrorState } from "./error"; import { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading"; @@ -54,7 +55,7 @@ interface ChartWrapperDataProps { } interface CommonProps { - /** Chart height in pixels */ + /** Chart height in pixels. By default the chart fills its parent's height (at least 160px). */ height?: number; /** Additional CSS classes */ className?: string; @@ -92,9 +93,19 @@ function QueryModeContent({ }); if (isWarehouseWaiting(loading, data, warehouseStatus)) { - return ; + return ( +
+ +
+ ); + } + if (loading) { + return ( +
+ +
+ ); } - if (loading) return ; if (error) return ; if (isEmpty || !data) return ; @@ -104,7 +115,7 @@ function QueryModeContent({ >
void }; +const win = window as { __appkitDesign?: Host }; + +function Probe({ ready }: { ready?: boolean }) { + useDesignReady(ready); + return null; +} + +afterEach(() => { + cleanup(); + delete win.__appkitDesign; +}); + +describe("useDesignReady", () => { + test("holds readiness while false and releases it, notifying the page", () => { + const changed = vi.fn(); + win.__appkitDesign = { blockers: 0, changed }; + + const { rerender, unmount } = render(); + expect(win.__appkitDesign.blockers).toBe(1); + + rerender(); + expect(win.__appkitDesign.blockers).toBe(0); + expect(changed).toHaveBeenCalledTimes(1); + + rerender(); + unmount(); + expect(win.__appkitDesign.blockers).toBe(0); + expect(changed).toHaveBeenCalledTimes(2); + }); + + test("ready by default", () => { + win.__appkitDesign = { blockers: 0 }; + render(); + expect(win.__appkitDesign.blockers).toBe(0); + }); + + test("does nothing outside /design", () => { + expect(() => render()).not.toThrow(); + expect(win.__appkitDesign).toBeUndefined(); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/use-design-ready.ts b/packages/appkit-ui/src/react/hooks/use-design-ready.ts new file mode 100644 index 000000000..6cac3f917 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-design-ready.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect } from "react"; + +/** + * Readiness bookkeeping shared with the `/design` page entry served by the + * `design()` plugin. + * + * CONTRACT: the design page entry and the app-ready script + * (packages/appkit/src/plugins/design/vite-plugin.ts) create + * `window.__appkitDesign` and read `blockers` / `changed`. Keep the key and + * shape in sync with them. + */ +interface DesignHost { + blockers: number; + changed?: () => void; +} + +/** + * Holds back a `/design` page's readiness while `ready` is `false`, for + * content the page can't detect on its own: data that loads after a delay, + * a chart that draws asynchronously, an animation that must finish first. + * While any mounted component passes `false`, the page isn't reported ready + * to an embedding app builder (`appkit:design-ready` and `/design/__status` on + * `/design`, `appkit:app-ready` on the app's own pages in development). + * + * Without the `design()` plugin's dev scripts (production, tests) it does + * nothing. + * + * @example + * ```tsx + * function TripsChart() { + * const { data, loading } = useAnalyticsQuery("trips_per_day", {}); + * useDesignReady(!loading && data != null); + * return ; + * } + * ``` + */ +export function useDesignReady(ready = true): void { + useLayoutEffect(() => { + if (ready) return; + const host = (window as { __appkitDesign?: DesignHost }).__appkitDesign; + if (!host) return; + host.blockers++; + return () => { + host.blockers--; + host.changed?.(); + }; + }, [ready]); +} diff --git a/packages/appkit-ui/tsdown.config.ts b/packages/appkit-ui/tsdown.config.ts index c56393287..dfe72c914 100644 --- a/packages/appkit-ui/tsdown.config.ts +++ b/packages/appkit-ui/tsdown.config.ts @@ -27,6 +27,12 @@ export default defineConfig([ to: "dist", rename: "styles.css", }, + // Props schema of the data-appkit draft components (generated by + // tools/generate-design-schema.ts). + { + from: "src/design/components.schema.json", + to: "dist/design", + }, ], clean: false, hash: false, @@ -53,6 +59,14 @@ export default defineConfig([ default: dist, }; + const schemaDist = "./dist/design/components.schema.json"; + pkg["./design/components.schema.json"] = context.isPublish + ? schemaDist + : { + development: "./src/design/components.schema.json", + default: schemaDist, + }; + return pkg; }, }, diff --git a/packages/appkit/src/plugins/analytics/query.ts b/packages/appkit/src/plugins/analytics/query.ts index 4b57b051b..25b8b3230 100644 --- a/packages/appkit/src/plugins/analytics/query.ts +++ b/packages/appkit/src/plugins/analytics/query.ts @@ -4,6 +4,7 @@ import { isSQLTypeMarker, type SQLTypeMarker, sql as sqlHelpers } from "shared"; import { getWorkspaceId } from "../../context"; import { ValidationError } from "../../errors"; +import { extractParameters } from "../../utils/sql-parameters"; import type { StatementParameter } from "../../workspace-client"; type SQLParameterValue = SQLTypeMarker | null | undefined; @@ -15,9 +16,8 @@ export class QueryProcessor { ): Promise> { const processed = { ...parameters }; - // extract all params from the query - const paramMatches = query.matchAll(/:([a-zA-Z_]\w*)/g); - const queryParams = new Set(Array.from(paramMatches, (m) => m[1])); + // extract all params from the query (not :x inside literals or comments) + const queryParams = new Set(extractParameters(query)); // auto-inject workspaceId if needed and not provided if (queryParams.has("workspaceId") && !processed.workspaceId) { @@ -41,9 +41,8 @@ export class QueryProcessor { const sqlParameters: StatementParameter[] = []; if (parameters) { - // extract all params from the query - const queryParamMatches = query.matchAll(/:([a-zA-Z_]\w*)/g); - const queryParams = new Set(Array.from(queryParamMatches, (m) => m[1])); + // extract all params from the query (not :x inside literals or comments) + const queryParams = new Set(extractParameters(query)); // only allow parameters that exist in the query for (const key of Object.keys(parameters)) { diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7e556ebd9..aab6fb478 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,3 +7,4 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; +export { design } from "./design"; diff --git a/packages/appkit/src/plugins/design/drafts.ts b/packages/appkit/src/plugins/design/drafts.ts new file mode 100644 index 000000000..ceae227c5 --- /dev/null +++ b/packages/appkit/src/plugins/design/drafts.ts @@ -0,0 +1,1061 @@ +import fs from "node:fs"; +import type { ServerResponse } from "node:http"; +import path from "node:path"; + +/** `/design/drafts/` → `drafts/.html` (relative to the app). */ +export const DRAFT_ROUTE = /^\/design\/drafts\/([A-Za-z0-9_-]+)\/?$/; +const DRAFT_FILE = /^([A-Za-z0-9_-]+)\.html$/; + +/** A draft as listed in `/design/__status`. */ +interface DraftInfo { + name: string; + path: string; +} + +/** Every `drafts/*.html`, by name. @internal */ +export function listDrafts(projectRoot: string): DraftInfo[] { + const dir = path.join(projectRoot, "drafts"); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .map((f) => DRAFT_FILE.exec(f)?.[1]) + .filter((n): n is string => !!n) + .sort() + .map((name) => ({ name, path: `/design/drafts/${name}` })); +} + +/** The draft file for a name, or null when there is none. @internal */ +export function draftFile(projectRoot: string, name: string): string | null { + const file = path.join(projectRoot, "drafts", `${name}.html`); + return fs.existsSync(file) ? file : null; +} + +/** + * Validates a `data-appkit` block and maps it to the appkit-ui component to + * render. Throws an Error whose message is shown in the draft and sent back to + * the agent. + * + * Runs in the browser too: the hydrator embeds it via `toString()`, so it must + * stay self-contained (no imports, no outer references). + * + * @internal + */ +export function normalizeBlock( + kind: string, + props: unknown, +): { component: string; props: Record } { + const CHARTS = [ + "BarChart", + "LineChart", + "AreaChart", + "PieChart", + "DonutChart", + ]; + const ALLOWED = [...CHARTS, "DataTable", "KpiCard"]; + if (!ALLOWED.includes(kind)) { + throw new Error( + `Unknown data-appkit component "${kind}". Allowed: ${ALLOWED.join(", ")}.`, + ); + } + if (!props || typeof props !== "object" || Array.isArray(props)) { + throw new Error(`${kind}: the block must be a JSON object of props.`); + } + const p = props as Record; + + if (CHARTS.includes(kind)) { + // Native chart props: rows in `data`, optional xKey / yKey. + if (Array.isArray(p.data)) { + if (p.data.some((row) => !row || typeof row !== "object")) { + throw new Error(`${kind}: "data" must be an array of row objects.`); + } + return { component: kind, props: p }; + } + // Compact form: { x: [...labels], series: [{ name, data: [...] }] }. + const { x, series, ...rest } = p; + if (!Array.isArray(x)) { + throw new Error( + `${kind}: needs "data" (rows) or "x" (labels) with "series".`, + ); + } + if (!Array.isArray(series) || series.length === 0) { + throw new Error(`${kind}: "series" must be a non-empty array.`); + } + const names: string[] = []; + for (const [i, s] of series.entries()) { + const item = s as { name?: unknown; data?: unknown }; + if (!item || typeof item.name !== "string" || !Array.isArray(item.data)) { + throw new Error( + `${kind}: series[${i}] needs a "name" and a "data" array.`, + ); + } + if (item.data.length !== x.length) { + throw new Error( + `${kind}: series "${item.name}" has ${item.data.length} values for ${x.length} x labels.`, + ); + } + names.push(item.name); + } + const data = x.map((label, i) => { + const row: Record = { x: label }; + for (const s of series as { name: string; data: unknown[] }[]) { + row[s.name] = s.data[i]; + } + return row; + }); + return { + component: kind, + props: { + ...rest, + data, + xKey: "x", + yKey: names.length === 1 ? names[0] : names, + }, + }; + } + + if (kind === "DataTable") { + const { columns, rows } = p; + if (!Array.isArray(columns) || columns.length === 0) { + throw new Error(`DataTable: "columns" must be a non-empty array.`); + } + const cols = columns.map((c, i) => { + if (typeof c === "string") return { key: c, label: c }; + const col = c as { key?: unknown; label?: unknown }; + if (col && typeof col.key === "string") { + return { + key: col.key, + label: typeof col.label === "string" ? col.label : col.key, + }; + } + throw new Error( + `DataTable: columns[${i}] must be a string or { key, label }.`, + ); + }); + if (!Array.isArray(rows)) { + throw new Error(`DataTable: "rows" must be an array.`); + } + const normalized = rows.map((r, i) => { + if (Array.isArray(r)) { + return Object.fromEntries(cols.map((c, j) => [c.key, r[j]])); + } + if (r && typeof r === "object") return r as Record; + throw new Error(`DataTable: rows[${i}] must be an object or an array.`); + }); + return { + component: kind, + props: { ...p, columns: cols, rows: normalized }, + }; + } + + // KpiCard + if (typeof p.label !== "string") { + throw new Error(`KpiCard: "label" must be a string.`); + } + if (typeof p.value !== "string" && typeof p.value !== "number") { + throw new Error(`KpiCard: "value" must be a string or a number.`); + } + return { component: kind, props: p }; +} + +/** + * JSON Schema (2020-12) of each component allowed in a `data-appkit` block, + * with an example. Served at `/design/__data-appkit.schema.json` so an agent + * (or its skill) can write valid blocks; `normalizeBlock` enforces the same + * rules at render time. + */ +export function dataAppkitSchema() { + const chartBase = { + title: { + type: "string", + description: + "Heading shown above the chart. Leave it out when the block's container already has its own heading.", + }, + height: { + type: "number", + description: + "Height in pixels. By default the chart fills its container's height (at least 160px).", + }, + showLegend: { type: "boolean", description: "Show the legend." }, + colors: { + type: "array", + items: { type: "string" }, + description: "Series colors (CSS colors); defaults to the theme palette.", + }, + ariaLabel: { type: "string" }, + }; + const orientation = { + enum: ["vertical", "horizontal"], + description: "Default vertical.", + }; + const compact = { + type: "object", + required: ["x", "series"], + properties: { + x: { + type: "array", + minItems: 1, + items: { type: ["string", "number"] }, + description: "Category or x-axis labels.", + }, + series: { + type: "array", + minItems: 1, + items: { + type: "object", + required: ["name", "data"], + properties: { + name: { type: "string" }, + data: { + type: "array", + items: { type: ["number", "null"] }, + description: "One value per x label (same length as x).", + }, + }, + }, + }, + }, + }; + const rows = { + type: "object", + required: ["data"], + properties: { + data: { + type: "array", + items: { type: "object" }, + description: 'Rows, e.g. [{"day":"Mon","trips":12}].', + }, + xKey: { + type: "string", + description: "Row field for x / categories. Auto-detected.", + }, + yKey: { + type: ["string", "array"], + items: { type: "string" }, + description: "Row field(s) for values. Auto-detected.", + }, + }, + }; + const chart = ( + description: string, + extra: Record, + example: unknown, + ) => ({ + description, + schema: { + type: "object", + oneOf: [compact, rows], + properties: { ...chartBase, ...extra }, + }, + example, + }); + const pie = { + innerRadius: { + type: "number", + minimum: 0, + maximum: 100, + description: "Inner radius in %.", + }, + showLabels: { + type: "boolean", + description: "Labels on slices. Default true.", + }, + labelPosition: { enum: ["outside", "inside", "center"] }, + }; + const singleSeries = { + x: ["Madrid", "Rome", "Paris"], + series: [{ name: "Trips", data: [320, 280, 150] }], + }; + + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "data-appkit blocks", + description: + 'Props for blocks in drafts. Each block is one JSON object matching its component\'s schema. A block with data-src="mock#" takes its data from drafts/mock.json (one JSON object; the key is looked up in its "datasets" object first, then at the top level): an array becomes the block\'s data (rows for a DataTable), an object is merged into its props. Props written in the block win; the block may hold no JSON at all.', + components: { + BarChart: chart( + "Bar chart (appkit-ui BarChart).", + { orientation, stacked: { type: "boolean" } }, + { + title: "Trips per day", + x: ["Mon", "Tue", "Wed"], + series: [{ name: "Trips", data: [12, 19, 7] }], + }, + ), + LineChart: chart( + "Line chart (appkit-ui LineChart).", + { + orientation, + smooth: { type: "boolean" }, + showSymbol: { type: "boolean" }, + }, + { + title: "Revenue", + x: ["Jan", "Feb", "Mar"], + series: [ + { name: "2025", data: [10, 14, 13] }, + { name: "2026", data: [12, 18, 21] }, + ], + }, + ), + AreaChart: chart( + "Area chart (appkit-ui AreaChart).", + { + orientation, + smooth: { type: "boolean" }, + showSymbol: { type: "boolean" }, + stacked: { type: "boolean" }, + }, + { + title: "Signups", + data: [ + { week: "W1", signups: 40 }, + { week: "W2", signups: 55 }, + ], + xKey: "week", + yKey: "signups", + }, + ), + PieChart: chart( + "Pie chart (appkit-ui PieChart). One series; x are the slice names.", + pie, + { title: "Trips by city", ...singleSeries }, + ), + DonutChart: chart( + "Donut chart (appkit-ui DonutChart). One series; x are the slice names.", + pie, + { title: "Trips by city", ...singleSeries }, + ), + DataTable: { + description: "Static table (appkit-ui Table).", + schema: { + type: "object", + required: ["columns", "rows"], + properties: { + columns: { + type: "array", + minItems: 1, + items: { + oneOf: [ + { + type: "string", + description: "Field name, also used as the header.", + }, + { + type: "object", + required: ["key"], + properties: { + key: { type: "string", description: "Row field." }, + label: { + type: "string", + description: "Header; defaults to key.", + }, + }, + }, + ], + }, + }, + rows: { + type: "array", + items: { + oneOf: [ + { type: "object", description: "Keyed by column key." }, + { type: "array", description: "Values in column order." }, + ], + }, + }, + }, + }, + example: { + columns: [ + { key: "city", label: "City" }, + { key: "trips", label: "Trips" }, + ], + rows: [{ city: "Madrid", trips: 320 }, ["Rome", 280]], + }, + }, + KpiCard: { + description: "KPI card (appkit-ui Card).", + schema: { + type: "object", + required: ["label", "value"], + properties: { + label: { type: "string" }, + value: { type: ["string", "number"] }, + delta: { + type: ["string", "number"], + description: 'E.g. "+4% vs last week".', + }, + hint: { type: "string" }, + }, + }, + example: { + label: "Total trips", + value: "1,204", + delta: "+4% vs last week", + }, + }, + }, + }; +} + +/** + * The app template's theme tokens as a standalone stylesheet for drafts + * (`/design/drafts/theme.css`): appkit-ui's CSS variables for light, for dark + * via `prefers-color-scheme` (unless `.light`), and for an explicit `.dark`, + * plus a zero-specificity page background and text color. Drafts link it + * instead of inlining a theme block. + * + * @internal + */ +export function themeCss(appkitUiCss: string): string { + const tokens = (body = "") => + body + .replace(/\/\*[\s\S]*?\*\//g, "") + .split(";") + .map((d) => d.trim()) + .filter((d) => /^(--[\w-]+|color-scheme)\s*:/.test(d)) + .map((d) => ` ${d.replace(/\s+/g, " ")};`) + .join("\n"); + const light = /(?:^|\n)\s*:root\s*\{([^}]*)\}/.exec(appkitUiCss)?.[1]; + const dark = + /@media\s*\(prefers-color-scheme:\s*dark\)\s*\{\s*:root:not\(\.light\)\s*\{([^}]*)\}/.exec( + appkitUiCss, + )?.[1]; + const darkClass = /(?:^|\n)\s*\.dark\s*\{([^}]*)\}/.exec(appkitUiCss)?.[1]; + return `/* AppKit theme tokens for drafts: light, and dark when the system prefers it. */ +:root { +${tokens(light)} +} +@media (prefers-color-scheme: dark) { + :root:not(.light) { +${tokens(dark).replace(/^/gm, " ")} + } +} +.dark { +${tokens(darkClass)} +} +:where(body) { + background: var(--background); + color: var(--foreground); +} +`; +} + +/** Tag injected into every served draft; `async` so it runs while streaming. */ +export const hydratorTag = (src: string) => + ``; + +/** + * Puts the hydrator tag at the start of a draft without pushing the doctype + * down (content before it would switch the page to quirks mode). + * + * @internal + */ +export function injectHydrator(html: string, tag: string): string { + const doctype = /^\s*]*>/i.exec(html); + if (!doctype) return tag + html; + const end = doctype[0].length; + return html.slice(0, end) + tag + html.slice(end); +} + +/** + * How long a draft must stay unchanged before its response is ended without + * ``; long enough for an agent pausing mid-write. + */ +const STABLE_MS = 8000; +/** Give up waiting for an empty draft file after this long. */ +const FIRST_BYTES_MS = 30000; + +/** + * Streams a draft to the browser while it's still being written: sends what + * exists, then appends whatever is added, and ends once the file ends with + * `` or hasn't changed for STABLE_MS. The browser parses it + * progressively, so the hydrator renders blocks as they complete. + * + * @internal + */ +export function streamDraft( + file: string, + tag: string, + res: ServerResponse, + /** Called with the file's mtime once everything up to it has been sent. */ + onDone?: (mtimeMs: number) => void, +): void { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + const decoder = new TextDecoder(); + const started = Date.now(); + let sent = 0; + let injected = false; + let lastChange = Date.now(); + let text = ""; + + const timer = setInterval(pump, 100); + res.on("close", () => clearInterval(timer)); + pump(); + + function finish() { + clearInterval(timer); + if (!injected) res.write(tag); + res.end(decoder.decode()); + try { + onDone?.(fs.statSync(file).mtimeMs); + } catch {} + } + + function pump() { + let bytes: Buffer; + try { + bytes = fs.readFileSync(file); + } catch { + return finish(); + } + // Rewritten from the start (not appended): end this response; the + // hydrator reloads the page on the change notification. + if (bytes.length < sent) return finish(); + if (bytes.length > sent) { + let chunk = decoder.decode(bytes.subarray(sent), { stream: true }); + sent = bytes.length; + lastChange = Date.now(); + text += chunk; + if (!injected) { + chunk = injectHydrator(chunk, tag); + injected = true; + } + res.write(chunk); + } + const now = Date.now(); + if (sent === 0) { + if (now - started > FIRST_BYTES_MS) finish(); + return; + } + if (/<\/html>\s*$/i.test(text) || now - lastChange > STABLE_MS) finish(); + } +} + +/** What {@link runHydrator} needs; injected so it can run under test. */ +interface HydratorDeps { + draft: string; + origins: string[]; + statusPath: string; + /** URL of drafts/mock.json, for blocks with `data-src="mock#"`. */ + mockPath: string; + /** App CSS (or appkit-ui's) to scope into each block's shadow root. */ + css: string; + h: (...args: any[]) => unknown; + createRoot: (el: unknown) => { render(node: unknown): void }; + UI: Record; + normalizeBlock: typeof normalizeBlock; + fetch: (url: string, init: RequestInit) => Promise; +} + +/** + * Renders a draft's `data-appkit` blocks and reports the ones that can't + * render. Runs in the browser: the hydrator embeds it via `toString()`, so it + * must stay self-contained (everything comes in through `deps`). + * + * @internal + */ +export function runHydrator(deps: HydratorDeps): { + scan(final: boolean): void; +} { + const { draft, origins, statusPath, css, h, createRoot, UI } = deps; + // Browser globals; reached through globalThis because this package compiles + // without the DOM typings. + const win = globalThis as any; + const document = win.document; + + // Tailwind's @property rules are ignored inside shadow roots: hoist them. + const properties = css.match(/@property[^{]+\{[^}]*\}/g) ?? []; + if (properties.length) { + const style = document.createElement("style"); + style.textContent = properties.join("\n"); + document.head?.append(style); + } + // :root becomes :host; a compound like :root:not(.light) (the dark-mode + // tokens) must become :host(:not(.light)), since :host:not(...) never + // matches inside a shadow root. + const shadowCss = css + .replace(/:root((?::not\([^)]*\))+)/g, ":host($1)") + .replaceAll(":root", ":host"); + + // Charts read their theme tokens from the document root, where a draft + // defines none: copy the resolved tokens there, at the lowest priority + // (:where), so a draft's own variables still win. Re-synced when the + // system color scheme changes. + const tokenNames = [ + ...new Set(css.match(/--(?!tw-)[a-zA-Z][\w-]*(?=\s*:)/g) ?? []), + ]; + const tokens = document.createElement("style"); + tokens.setAttribute("data-appkit-tokens", ""); + (document.head ?? document.documentElement).prepend(tokens); + const probe = document.createElement("div"); + probe.hidden = true; + probe.attachShadow({ mode: "open" }).innerHTML = + ""; + document.documentElement.append(probe); + const syncTokens = () => { + const resolved = win.getComputedStyle(probe); + tokens.textContent = + ":where(:root){" + + tokenNames + .map((n) => { + const v = resolved.getPropertyValue(n).trim(); + return v ? n + ":" + v + ";" : ""; + }) + .join("") + + "}"; + }; + syncTokens(); + win + .matchMedia?.("(prefers-color-scheme: dark)") + .addEventListener?.("change", syncTokens); + + const text = (v: unknown) => + v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v); + const RENDER: Record unknown> = { + DataTable: ({ + columns, + rows, + }: { + columns: { key: string; label: string }[]; + rows: Record[]; + }) => + h( + UI.Table, + null, + h( + UI.TableHeader, + null, + h( + UI.TableRow, + null, + columns.map((c) => h(UI.TableHead, { key: c.key }, c.label)), + ), + ), + h( + UI.TableBody, + null, + rows.map((r, i) => + h( + UI.TableRow, + { key: i }, + columns.map((c) => + h(UI.TableCell, { key: c.key }, text(r[c.key])), + ), + ), + ), + ), + ), + KpiCard: ({ + label, + value, + delta, + hint, + }: { + label: string; + value: unknown; + delta?: unknown; + hint?: unknown; + }) => + h( + UI.Card, + null, + h( + UI.CardHeader, + null, + h(UI.CardDescription, null, label), + h(UI.CardTitle, { className: "text-2xl" }, text(value)), + ), + delta != null || hint != null + ? h( + UI.CardContent, + { className: "text-sm text-muted-foreground" }, + [delta, hint] + .filter((v) => v != null) + .map(text) + .join(" · "), + ) + : null, + ), + }; + + // Skeleton while a block's JSON is still streaming in, then a short fade-in + // when it renders; both off with prefers-reduced-motion. + const blockCss = ` +.appkit-skeleton { min-height: 160px; border-radius: 8px; background: var(--muted, #f4f4f5); animation: appkit-pulse 1.4s ease-in-out infinite; } +.appkit-skeleton[data-kind="KpiCard"] { min-height: 96px; } +.appkit-root { height: 100%; display: flex; flex-direction: column; } +.appkit-title { margin: 0 0 8px; font: 600 14px/1.4 system-ui, sans-serif; color: var(--card-foreground, inherit); } +.appkit-chart { flex: 1 1 auto; min-height: 0; } +.appkit-fade { animation: appkit-fade-in 200ms ease-out; } +@keyframes appkit-fade-in { from { opacity: 0; } } +@keyframes appkit-pulse { 50% { opacity: 0.55; } } +@media (prefers-reduced-motion: reduce) { + .appkit-skeleton { animation: none; } + .appkit-fade { animation: none; } +}`; + + // One host per block, right after its script in the block's own container: + // the skeleton and later the rendered component live in its shadow root. + // A host already there (the hydrator ran twice) is reused, never doubled. + const hostOf = (script: any) => { + const next = script.nextElementSibling; + if (next?.hasAttribute?.("data-appkit-block") && next.shadowRoot) { + return next; + } + const host = document.createElement("div"); + host.setAttribute("data-appkit-block", script.dataset.appkit ?? ""); + host.style.display = "block"; + // The block fills its container when it's all the container holds (an + // agent's sized box); next to a heading or text it keeps its own height, + // so it can't push out of the container. + const alone = [...(script.parentElement?.children ?? [])].every( + (el: any) => + el === script || + el.hasAttribute("data-appkit-block") || + (el.tagName === "SCRIPT" && el.hasAttribute("data-appkit")), + ); + if (alone) host.style.height = "100%"; + script.after(host); + const shadow = host.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = shadowCss + blockCss; + shadow.append(style); + return host; + }; + const shadowOf = (script: any) => hostOf(script).shadowRoot; + // Blocks already shown in this page, by content: one the browser re-parses + // (same JSON) renders again without fading in again. + const shown: Set = (win.__appkitShownBlocks ??= new Set()); + const skeleton = (script: any) => { + const shadow = shadowOf(script); + if (shadow.querySelector(".appkit-skeleton")) return; + const bone = document.createElement("div"); + bone.className = "appkit-skeleton"; + bone.setAttribute("data-kind", script.dataset.appkit ?? ""); + shadow.append(bone); + }; + const mount = (script: any) => { + const host = hostOf(script); + host.setAttribute("data-appkit-state", "rendered"); + const shadow = host.shadowRoot; + shadow.querySelector(".appkit-skeleton")?.remove(); + const root = document.createElement("div"); + root.className = "appkit-root"; + shadow.append(root); + const key = (script.dataset.appkit ?? "") + "\0" + script.textContent; + if (!shown.has(key)) { + shown.add(key); + // A CSS animation, not a class set on an animation frame: frames + // don't run in a background tab or hidden frame, and the block must + // still show (the animation is timed by the clock, so it has ended by + // the first frame that does render). + root.classList.add("appkit-fade"); + } + return root; + }; + + const blocks = () => [ + ...document.querySelectorAll( + 'script[type="application/json"][data-appkit]', + ), + ]; + const handled = new WeakSet(); + + // One report at a time, in order: the status endpoint must see the reset, + // then the errors, then `checked` (a late reset would wipe the rest). + let reports: Promise = Promise.resolve(); + const post = (body: unknown) => { + reports = reports.then(() => + deps + .fetch(statusPath, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .catch(() => {}), + ); + }; + + const fail = (script: any, error: unknown) => { + handled.add(script); + const component = script.dataset.appkit ?? ""; + const message = error instanceof Error ? error.message : String(error); + createRoot(mount(script)).render( + h( + "div", + { + "data-appkit-error": "", + style: { + padding: "12px", + border: "1px dashed var(--border)", + borderRadius: "8px", + color: "var(--muted-foreground)", + font: "13px system-ui, sans-serif", + }, + }, + "Couldn't render " + component + ": " + message, + ), + ); + const report = { + draft, + block: blocks().indexOf(script), + component, + error: message, + }; + post(report); + const target = win.parent !== win ? win.parent : win.opener; + if (target) { + for (const origin of origins) { + target.postMessage( + { type: "appkit:draft-error", version: 1, ...report }, + origin, + ); + } + } + }; + + // drafts/mock.json: fetched once per page for `data-src="mock#"` + // blocks, and asked for again every second until it's there and parses + // (the agent may still be writing it). Blocks wait as skeletons. + const MOCK_TRIES = 30; + let mock: Record | undefined; + let mockLoading = false; + let mockTries = 0; + let finalSeen = false; + const loadMock = () => { + if (mock || mockLoading || mockTries >= MOCK_TRIES) return; + mockLoading = true; + deps + .fetch(deps.mockPath, { method: "GET" }) + .then((r: any) => + r?.ok ? r.text() : Promise.reject(new Error("HTTP " + r?.status)), + ) + .then((body: string) => { + const value = JSON.parse(body); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("not a JSON object"); + } + mock = value; + }) + .catch(() => { + mockTries++; + }) + .finally(() => { + mockLoading = false; + if (mock || mockTries >= MOCK_TRIES) scan(finalSeen); + else win.setTimeout(loadMock, 1000); + }); + }; + // A block's data from mock.json: rows become its data prop (`rows` for a + // DataTable), an object is merged into its props. Inline props win. + const withMock = (script: any, props: unknown) => { + const key = /^mock#(.+)$/.exec(script.dataset.src)?.[1]; + if (!key) { + throw new Error('data-src must be "mock#".'); + } + if (!mock) { + throw new Error( + "drafts/mock.json didn't load (missing, or not a JSON object).", + ); + } + // UNV-4's drafts skill writes { mock, datasets: { : rows } }. + const datasets = (mock as { datasets?: unknown }).datasets; + const source = + datasets && typeof datasets === "object" && key in datasets + ? (datasets as Record) + : mock; + if (!(key in source)) + throw new Error( + 'drafts/mock.json has no "' + key + '" (in "datasets" or at the top).', + ); + const value = source[key]; + const own = (props ?? {}) as Record; + if (Array.isArray(value)) { + const prop = script.dataset.appkit === "DataTable" ? "rows" : "data"; + return { [prop]: value, ...own }; + } + if (value && typeof value === "object") return { ...value, ...own }; + throw new Error('mock "' + key + '" must be an array or an object.'); + }; + + // final: the document is complete, so JSON that still doesn't parse never will. + let reportedChecked = false; + const scan = (final: boolean) => { + if (final) finalSeen = true; + scanBlocks(final); + // Every block of the complete page is handled: tell the status endpoint + // this version's errors are known (until then it reports them as null). + if (final && !reportedChecked && blocks().every((b) => handled.has(b))) { + reportedChecked = true; + post({ draft, checked: true }); + } + }; + const scanBlocks = (final: boolean) => { + for (const script of blocks()) { + if (handled.has(script)) continue; + // Rendered by an earlier run of the hydrator in this page. + const next = script.nextElementSibling; + if ( + next?.hasAttribute?.("data-appkit-block") && + next.getAttribute("data-appkit-state") === "rendered" + ) { + handled.add(script); + continue; + } + let props: unknown; + const body = (script.textContent ?? "").trim(); + // A data-src block may hold no props: empty is `{}` once the parser is + // past the script (until then its text may still be coming). + if ( + body === "" && + script.dataset.src != null && + (final || script.nextSibling) + ) { + props = {}; + } else + try { + props = JSON.parse(body); + } catch (error) { + if (final) { + fail( + script, + new Error( + "invalid JSON: " + ((error as Error)?.message ?? error), + ), + ); + } else { + skeleton(script); // still streaming in + } + continue; + } + if (script.dataset.src != null && !mock && mockTries < MOCK_TRIES) { + skeleton(script); + loadMock(); + continue; + } + try { + if (script.dataset.src != null) props = withMock(script, props); + const spec = deps.normalizeBlock(script.dataset.appkit ?? "", props); + handled.add(script); + const render = RENDER[spec.component]; + if (render) { + createRoot(mount(script)).render(render(spec.props)); + } else { + // A chart: its title is a heading above it, not drawn inside the + // chart where ECharts would put it over the axes and legend. + const { title, ...props } = spec.props as Record; + createRoot(mount(script)).render([ + title != null + ? h("div", { key: "t", className: "appkit-title" }, text(title)) + : null, + h( + "div", + { key: "c", className: "appkit-chart" }, + h(UI[spec.component], props), + ), + ]); + } + } catch (error) { + fail(script, error); + } + } + }; + + // A new load of this draft: earlier errors are replaced by this one's. + post({ draft, reset: true }); + + if (document.readyState === "loading") { + const observer = new win.MutationObserver(() => scan(false)); + observer.observe(document, { + subtree: true, + childList: true, + characterData: true, + }); + document.addEventListener("DOMContentLoaded", () => { + observer.disconnect(); + scan(true); + }); + scan(false); + } else { + scan(true); + } + return { scan }; +} + +/** + * Browser module injected into every served draft: loads React and + * appkit-ui, then runs {@link runHydrator}, which renders each + * ` + + + +`; + +describe("runHydrator", () => { + test("sends every block that can't render to the builder as appkit:draft-error v1", () => { + const t = setup(BLOCKS); + restore = t.restore; + + expect(t.postMessage).toHaveBeenCalledTimes(2); + expect(t.postMessage).toHaveBeenCalledWith( + { + type: "appkit:draft-error", + version: 1, + draft: "dash", + block: 1, + component: "Sparkline", + error: expect.stringContaining( + 'Unknown data-appkit component "Sparkline"', + ), + }, + "https://builder.example.com", + ); + expect(t.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "appkit:draft-error", + block: 2, + component: "LineChart", + error: expect.stringMatching(/^invalid JSON: /), + }), + "https://builder.example.com", + ); + }); + + test("reports them to /design/__status, after resetting this draft's earlier errors", async () => { + const t = setup(BLOCKS); + restore = t.restore; + await flush(); + + const bodies = t.fetch.mock.calls.map((c) => { + const [url, init] = c as unknown as [string, { body: string }]; + expect(url).toBe("/design/__status"); + return JSON.parse(init.body); + }); + expect(bodies[0]).toEqual({ draft: "dash", reset: true }); + // Then each error, and last that every block was handled. + expect(bodies.at(-1)).toEqual({ draft: "dash", checked: true }); + expect(bodies.slice(1, -1)).toEqual([ + expect.objectContaining({ + draft: "dash", + block: 1, + component: "Sparkline", + }), + expect.objectContaining({ + draft: "dash", + block: 2, + component: "LineChart", + }), + ]); + }); + + test("renders the valid blocks and shows a placeholder for the others", () => { + const t = setup(BLOCKS); + restore = t.restore; + + const hosts = [...doc.querySelectorAll("[data-appkit-block]")].map((h) => + h.getAttribute("data-appkit-block"), + ); + expect(hosts).toEqual(["KpiCard", "Sparkline", "LineChart", "BarChart"]); + const placeholders = t.rendered.filter( + (n) => "data-appkit-error" in (n.props ?? {}), + ); + expect(placeholders).toHaveLength(2); + // A chart renders in its wrapper: [heading or null, chart box]. + const chart = t.rendered.find(Array.isArray) as unknown as [ + unknown, + { props: { children: { type: unknown; props: unknown } } }, + ]; + expect(chart[1].props.children).toEqual({ + type: "BarChart", + props: expect.objectContaining({ xKey: "x", yKey: "s" }), + }); + }); + + test("never posts without a builder win", async () => { + doc.body.innerHTML = BLOCKS; + const fetch = vi.fn(async () => ({})); + expect(() => + runHydrator({ + draft: "dash", + origins: [], + statusPath: "/design/__status", + mockPath: "/m", + css: "", + h: () => null, + createRoot: () => ({ render() {} }), + UI, + normalizeBlock, + fetch, + }), + ).not.toThrow(); + // The status endpoint still gets the errors (reset, 2 errors, checked). + await flush(); + expect(fetch).toHaveBeenCalledTimes(4); + }); + + test("sends one status report at a time, in order, even when requests finish out of order", async () => { + doc.body.innerHTML = BLOCKS; + const pending: { + body: { reset?: true; checked?: true; block?: number }; + done: () => void; + }[] = []; + const fetch = vi.fn( + (_url: string, init: { body: string }) => + new Promise((done) => + pending.push({ body: JSON.parse(init.body), done: () => done({}) }), + ), + ); + runHydrator({ + draft: "dash", + origins: [], + statusPath: "/s", + mockPath: "/m", + css: "", + h: () => null, + createRoot: () => ({ render() {} }), + UI, + normalizeBlock, + fetch: fetch as never, + }); + const sent: unknown[] = []; + // Each report starts only once the one before it has finished; finish + // the newest first, as a slow network might. + for (;;) { + await flush(); + const open = pending.splice(0); + if (open.length === 0) break; + expect(open).toHaveLength(1); + sent.push(open[0].body); + open.reverse().forEach((p) => p.done()); + } + expect(sent).toEqual([ + { draft: "dash", reset: true }, + expect.objectContaining({ block: 1 }), + expect.objectContaining({ block: 2 }), + { draft: "dash", checked: true }, + ]); + }); + + test("scopes compound :root selectors as valid :host() selectors", () => { + doc.body.innerHTML = + ''; + runHydrator({ + draft: "d", + origins: [], + statusPath: "/s", + mockPath: "/m", + css: ":root{--x:1}@media (prefers-color-scheme: dark){:root:not(.light){--x:2}}", + h: () => null, + createRoot: () => ({ render() {} }), + UI, + normalizeBlock, + fetch: async () => ({}), + }); + const host = [ + ...doc.querySelectorAll("[data-appkit-block]"), + ][0] as unknown as { + shadowRoot: { querySelector(s: string): { textContent: string } }; + }; + expect( + host.shadowRoot + .querySelector("style") + .textContent.startsWith( + ":host{--x:1}@media (prefers-color-scheme: dark){:host(:not(.light)){--x:2}}", + ), + ).toBe(true); + }); + + test("a block still streaming shows a skeleton, replaced in place when its JSON completes", () => { + doc.body.innerHTML = ""; + const rendered: unknown[] = []; + const { scan } = runHydrator({ + draft: "d", + origins: [], + statusPath: "/s", + mockPath: "/m", + css: "", + h: (type: unknown) => ({ type }), + createRoot: () => ({ render: (n: unknown) => rendered.push(n) }), + UI, + normalizeBlock, + fetch: async () => ({}), + }); + doc.body.innerHTML = + ''; + scan(false); + const host = [ + ...doc.querySelectorAll("[data-appkit-block]"), + ][0] as unknown as { + shadowRoot: { + querySelector( + s: string, + ): { getAttribute(n: string): string | null; className: string } | null; + querySelector(s: "style"): { textContent: string }; + }; + }; + const bone = host.shadowRoot.querySelector(".appkit-skeleton"); + expect(bone?.getAttribute("data-kind")).toBe("KpiCard"); + expect(rendered).toHaveLength(0); + + ( + doc as unknown as { querySelector(s: string): { textContent: string } } + ).querySelector("script[data-appkit]").textContent = + '{"label":"Trips","value":12}'; + scan(false); + expect([...doc.querySelectorAll("[data-appkit-block]")]).toHaveLength(1); // same host + expect(host.shadowRoot.querySelector(".appkit-skeleton")).toBeNull(); + expect(host.shadowRoot.querySelector(".appkit-fade")).not.toBeNull(); + expect(rendered).toHaveLength(1); + // The fade and the pulse are off with prefers-reduced-motion. + expect(host.shadowRoot.querySelector("style").textContent).toMatch( + /@media \(prefers-reduced-motion: reduce\) \{[^}]*animation: none;[^}]*\}\s*\.appkit-fade \{ animation: none; \}/, + ); + }); + + test("the fade-in never hides a block: no opacity is held back for an animation frame", () => { + doc.body.innerHTML = BAR; + const raf = vi.fn(); + (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = + raf; + try { + hydrate(); + } finally { + delete (globalThis as { requestAnimationFrame?: unknown }) + .requestAnimationFrame; + } + expect(raf).not.toHaveBeenCalled(); + const css = ( + hosts()[0].shadowRoot.querySelector("style") as { textContent: string } + ).textContent; + // Only the keyframes start at 0; the block's own style is fully opaque. + expect(css).toMatch( + /\.appkit-fade \{ animation: appkit-fade-in 200ms ease-out; \}/, + ); + expect(css).not.toMatch(/\.appkit-fade \{[^}]*opacity/); + }); + + test("exposes the theme tokens on the document root at the lowest priority", () => { + const t = setup(""); + restore = t.restore; + const tokens = doc.querySelector("style[data-appkit-tokens]"); + expect(tokens?.textContent?.startsWith(":where(:root){")).toBe(true); + }); + + const hydrate = (render: (n: unknown) => void = () => {}) => + runHydrator({ + draft: "d", + origins: [], + statusPath: "/s", + mockPath: "/m", + css: "", + h: (type: unknown, props: unknown, ...children: unknown[]) => ({ + type, + props, + children, + }), + createRoot: () => ({ render }), + UI, + normalizeBlock, + fetch: async () => ({}), + }); + type Host = { + style: { height: string }; + shadowRoot: { querySelector(s: string): unknown }; + }; + const hosts = () => + [...doc.querySelectorAll("[data-appkit-block]")] as unknown as Host[]; + const BAR = + ''; + + test("a chart's title is a heading above the chart, not drawn inside it", () => { + doc.body.innerHTML = BAR; + const rendered: any[] = []; + hydrate((n) => rendered.push(n)); + const [heading, box] = rendered[0]; + expect(heading).toMatchObject({ + type: "div", + props: { className: "appkit-title" }, + children: ["Fares"], + }); + const chart = box.children[0]; + expect(chart.type).toBe("BarChart"); + expect(chart.props).not.toHaveProperty("title"); + }); + + test("a block fills its container only when it's all the container holds", () => { + doc.body.innerHTML = `
${BAR}

Fares

${BAR}
`; + hydrate(); + expect(hosts().map((h) => h.style.height)).toEqual(["100%", ""]); + }); + + test("running again never doubles, remounts or re-fades a block", () => { + doc.body.innerHTML = `
${BAR}
`; + const first: unknown[] = []; + hydrate((n) => first.push(n)); + const again: unknown[] = []; + hydrate((n) => again.push(n)); + expect(hosts()).toHaveLength(1); + expect(first).toHaveLength(1); + expect(again).toHaveLength(0); + + // The same block parsed again (a fresh node, e.g. the builder re-set the + // page): rendered, but without the fade-in. + doc.body.innerHTML = `
${BAR}
`; + hydrate(); + expect(hosts()[0].shadowRoot.querySelector(".appkit-root")).not.toBeNull(); + expect(hosts()[0].shadowRoot.querySelector(".appkit-fade")).toBeNull(); + }); + + describe('data-src="mock#"', () => { + afterEach(() => vi.useRealTimers()); + + // fetch for drafts/mock.json: a 404 `missing` times, then this mock. + const serveMock = (mock: unknown, missing = 0) => { + const calls: string[] = []; + const fetch = vi.fn(async (url: string, init?: { method?: string }) => { + if (init?.method !== "GET") return {}; + calls.push(url); + return calls.length <= missing + ? { ok: false, status: 404 } + : { ok: true, text: async () => JSON.stringify(mock) }; + }); + return { fetch, calls }; + }; + const run = (fetch: unknown, render: (n: any) => void) => + runHydrator({ + draft: "d", + origins: [], + statusPath: "/s", + mockPath: "/design/drafts/mock.json", + css: "", + h: (type: unknown, props: unknown, ...children: unknown[]) => ({ + type, + props, + children, + }), + createRoot: () => ({ render }), + UI, + normalizeBlock, + fetch: fetch as never, + }); + const settle = () => vi.advanceTimersByTimeAsync(0); + + test("waits as a skeleton until mock.json is there, then mounts once with its data", async () => { + vi.useFakeTimers(); + doc.body.innerHTML = ` +
+
`; + const { fetch, calls } = serveMock( + { + trips: [{ day: "Mon", n: 3 }], + kpi: { label: "Trips", value: 12 }, + }, + 2, + ); + const rendered: any[] = []; + run(fetch, (n) => rendered.push(n)); + await settle(); + expect(rendered).toHaveLength(0); + const shadows = () => + [...doc.querySelectorAll("[data-appkit-block]")].map( + (h) => + ( + h as unknown as { + shadowRoot: { querySelector(s: string): unknown }; + } + ).shadowRoot, + ); + expect(shadows().every((s) => s.querySelector(".appkit-skeleton"))).toBe( + true, + ); + + await vi.advanceTimersByTimeAsync(2_000); // two retries + expect(calls).toHaveLength(3); // one request at a time, for both blocks + expect(rendered).toHaveLength(2); + const chart = rendered[0][1].children[0]; + expect(chart).toMatchObject({ + type: "BarChart", + props: { data: [{ day: "Mon", n: 3 }], xKey: "day", yKey: "n" }, + }); + expect(shadows().some((s) => s.querySelector(".appkit-skeleton"))).toBe( + false, + ); + + await vi.advanceTimersByTimeAsync(5_000); + expect(calls).toHaveLength(3); // cached: no more requests + expect(rendered).toHaveLength(2); // mounted once + }); + + test("inline props win, and a DataTable gets the rows", async () => { + vi.useFakeTimers(); + doc.body.innerHTML = ` +
+
`; + const { fetch } = serveMock({ + t: [{ city: "Rome" }], + k: { label: "Mock label", value: 5 }, + }); + const rendered: any[] = []; + run(fetch, (n) => rendered.push(n)); + await settle(); + const [table, kpi] = rendered.map((n) => JSON.stringify(n)); + expect(table).toContain('"Rome"'); // the mock rows, in the table body + expect(kpi).toContain('"Own label"'); + expect(kpi).not.toContain("Mock label"); + expect(kpi).toContain('"5"'); // the value still comes from the mock + }); + + test('finds the key in "datasets" first, then at the top level', async () => { + vi.useFakeTimers(); + doc.body.innerHTML = ` +
+
`; + // The drafts skill's shape: { mock, datasets: { : rows } }, with + // a same-named top-level key that must lose to datasets. + const { fetch } = serveMock({ + mock: true, + trips: [{ day: "top-level", n: 0 }], + datasets: { trips: [{ day: "Mon", n: 3 }] }, + total: { label: "Trips", value: 12 }, + }); + const rendered: any[] = []; + run(fetch, (n) => rendered.push(n)); + await settle(); + expect(rendered).toHaveLength(2); + expect(rendered[0][1].children[0].props.data).toEqual([ + { day: "Mon", n: 3 }, + ]); + expect(JSON.stringify(rendered[1])).toContain('"Trips"'); + }); + + test("reports the draft checked only once blocks waiting on mock.json are handled", async () => { + vi.useFakeTimers(); + doc.body.innerHTML = `
`; + const { fetch } = serveMock( + { datasets: { k: { label: "a", value: 1 } } }, + 1, + ); + run(fetch, () => {}); + const checked = () => + fetch.mock.calls.filter((c) => + (c[1] as { body?: string } | undefined)?.body?.includes( + '"checked":true', + ), + ).length; + await settle(); + expect(checked()).toBe(0); // still waiting on mock.json + await vi.advanceTimersByTimeAsync(1_000); + expect(checked()).toBe(1); + }); + + test("a missing key fails just that block, with a clear error", async () => { + vi.useFakeTimers(); + doc.body.innerHTML = `
`; + const { fetch } = serveMock({ trips: [] }); + const rendered: any[] = []; + run(fetch, (n) => rendered.push(n)); + await settle(); + expect(rendered).toHaveLength(1); + expect(rendered[0].children[0]).toContain( + 'drafts/mock.json has no "nope"', + ); + const reports = fetch.mock.calls + .map((c) => c[1] as { method?: string; body?: string } | undefined) + .filter((i) => i?.method === "POST" && i.body?.includes("nope")); + expect(reports).toHaveLength(1); + }); + }); +}); diff --git a/packages/appkit/src/plugins/design/tests/drafts.test.ts b/packages/appkit/src/plugins/design/tests/drafts.test.ts new file mode 100644 index 000000000..20c75ed86 --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/drafts.test.ts @@ -0,0 +1,578 @@ +import fs from "node:fs"; +import type { ServerResponse } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { + dataAppkitSchema, + hydratorModule, + injectHydrator, + listDrafts, + normalizeBlock, + streamDraft, + themeCss, +} from "../drafts"; +import { designVitePlugin } from "../vite-plugin"; + +describe("normalizeBlock", () => { + test("compact chart form becomes rows with xKey / yKey", () => { + expect( + normalizeBlock("BarChart", { + x: ["Jan", "Feb"], + series: [{ name: "2026", data: [12, 19] }], + title: "Trips", + }), + ).toEqual({ + component: "BarChart", + props: { + title: "Trips", + data: [ + { x: "Jan", 2026: 12 }, + { x: "Feb", 2026: 19 }, + ], + xKey: "x", + yKey: "2026", + }, + }); + expect( + normalizeBlock("LineChart", { + x: ["a"], + series: [ + { name: "s1", data: [1] }, + { name: "s2", data: [2] }, + ], + }).props.yKey, + ).toEqual(["s1", "s2"]); + }); + + test("native chart props pass through", () => { + const props = { data: [{ day: "Mon", trips: 3 }], xKey: "day" }; + expect(normalizeBlock("AreaChart", props)).toEqual({ + component: "AreaChart", + props, + }); + }); + + test("DataTable takes column names or {key,label} and object or array rows", () => { + expect( + normalizeBlock("DataTable", { + columns: ["city", { key: "n", label: "Trips" }], + rows: [["Madrid", 3], { city: "Rome", n: 5 }], + }).props, + ).toEqual({ + columns: [ + { key: "city", label: "city" }, + { key: "n", label: "Trips" }, + ], + rows: [ + { city: "Madrid", n: 3 }, + { city: "Rome", n: 5 }, + ], + }); + }); + + test("KpiCard needs a label and a value", () => { + expect( + normalizeBlock("KpiCard", { label: "Revenue", value: "$48k" }), + ).toEqual({ + component: "KpiCard", + props: { label: "Revenue", value: "$48k" }, + }); + }); + + test.each([ + ["Chart", {}, /Unknown data-appkit component "Chart"/], + ["BarChart", [], /JSON object of props/], + ["BarChart", { title: "x" }, /needs "data" \(rows\) or "x"/], + ["BarChart", { x: ["a"], series: [] }, /non-empty array/], + [ + "PieChart", + { x: ["a", "b"], series: [{ name: "s", data: [1] }] }, + /1 values for 2 x labels/, + ], + [ + "DataTable", + { columns: [], rows: [] }, + /"columns" must be a non-empty array/, + ], + ["DataTable", { columns: ["a"], rows: [1] }, /rows\[0\]/], + ["KpiCard", { value: 1 }, /"label" must be a string/], + ])("%s %j is rejected", (kind, props, message) => { + expect(() => normalizeBlock(kind, props)).toThrow(message); + }); +}); + +describe("dataAppkitSchema", () => { + const { components } = dataAppkitSchema(); + + test("covers exactly the components normalizeBlock accepts", () => { + let allowed: string[] = []; + try { + normalizeBlock("__none__", {}); + } catch (e) { + allowed = + /Allowed: (.*)\.$/.exec((e as Error).message)?.[1].split(", ") ?? []; + } + expect(Object.keys(components).sort()).toEqual([...allowed].sort()); + }); + + test.each(Object.entries(components))("%s example renders", (kind, spec) => { + expect(() => normalizeBlock(kind, spec.example)).not.toThrow(); + }); + + test.each( + Object.entries(components).flatMap(([kind, spec]) => + ((spec.schema as { required?: string[] }).required ?? []).map( + (field) => [kind, field] as const, + ), + ), + )("%s without %s is rejected, as the schema says", (kind, field) => { + const { [field]: _dropped, ...rest } = components[ + kind as keyof typeof components + ].example as Record; + expect(() => normalizeBlock(kind, rest)).toThrow(); + }); + + test("the shipped components.schema.json is up to date", () => { + const shipped = JSON.parse( + fs.readFileSync( + path.join( + import.meta.dirname, + "../../../../../appkit-ui/src/design/components.schema.json", + ), + "utf-8", + ), + ); + // If this fails, run: pnpm exec tsx tools/generate-design-schema.ts + expect(shipped).toEqual(dataAppkitSchema()); + }); + + test("charts need either x + series or data rows", () => { + for (const kind of [ + "BarChart", + "LineChart", + "AreaChart", + "PieChart", + "DonutChart", + ]) { + expect(() => normalizeBlock(kind, { title: "t" })).toThrow( + /needs "data"/, + ); + } + }); +}); + +describe("themeCss", () => { + const css = themeCss( + fs.readFileSync( + path.join( + import.meta.dirname, + "../../../../../appkit-ui/src/react/styles/globals.css", + ), + "utf-8", + ), + ); + + test("has the template's light and dark tokens, dark via prefers-color-scheme", () => { + expect(css).toMatch( + /:root \{\n {2}--radius: 0\.625rem;\n {2}color-scheme: light;/, + ); + expect(css).toMatch( + /@media \(prefers-color-scheme: dark\) \{\n {2}:root:not\(\.light\) \{\n[\s\S]*color-scheme: dark;/, + ); + for (const token of [ + "--background", + "--foreground", + "--primary", + "--chart-1", + "--border", + ]) { + expect(css.split(token).length - 1).toBeGreaterThanOrEqual(3); // light, dark, .dark + } + }); + + test("only tokens: no utilities or base rules besides the body colors", () => { + expect(css).not.toContain("@layer"); + expect(css).not.toContain("@import"); + expect(css).toContain(":where(body) {\n background: var(--background);"); + }); +}); + +describe("injectHydrator", () => { + const tag = ''; + test("goes after the doctype so the page stays in standards mode", () => { + expect(injectHydrator("", tag)).toBe( + `${tag}`, + ); + }); + test("goes first without a doctype", () => { + expect(injectHydrator("
", tag)).toBe(`${tag}
`); + }); +}); + +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "appkit-drafts-")); + +describe("listDrafts", () => { + test("lists drafts/*.html by name", () => { + const root = tmp(); + fs.mkdirSync(path.join(root, "drafts")); + for (const f of ["b.html", "a.html", "index.json", "notes.md"]) { + fs.writeFileSync(path.join(root, "drafts", f), ""); + } + expect(listDrafts(root)).toEqual([ + { name: "a", path: "/design/drafts/a" }, + { name: "b", path: "/design/drafts/b" }, + ]); + expect(listDrafts(tmp())).toEqual([]); + }); +}); + +describe("streamDraft", () => { + function fakeResponse() { + const chunks: string[] = []; + let ended: () => void; + const done = new Promise((r) => (ended = r)); + const res = { + setHeader() {}, + on() {}, + write(chunk: string) { + chunks.push(chunk); + }, + end(chunk?: string) { + if (chunk) chunks.push(chunk); + ended(); + }, + } as unknown as ServerResponse; + return { res, chunks, done }; + } + + test("sends the draft as it's written, with the hydrator injected once", async () => { + const file = path.join(tmp(), "d.html"); + fs.writeFileSync(file, "

first"); + const { res, chunks, done } = fakeResponse(); + streamDraft(file, "", res); + + await new Promise((r) => setTimeout(r, 250)); + fs.appendFileSync(file, " é

"); + await new Promise((r) => setTimeout(r, 250)); + fs.appendFileSync(file, ""); + await done; + + expect(chunks.length).toBeGreaterThan(1); // streamed, not sent at once + expect(chunks.join("")).toBe( + "

first é

", + ); + }); + + test( + "ends a draft that stops changing without ", + { timeout: 15000 }, + async () => { + const file = path.join(tmp(), "d.html"); + fs.writeFileSync(file, "

partial"); + const { res, chunks, done } = fakeResponse(); + const started = Date.now(); + streamDraft(file, "", res); + await done; + expect(Date.now() - started).toBeGreaterThanOrEqual(7900); + expect(chunks.join("")).toBe("

partial"); + }, + ); +}); + +describe("hydratorModule", () => { + const code = hydratorModule( + "dash", + ["https://builder.example.com"], + "/design/__status", + "/design/drafts/mock.json", + ); + + test("renders with the real appkit-ui components, no CDN", () => { + expect(code).toContain('import("@databricks/appkit-ui/react")'); + // Fast Refresh preamble before any component module loads. + expect(code.indexOf("injectIntoGlobalHook(window)")).toBeLessThan( + code.indexOf('import("@databricks/appkit-ui/react")'), + ); + expect(code).not.toMatch(/https?:\/\/(?!builder\.example\.com)/); + expect(code).toContain("const normalizeBlock = function normalizeBlock("); + }); + + test("posts errors to this dev server's origin, without Vite's asset-URL pattern", () => { + expect(code).toContain( + 'statusPath: new URL(import.meta.url).origin + "/design/__status"', + ); + expect(code).not.toMatch(/new URL\([^)]*,\s*import\.meta\.url\)/); + }); + + test("is valid JavaScript", async () => { + const { parseAst } = await import("vite"); + expect(() => parseAst(code)).not.toThrow(); + }); + + test("embeds the tested runHydrator with this draft's origins", () => { + expect(code).toContain("const runHydrator = function runHydrator("); + expect(code).toContain('origins: ["https://builder.example.com"]'); + expect(code).toContain("draft: DRAFT"); + }); +}); + +describe("draft errors in /design/__status", () => { + const cwd = process.cwd(); + afterEach(() => process.chdir(cwd)); + + type Handler = ( + req: { url: string; method: string; body?: unknown }, + res: { + statusCode: number; + setHeader(k: string, v: string): void; + end(body?: string): void; + }, + next: () => void, + ) => Promise; + + async function call( + handler: Handler, + method: string, + url: string, + body?: unknown, + headers: Record = {}, + ) { + let out = ""; + const set: Record = {}; + const res = { + statusCode: 200, + setHeader: (k: string, v: string) => (set[k.toLowerCase()] = v), + end: (b?: string) => (out = b ?? ""), + }; + await handler({ url, method, body, headers } as never, res, () => {}); + let json: unknown; + try { + json = out ? JSON.parse(out) : undefined; + } catch {} + return { status: res.statusCode, json: json as any, headers: set }; + } + function start( + transformRequest: (url: string) => Promise = async () => ({}), + builderOrigins: string[] = [], + ) { + const app = tmp(); + fs.mkdirSync(path.join(app, "drafts")); + fs.writeFileSync(path.join(app, "drafts", "dash.html"), ""); + process.chdir(app); + const plugin = designVitePlugin({ builderOrigins }); + let handler: Handler | undefined; + (plugin.configureServer as (s: unknown) => void)({ + config: { root: path.join(app, "client") }, + ws: { send() {} }, + middlewares: { use: (h: Handler) => (handler = h) }, + transformRequest, + moduleGraph: { getModuleByUrl: async () => undefined }, + }); + return { app, handler: handler as Handler }; + } + + const writeIndex = (app: string, text: string) => { + const file = path.join(app, "client/src/design/index.tsx"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + }; + + test("errors the hydrator POSTs are listed once it has checked every block, and a reload resets them", async () => { + const { handler } = start(); + const S = "/design/__status"; + // No page has rendered the draft (e.g. right after a restart): unknown, + // not "no errors". + expect((await call(handler, "GET", S)).json.drafts[0]).toMatchObject({ + checked: false, + errors: null, + }); + await call(handler, "POST", S, { draft: "dash", reset: true }); + await call(handler, "POST", S, { + draft: "dash", + block: 2, + component: "DataTable", + error: 'DataTable: "columns" must be a non-empty array.', + }); + // Still rendering: not known yet. + expect((await call(handler, "GET", S)).json.drafts[0].errors).toBeNull(); + await call(handler, "POST", S, { draft: "dash", checked: true }); + + const { json } = await call(handler, "GET", S); + expect(json.drafts).toEqual([ + { + name: "dash", + path: "/design/drafts/dash", + checked: true, + errors: [ + { + block: 2, + component: "DataTable", + error: 'DataTable: "columns" must be a non-empty array.', + }, + ], + }, + ]); + + await call(handler, "POST", S, { draft: "dash", reset: true }); + expect((await call(handler, "GET", S)).json.drafts[0].errors).toBeNull(); + await call(handler, "POST", S, { draft: "dash", checked: true }); + expect((await call(handler, "GET", S)).json.drafts[0].errors).toEqual([]); + }); + + test("compiled and waitingFor tell an agent what /design waits on, without a browser", async () => { + const S = "/design/__status"; + const ok = start(); + expect((await call(ok.handler, "GET", S)).json).toMatchObject({ + ready: false, + compiled: false, + waitingFor: "index", + }); + writeIndex( + ok.app, + "export const designPlaceholder = true;\nexport default () => null;", + ); + expect((await call(ok.handler, "GET", S)).json.waitingFor).toBe( + "placeholder", + ); + writeIndex(ok.app, "export default () => null;"); + expect((await call(ok.handler, "GET", S)).json).toMatchObject({ + ready: false, + compiled: true, + waitingFor: "paint", + }); + + const broken = start(async () => { + throw new Error( + 'Failed to resolve import "./nope" from "src/design/index.tsx"', + ); + }); + writeIndex(broken.app, 'import "./nope";\nexport default () => null;'); + expect((await call(broken.handler, "GET", S)).json).toMatchObject({ + compiled: false, + compileError: expect.stringContaining("Failed to resolve import"), + waitingFor: "compile", + }); + }); + + test("serves the hydrator at a stable URL for drafts streamed elsewhere", async () => { + const { handler } = start(); + const r = await call( + handler, + "GET", + "/design/drafts/__hydrator.js?draft=dash", + ); + expect(r.status).toBe(302); + expect(r.headers.location).toBe( + "/@id/__x00__virtual:appkit-draft-hydrator:dash.mjs", + ); + // No (or an unsafe) name: a neutral one, never a path. + expect( + (await call(handler, "GET", "/design/drafts/__hydrator.js?draft=../x")) + .headers.location, + ).toBe("/@id/__x00__virtual:appkit-draft-hydrator:stream.mjs"); + expect((await call(handler, "GET", "/design/__status")).json.hydrator).toBe( + "/design/drafts/__hydrator.js", + ); + }); + + test("errors of a draft streamed elsewhere (no file) are listed too", async () => { + const { handler } = start(); + const S = "/design/__status"; + await call(handler, "POST", S, { draft: "live", reset: true }); + await call(handler, "POST", S, { + draft: "live", + block: 0, + component: "Sparkline", + error: "Unknown", + }); + await call(handler, "POST", S, { draft: "live", checked: true }); + expect((await call(handler, "GET", S)).json.drafts).toContainEqual({ + name: "live", + path: null, + streamed: true, + checked: true, + errors: [{ block: 0, component: "Sparkline", error: "Unknown" }], + }); + }); + + test("its endpoints answer CORS for the builder origins only", async () => { + const { handler } = start(undefined, ["https://builder.example.com"]); + const pre = await call(handler, "OPTIONS", "/design/__status", undefined, { + origin: "https://builder.example.com", + }); + expect(pre.status).toBe(204); + expect(pre.headers["access-control-allow-origin"]).toBe( + "https://builder.example.com", + ); + expect(pre.headers["access-control-allow-headers"]).toBe("Content-Type"); + const other = await call( + handler, + "GET", + "/design/drafts/__hydrator.js", + undefined, + { + origin: "https://evil.example.com", + }, + ); + expect(other.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("serves drafts/mock.json next to the drafts, to the builder origins too", async () => { + const { app, handler } = start(undefined, ["https://builder.example.com"]); + const M = "/design/drafts/mock.json"; + expect((await call(handler, "GET", "/design/__status")).json.mock).toBe(M); + expect((await call(handler, "GET", M)).status).toBe(404); + + fs.writeFileSync( + path.join(app, "drafts", "mock.json"), + '{"trips":[{"day":"Mon","n":3}]}', + ); + const r = await call(handler, "GET", M, undefined, { + origin: "https://builder.example.com", + }); + expect(r.status).toBe(200); + expect(r.json).toEqual({ trips: [{ day: "Mon", n: 3 }] }); + expect(r.headers["access-control-allow-origin"]).toBe( + "https://builder.example.com", + ); + expect(r.headers["cache-control"]).toBe("no-store"); + // Never listed as a draft. + expect( + (await call(handler, "GET", "/design/__status")).json.drafts.map( + (d: { name: string }) => d.name, + ), + ).toEqual(["dash"]); + }); + + test("serves the block schema and links it from the status", async () => { + const { handler } = start(); + const status = (await call(handler, "GET", "/design/__status")).json; + expect(status.blockSchema).toBe("/design/__data-appkit.schema.json"); + const schema = (await call(handler, "GET", status.blockSchema)).json; + expect(Object.keys(schema.components)).toContain("DataTable"); + expect(schema.components.DataTable.schema.properties.columns.minItems).toBe( + 1, + ); + }); + + test("errors of an older version of the draft are dropped", async () => { + const { app, handler } = start(); + const S = "/design/__status"; + await call(handler, "POST", S, { + draft: "dash", + block: 0, + component: "X", + error: "e", + }); + const file = path.join(app, "drafts", "dash.html"); + fs.utimesSync(file, new Date(), new Date(Date.now() + 5000)); + // The new version hasn't been rendered yet: unknown. + expect((await call(handler, "GET", S)).json.drafts[0]).toMatchObject({ + checked: false, + errors: null, + }); + }); +}); diff --git a/packages/appkit/src/plugins/design/vite-plugin.ts b/packages/appkit/src/plugins/design/vite-plugin.ts new file mode 100644 index 000000000..b5848010c --- /dev/null +++ b/packages/appkit/src/plugins/design/vite-plugin.ts @@ -0,0 +1,897 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import type { Plugin, ViteDevServer } from "vite"; + +import { + DRAFT_ROUTE, + dataAppkitSchema, + draftFile, + hydratorModule, + hydratorTag, + listDrafts, + streamDraft, + themeCss, +} from "./drafts"; + +/** + * One virtual entry per page: `virtual:appkit-design-page:.mjs`. The + * `.mjs` keeps @vitejs/plugin-react from treating the entry as a `.tsx` + * component module and injecting Fast Refresh code that imports it by id. + */ +const ENTRY_PREFIX = "virtual:appkit-design-page:"; +const ENTRY_SUFFIX = ".mjs"; +/** Hydrator injected into served drafts: `virtual:appkit-draft-hydrator:.mjs`. */ +const HYDRATOR_PREFIX = "virtual:appkit-draft-hydrator:"; +/** Script added to the app's own pages (not /design) to post appkit:app-ready. */ +const APP_READY_ID = "virtual:appkit-app-ready.mjs"; + +/** + * Marks the app template's default home page. While an element carrying it + * is on screen the app isn't reported ready, so a builder never reveals the + * placeholder home page. Remove it with the page when the app gets real + * content. + */ +const TEMPLATE_HOME_ATTR = "data-appkit-template-home"; +const PAGE_FILE = /^(index|pages\/[A-Za-z0-9_-]+)\.(tsx|jsx)$/; +const DESIGN_ROUTE = /^\/design(?:\/([A-Za-z0-9_-]+))?\/?$/; +const STATUS_PATH = "/design/__status"; +const BLOCK_SCHEMA_PATH = "/design/__data-appkit.schema.json"; +const DRAFT_THEME_PATH = "/design/drafts/theme.css"; +/** drafts/mock.json, the data `data-src="mock#"` blocks read. */ +const DRAFT_MOCK_PATH = "/design/drafts/mock.json"; +/** Stable URL of the draft hydrator, for drafts streamed from elsewhere. */ +const HYDRATOR_PATH = "/design/drafts/__hydrator.js"; +/** Vite's default dev CORS origins (localhost), kept when builders are added. */ +const LOCALHOST_ORIGIN = + /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/; +const EXTENSIONS = [".tsx", ".jsx"]; + +/** + * `pages/index.tsx` would collide with the root page, `pages/drafts.tsx` with + * the drafts at `/design/drafts/`, and `__*` with the plugin's own + * endpoints (`/design/__status`), so those names aren't pages. + */ +const isReservedName = (name: string) => + name === "index" || name === "drafts" || name.startsWith("__"); + +/** Page files the browser entry may route to (reserved names excluded). */ +const PAGE_GLOBS = [ + "/src/design/index.{tsx,jsx}", + "/src/design/pages/*.{tsx,jsx}", + "!/src/design/pages/index.{tsx,jsx}", + "!/src/design/pages/drafts.{tsx,jsx}", + "!/src/design/pages/__*", +]; + +/** + * Marks the template's starter page. A page exporting it is never reported + * as ready, so a builder doesn't reveal the starter while the real design is + * still being written. Delete the export when replacing the page. + */ +const PLACEHOLDER_EXPORT = /export\s+const\s+designPlaceholder\s*=\s*true/; + +/** A design page as listed in the status and ready payloads. */ +interface DesignPageInfo { + /** `index`, or the `` of `pages/.tsx`. */ + name: string; + /** URL of the page: `/design` or `/design/`. */ + path: string; +} + +/** + * Maps a request path to its design page file under `/src/design`: + * `/design` → `index.tsx`, `/design/` → `pages/.tsx`. + * Returns `undefined` for paths outside `/design`, and `null` for anything + * under `/design` that isn't an existing page (served as 404). + * + * @internal + */ +export function resolveDesignPage( + clientRoot: string, + pathname: string, +): string | null | undefined { + if (pathname !== "/design" && !pathname.startsWith("/design/")) { + return undefined; + } + const match = DESIGN_ROUTE.exec(pathname); + if (!match || (match[1] && isReservedName(match[1]))) return null; + const base = match[1] + ? path.join(clientRoot, "src/design/pages", match[1]) + : path.join(clientRoot, "src/design/index"); + for (const ext of EXTENSIONS) { + if (fs.existsSync(base + ext)) return base + ext; + } + return null; +} + +/** Every existing design page, index first. @internal */ +export function listDesignPages(clientRoot: string): DesignPageInfo[] { + const pages: DesignPageInfo[] = []; + if (resolveDesignPage(clientRoot, "/design")) { + pages.push({ name: "index", path: "/design" }); + } + const dir = path.join(clientRoot, "src/design/pages"); + const names = fs.existsSync(dir) + ? fs + .readdirSync(dir) + .map((f) => /^([A-Za-z0-9_-]+)\.(tsx|jsx)$/.exec(f)?.[1]) + .filter((n): n is string => !!n && !isReservedName(n)) + : []; + for (const name of [...new Set(names)].sort()) { + pages.push({ name, path: `/design/${name}` }); + } + return pages; +} + +/** Modification time of the index page, or undefined when there is none. */ +function indexVersion(clientRoot: string): number | undefined { + const index = resolveDesignPage(clientRoot, "/design"); + return index ? fs.statSync(index).mtimeMs : undefined; +} + +/** + * `/design/__status`: ready once `index.tsx` exists, isn't the template + * placeholder, and has painted in a browser since it last changed + * (`paintedVersion` is the file's mtime when that paint was reported). + * + * @internal + */ +export function designStatus( + clientRoot: string, + paintedVersion: number | undefined, +): { ready: boolean; placeholder: boolean; pages: DesignPageInfo[] } { + const index = resolveDesignPage(clientRoot, "/design"); + const placeholder = + !!index && PLACEHOLDER_EXPORT.test(fs.readFileSync(index, "utf-8")); + const real = !!index && !placeholder; + const painted = + paintedVersion !== undefined && paintedVersion === indexVersion(clientRoot); + return { + ready: real && painted, + placeholder, + pages: listDesignPages(clientRoot), + }; +} + +/** + * Whether the page entry can use react-router 7+ (the template ships it): + * Node's resolution from the design page, i.e. the app's own copy. Not from + * this plugin's folder (pnpm may hoist an older react-router there), and not + * through Vite, which returns its pre-bundled copy in node_modules/.vite/deps, + * whose package.json has no name or version. + */ +function hasReactRouter(clientRoot: string): boolean { + let entry: string; + try { + entry = createRequire( + path.join(clientRoot, "src/design/index.tsx"), + ).resolve("react-router"); + } catch { + return false; + } + for ( + let dir = path.dirname(entry); + dir !== path.dirname(dir); + dir = path.dirname(dir) + ) { + const pkg = path.join(dir, "package.json"); + if (!fs.existsSync(pkg)) continue; + const { name, version } = JSON.parse(fs.readFileSync(pkg, "utf-8")); + if (name !== "react-router") continue; + return Number.parseInt(String(version), 10) >= 7; + } + return false; +} + +/** + * Browser code shared by the design-page entry and the app-ready script: + * `settled(isCurrent)` resolves once the page shows its real content (web + * fonts loaded, no `useDesignReady(false)` holder, no page fetch in flight, + * no DOM change for 300 ms, then two frames). Expects the including module to + * define `schedule()`, which it calls when a fetch completes. + */ +const SETTLE_SNIPPET = ` +// Shared with appkit-ui's useDesignReady(): components holding readiness back +// bump \`blockers\` and call \`changed()\` when they let go. +// CONTRACT: keep the key and shape in sync with appkit-ui's use-design-ready.ts. +const host = (window.__appkitDesign ??= { blockers: 0 }); + +// In-flight fetches started by the page (chart data and the like). Our own +// status POSTs are not counted. +let pendingFetches = 0; +const realFetch = window.fetch.bind(window); +window.fetch = (input, init) => { + const url = typeof input === "string" ? input : input?.url ?? String(input); + if (url.includes("${STATUS_PATH}")) return realFetch(input, init); + pendingFetches++; + return realFetch(input, init).finally(() => { + pendingFetches--; + // The response still has to be read and rendered: count its arrival as + // activity so the quiet window starts after it, not before. + lastMutation = performance.now(); + schedule(); + }); +}; + +const QUIET_MS = 300; +// Automatic waits (fetches, DOM changes) give up after this, so a stream that +// never ends can't hold the page back forever. useDesignReady(false) has no cap. +const SETTLE_CAP_MS = 10000; +const frame = () => new Promise((r) => requestAnimationFrame(r)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let lastMutation = performance.now(); +new MutationObserver(() => (lastMutation = performance.now())).observe(document.getElementById("root"), { + subtree: true, + childList: true, + characterData: true, + attributes: true, +}); + +// Resolves once the page shows its real content: fonts loaded, no fetch in +// flight, no DOM change for QUIET_MS, then two frames (painted). \`paint\` +// replaces the frame wait. +async function settled(isCurrent, paint) { + await document.fonts?.ready; + const start = performance.now(); + while (isCurrent()) { + const capped = performance.now() - start > SETTLE_CAP_MS; + const quiet = performance.now() - lastMutation >= QUIET_MS; + if (host.blockers === 0 && (capped || (pendingFetches === 0 && quiet))) break; + await sleep(50); + } + if (paint) return paint(); + await frame(); + await frame(); +} +`; + +/** + * Entry for one page. The requested page is imported directly; the other + * pages only load when navigated to, so an unfinished draft can't break this + * one. With react-router, pages share a router whose basename is `/design`, + * so `` stays at `/design/orders`. + * + * After first paint it reports the page (unless it's the placeholder) to the + * dev server and posts `appkit:design-ready` to the embedding builder, only + * to the configured origins. + * + * @internal + */ +export function entryModule( + file: string, + builderOrigins: string[], + router: boolean, +): string { + const shared = ` +import { Component, createElement, Suspense, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import * as initial from "/src/design/${file}"; +import.meta.glob("/src/index.css", { eager: true }); + +const ORIGINS = ${JSON.stringify(builderOrigins)}; +const pageName = (file) => + file.startsWith("/src/design/pages/") ? file.slice(18).replace(/\\.[jt]sx$/, "") : "index"; +const pages = Object.keys(import.meta.glob(${JSON.stringify(PAGE_GLOBS)})) + .map((file) => ({ name: pageName(file), path: pageName(file) === "index" ? "/design" : "/design/" + pageName(file) })) + .sort((a, b) => (a.name === "index" ? -1 : b.name === "index" ? 1 : a.name.localeCompare(b.name))); + +${SETTLE_SNIPPET} +function postStatus(name, ok) { + fetch("${STATUS_PATH}", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ page: name, ok }), + }).catch(() => {}); +} + +function reportReady(name) { + postStatus(name, true); + const target = window.parent !== window ? window.parent : window.opener; + if (!target) return; + for (const origin of ORIGINS) { + target.postMessage({ type: "appkit:design-ready", version: 1, page: name, pages }, origin); + } +} + +// The page on screen and whether it rendered (not the placeholder, no error). +let current; +let run = 0; + +// (Re)starts the wait for the current page; a newer render or update cancels +// an older wait, so only the latest state can be reported. +function schedule() { + if (!current?.ok) return; + const mine = ++run; + const { name } = current; + const isCurrent = () => mine === run && current?.ok && current.name === name; + settled(isCurrent).then(() => { + if (isCurrent() && host.blockers === 0) reportReady(name); + }); +} +host.changed = () => { + if (host.blockers === 0) schedule(); +}; + +// A Suspense fallback on screen means the page isn't showing yet. +function Pending() { + useLayoutEffect(() => { + host.blockers++; + return () => { + host.blockers--; + // What it revealed may still draw (charts in effects): wait a quiet window. + lastMutation = performance.now(); + host.changed(); + }; + }, []); + return null; +} + +// Reports readiness only from a successful render. A render error is caught +// here (and shown) instead of being reported; the server is told, so a +// failure in any component the page uses un-readies it. +function withReady(mod, name) { + const placeholder = mod.designPlaceholder === true; + return class DesignReady extends Component { + state = { error: null }; + static getDerivedStateFromError(error) { + return { error }; + } + componentDidMount() { + this.settle(); + } + componentDidUpdate() { + this.settle(); + } + settle() { + current = { name, ok: !this.state.error && !placeholder }; + if (this.state.error) postStatus(name, false); + else schedule(); + } + render() { + if (this.state.error) { + return createElement( + "pre", + { style: { padding: 16, whiteSpace: "pre-wrap", color: "#b91c1c" } }, + "Design page failed to render: " + (this.state.error?.message ?? String(this.state.error)), + ); + } + return createElement(Suspense, { fallback: createElement(Pending) }, createElement(mod.default)); + } + }; +} + +// Hot edits re-render in place without remounting; once React has applied +// the edit, wait for the page to settle again before reporting it. +// (Adding or removing the placeholder export reloads the page instead.) +import.meta.hot?.on("vite:afterUpdate", () => setTimeout(schedule, 50)); +`; + + if (!router) { + return `${shared} +createRoot(document.getElementById("root")).render( + createElement(withReady(initial, pageName("/src/design/${file}"))), +); +`; + } + + return `${shared} +import { createBrowserRouter, RouterProvider } from "react-router"; + +const lazyPages = import.meta.glob(${JSON.stringify(PAGE_GLOBS)}); +const routes = Object.keys(lazyPages).map((file) => { + const name = pageName(file); + const path = name === "index" ? "/" : "/" + name; + return file === "/src/design/${file}" + ? { path, Component: withReady(initial, name) } + : { path, lazy: async () => ({ Component: withReady(await lazyPages[file](), name) }) }; +}); +const router = createBrowserRouter(routes, { basename: "/design" }); +createRoot(document.getElementById("root")).render(createElement(RouterProvider, { router })); +`; +} + +/** + * Added to the app's own pages in development when a builder origin is set. + * Once the page has settled (see SETTLE_SNIPPET), and again after client-side + * navigation or a hot update, it posts `appkit:app-ready` v1 to the embedding + * builder, unless the template's default home page is on screen. + * + * @internal + */ +export function appReadyModule(builderOrigins: string[]): string { + return ` +const ORIGINS = ${JSON.stringify(builderOrigins)}; +${SETTLE_SNIPPET} + +// A builder may load the app in a hidden frame to reveal it when ready, and +// hidden frames get no animation frames: two frames, or 100 ms without them. +const painted = () => + Promise.race([frame().then(frame), sleep(100)]); + +let run = 0; +function schedule() { + const mine = ++run; + settled(() => mine === run, painted).then(() => { + if (mine !== run || host.blockers > 0) return; + if (document.querySelector("[${TEMPLATE_HOME_ATTR}]")) return; + const target = window.parent !== window ? window.parent : window.opener; + if (!target) return; + for (const origin of ORIGINS) { + target.postMessage({ type: "appkit:app-ready", version: 1, path: location.pathname }, origin); + } + }); +} +host.changed = () => { + if (host.blockers === 0) schedule(); +}; + +// Client-side navigation (react-router etc.) changes the page without a load. +for (const method of ["pushState", "replaceState"]) { + const original = history[method]; + history[method] = function (...args) { + const result = original.apply(this, args); + schedule(); + return result; + }; +} +addEventListener("popstate", schedule); +// E.g. the default home page being replaced by the real one. +import.meta.hot?.on("vite:afterUpdate", () => setTimeout(schedule, 50)); +schedule(); +`; +} + +/** Cap on modules compiled per status check, so a huge graph can't stall it. */ +const COMPILE_GRAPH_LIMIT = 500; + +/** + * Compiles `url` and every module of the app it imports, transitively + * (dependencies in node_modules and virtual modules are skipped), the way a + * browser loading the page would. Returns the first error, or undefined when + * the whole graph builds. + * + * `transformRequest` alone only compiles the one module; its imports are only + * resolved, so a syntax error in an imported component would go unnoticed. + * + * @internal + */ +export async function compileGraph( + server: Pick, + url: string, +): Promise { + const seen = new Set(); + const queue = [url]; + while (queue.length > 0 && seen.size < COMPILE_GRAPH_LIMIT) { + const next = queue.shift() as string; + if (seen.has(next)) continue; + seen.add(next); + try { + await server.transformRequest(next); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Name the failing module (Vite's location when it has one), since the + // error can come from deep in the graph and may not say where. + const loc = (error as { loc?: { file?: string; line?: number } }).loc; + const where = loc?.file + ? `${loc.file}${loc.line ? `:${loc.line}` : ""}` + : next; + return message.includes(path.basename(where.split(":")[0])) + ? message + : `${where}: ${message}`; + } + const mod = await server.moduleGraph.getModuleByUrl(next); + for (const dep of mod?.importedModules ?? []) { + const file = dep.file ?? ""; + if ( + !file || + file.includes("/node_modules/") || + dep.url.startsWith("/@id/") + ) { + continue; + } + // CSS and assets are imported too; only script modules can fail to build. + if (!/\.[cm]?[jt]sx?$/.test(file)) continue; + queue.push(dep.url); + } + } + return undefined; +} + +const pageHtml = (title: string, entryUrl: string) => ` + + + + + + ${title} + + +

+ + + +`; + +/** + * Serves `/design` (the default export of `client/src/design/index.tsx`), + * `/design/` (`client/src/design/pages/.tsx`) and the + * `/design/__status` readiness endpoint. + * + * @internal + */ +export function designVitePlugin({ + builderOrigins = [], +}: { builderOrigins?: string[] } = {}): Plugin { + let root = process.cwd(); + // Drafts live in the app folder (the dev server's working directory). + const projectRoot = process.cwd(); + const draftsDir = path.join(projectRoot, "drafts"); + // mtime of the index page when a browser last reported painting it. + let paintedVersion: number | undefined; + // Render errors reported by the hydrator, per draft, for one version of it; + // `checked` once a page has handled every block of that version. + const draftErrors = new Map< + string, + { + version: number; + checked: boolean; + errors: { block: number; component: string; error: string }[]; + } + >(); + // Drafts with a response still streaming, and the version last streamed in + // full: a change notification (page reload) is only needed after both. + const streamingDrafts = new Map(); + const streamedVersion = new Map(); + const draftVersion = (name: string) => { + const file = draftFile(projectRoot, name); + return file ? fs.statSync(file).mtimeMs : -1; + }; + + return { + name: "appkit-design", + apply: "serve", + + // A draft streamed by the builder (from its own origin) loads the + // hydrator and its imports from this dev server: allow the builder origins + // next to Vite's default localhost ones. + config() { + if (builderOrigins.length === 0) return; + return { + server: { cors: { origin: [...builderOrigins, LOCALHOST_ORIGIN] } }, + }; + }, + + configResolved(config) { + root = config.root; + }, + + resolveId(id) { + if ( + id.startsWith(ENTRY_PREFIX) || + id.startsWith(HYDRATOR_PREFIX) || + id === APP_READY_ID + ) { + return `\0${id}`; + } + }, + + transformIndexHtml(_html, ctx) { + // App pages only: design pages report appkit:design-ready themselves. + if (builderOrigins.length === 0) return; + if (resolveDesignPage(root, ctx.path.split("?")[0]) !== undefined) return; + return [ + { + tag: "script", + attrs: { type: "module", src: `/@id/__x00__${APP_READY_ID}` }, + injectTo: "body", + }, + ]; + }, + + async load(id) { + if (id === `\0${APP_READY_ID}`) return appReadyModule(builderOrigins); + if (id.startsWith(`\0${HYDRATOR_PREFIX}`) && id.endsWith(ENTRY_SUFFIX)) { + const draft = id.slice( + HYDRATOR_PREFIX.length + 1, + -ENTRY_SUFFIX.length, + ); + if (/^[A-Za-z0-9_-]+$/.test(draft)) { + return hydratorModule( + draft, + builderOrigins, + STATUS_PATH, + DRAFT_MOCK_PATH, + ); + } + return; + } + if (!id.startsWith(`\0${ENTRY_PREFIX}`)) return; + if (!id.endsWith(ENTRY_SUFFIX)) return; + const file = id.slice(ENTRY_PREFIX.length + 1, -ENTRY_SUFFIX.length); + if (PAGE_FILE.test(file)) { + const router = hasReactRouter(root); + return entryModule(file, builderOrigins, router); + } + }, + + configureServer(server) { + // Not Vite's watcher: for a changed .html it matches no module, Vite + // sends a full reload, and in middleware mode that reloads every page + // (a draft mid-stream, the app) on each write. + const onDraftChange = (file: string | null) => { + const name = /^([A-Za-z0-9_-]+)\.html$/.exec(file ?? "")?.[1]; + // Writes during a stream reach the page through the stream itself, + // and a late event for content already streamed needs no reload. + if ( + name && + !(streamingDrafts.get(name) ?? 0) && + draftVersion(name) !== streamedVersion.get(name) + ) { + server.ws.send({ + type: "custom", + event: "appkit:draft-changed", + data: { draft: name }, + }); + } + }; + let draftsWatcher: fs.FSWatcher | undefined; + const watchDrafts = () => { + if (draftsWatcher || !fs.existsSync(draftsDir)) return; + draftsWatcher = fs.watch(draftsDir, (_, file) => onDraftChange(file)); + draftsWatcher.unref(); + }; + watchDrafts(); + // drafts/ may only be created once the app is running. + fs.watch(projectRoot, watchDrafts).unref(); + server.middlewares.use(async (req, res, next) => { + const pathname = req.url?.split("?")[0] ?? ""; + + // The plugin's own endpoints are also used by builder pages on other + // origins (a streamed draft posts errors, loads the hydrator). + const origin = req.headers?.origin; + if ( + typeof origin === "string" && + builderOrigins.includes(origin) && + (pathname === STATUS_PATH || + pathname === HYDRATOR_PATH || + pathname === DRAFT_THEME_PATH || + pathname === DRAFT_MOCK_PATH || + pathname === BLOCK_SCHEMA_PATH) + ) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + if (req.method === "OPTIONS") { + res.setHeader("Access-Control-Allow-Methods", "GET, POST"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.statusCode = 204; + res.end(); + return; + } + } + + if (pathname === HYDRATOR_PATH) { + const draft = new URL(req.url ?? "", "http://x").searchParams.get( + "draft", + ); + const name = + draft && /^[A-Za-z0-9_-]+$/.test(draft) ? draft : "stream"; + res.statusCode = 302; + res.setHeader( + "Location", + `/@id/__x00__${HYDRATOR_PREFIX}${name}${ENTRY_SUFFIX}`, + ); + res.end(); + return; + } + + if (pathname === STATUS_PATH) { + if (req.method === "POST") { + // Under AppKit's server, express.json() has already consumed the + // stream into `req.body`; a bare Vite server leaves it unread. + let body = (req as { body?: { page?: unknown } }).body; + if (body === undefined) { + let raw = ""; + for await (const chunk of req) raw += chunk; + try { + body = JSON.parse(raw); + } catch {} + } + const report = body as { + draft?: unknown; + reset?: unknown; + checked?: unknown; + block?: unknown; + component?: unknown; + error?: unknown; + }; + if (typeof report?.draft === "string") { + const version = draftVersion(report.draft); + let entry = draftErrors.get(report.draft); + if (report.reset === true || entry?.version !== version) { + entry = { version, checked: false, errors: [] }; + draftErrors.set(report.draft, entry); + } + if (report.checked === true) entry.checked = true; + if (typeof report.error === "string") { + entry.errors.push({ + block: typeof report.block === "number" ? report.block : -1, + component: String(report.component ?? ""), + error: report.error, + }); + } + } + if (body?.page === "index") { + paintedVersion = + (body as { ok?: unknown }).ok === false + ? undefined + : indexVersion(server.config.root); + } + res.statusCode = 204; + res.end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-store"); + const files = listDrafts(projectRoot); + const drafts: { + name: string; + path: string | null; + streamed?: true; + checked: boolean; + // null: not known yet (no page has rendered this version). + errors: + | { block: number; component: string; error: string }[] + | null; + }[] = files.map((d) => { + const entry = draftErrors.get(d.name); + const checked = + entry?.version === draftVersion(d.name) && entry.checked; + return { ...d, checked, errors: checked ? entry.errors : null }; + }); + // Drafts streamed from elsewhere (no file here) report errors too. + for (const [name, entry] of draftErrors) { + if (files.some((d) => d.name === name) || entry.version !== -1) { + continue; + } + drafts.push({ + name, + path: null, + streamed: true, + checked: entry.checked, + errors: entry.checked ? entry.errors : null, + }); + } + const status = designStatus(server.config.root, paintedVersion); + // `ready` needs a browser to paint /design. `compiled` doesn't: it + // builds the index page (and resolves its imports) server-side, so + // an agent can wait on it without a browser. + const index = resolveDesignPage(server.config.root, "/design"); + let compiled = false; + let compileError: string | undefined; + if (index && !status.placeholder) { + compileError = await compileGraph( + server, + `/${path.relative(server.config.root, index).split(path.sep).join("/")}`, + ); + compiled = compileError === undefined; + } + const waitingFor = !index + ? "index" + : status.placeholder + ? "placeholder" + : !compiled + ? "compile" + : !status.ready + ? "paint" + : null; + res.end( + JSON.stringify({ + ...status, + compiled, + ...(compileError ? { compileError } : {}), + waitingFor, + drafts, + blockSchema: BLOCK_SCHEMA_PATH, + theme: DRAFT_THEME_PATH, + mock: DRAFT_MOCK_PATH, + hydrator: HYDRATOR_PATH, + }), + ); + return; + } + + if (pathname === DRAFT_THEME_PATH) { + let css = ""; + try { + css = fs.readFileSync( + createRequire( + path.join(server.config.root, "package.json"), + ).resolve("@databricks/appkit-ui/styles.css"), + "utf-8", + ); + } catch {} + res.setHeader("Content-Type", "text/css; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(themeCss(css)); + return; + } + + if (pathname === DRAFT_MOCK_PATH) { + // Served as written: while the agent is still writing it, the + // hydrator fails to parse it and asks again. + const file = path.join(draftsDir, "mock.json"); + res.setHeader("Cache-Control", "no-store"); + if (!fs.existsSync(file)) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("No drafts/mock.json yet."); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end(fs.readFileSync(file, "utf-8")); + return; + } + + if (pathname === BLOCK_SCHEMA_PATH) { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(dataAppkitSchema(), null, 2)); + return; + } + + const draft = DRAFT_ROUTE.exec(pathname)?.[1]; + if (draft) { + const file = draftFile(projectRoot, draft); + if (!file) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end(`No draft at drafts/${draft}.html.`); + return; + } + streamingDrafts.set(draft, (streamingDrafts.get(draft) ?? 0) + 1); + streamDraft( + file, + hydratorTag( + `/@id/__x00__${HYDRATOR_PREFIX}${draft}${ENTRY_SUFFIX}`, + ), + res, + (mtime) => { + streamingDrafts.set(draft, (streamingDrafts.get(draft) ?? 1) - 1); + streamedVersion.set(draft, mtime); + }, + ); + return; + } + + const file = resolveDesignPage(server.config.root, pathname); + if (file === undefined) return next(); + if (file === null) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end( + `No design page for ${pathname}. Add client/src/design/index.tsx (for /design) or client/src/design/pages/.tsx (for /design/) with a default export. "index" and names starting with "__" are reserved for page files.`, + ); + return; + } + try { + const name = path.basename(file, path.extname(file)); + const title = name === "index" ? "Design" : `Design: ${name}`; + const rel = path + .relative(path.join(server.config.root, "src/design"), file) + .split(path.sep) + .join("/"); + const html = await server.transformIndexHtml( + req.url ?? pathname, + pageHtml(title, `/@id/__x00__${ENTRY_PREFIX}${rel}${ENTRY_SUFFIX}`), + ); + res.setHeader("Content-Type", "text/html"); + res.end(html); + } catch (error) { + next(error); + } + }); + }, + }; +} diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index ff8efcd0d..e10323c9b 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -7,6 +7,7 @@ import express from "express"; import getPort, { portNumbers } from "get-port"; import type { PluginClientConfigs, PluginPhase } from "shared"; import { camelToKebab } from "shared"; +import type { PluginOption } from "vite"; import { AppKitError, ServerError } from "../../errors"; import { httpStatusOf } from "../../errors/http-status"; @@ -344,10 +345,14 @@ export class ServerPlugin extends Plugin { // auto-detection based on environment if (isDev) { + const vitePlugins = [ + ...(this.context?.getPlugins().values() ?? []), + ].flatMap((plugin) => plugin.vitePlugins?.() ?? []); this.viteDevServer = new ViteDevServer( this.serverApplication, endpoints, pluginConfigs, + vitePlugins as PluginOption[], ); await this.viteDevServer.setup(); return; diff --git a/packages/appkit/src/plugins/server/vite-dev-server.ts b/packages/appkit/src/plugins/server/vite-dev-server.ts index 22c29cea0..1709380e7 100644 --- a/packages/appkit/src/plugins/server/vite-dev-server.ts +++ b/packages/appkit/src/plugins/server/vite-dev-server.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type express from "express"; -import type { ViteDevServer as ViteDevServerType } from "vite"; +import type { PluginOption, ViteDevServer as ViteDevServerType } from "vite"; import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; @@ -34,6 +34,7 @@ export class ViteDevServer extends BaseServer { app: express.Application, endpoints: PluginEndpoints = {}, pluginConfigs: PluginClientConfigs = {}, + private readonly extraPlugins: PluginOption[] = [], ) { super(app, endpoints, pluginConfigs); this.vite = null; @@ -88,6 +89,7 @@ export class ViteDevServer extends BaseServer { reactSourceLocPlugin({ projectRoot }), appKitTypesPlugin(), appKitServingTypesPlugin(), + ...this.extraPlugins, ], appType: "custom", }; diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index d91b8cabe..8ccdb1ef0 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -5,6 +5,11 @@ import { tableFromIPC } from "apache-arrow"; import pc from "picocolors"; import { createLogger } from "../logging/logger"; +import { + extractParameters, + getProtectedRanges, + isInsideProtectedRange, +} from "../utils/sql-parameters"; import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache"; import { classifyBlockingFailure, @@ -40,20 +45,6 @@ const logger = createLogger("type-generator:query-registry"); */ const PREFLIGHT_WAIT_MAX_MS = 300_000; -/** - * Regex breakdown: - * '(?:[^']|'')*' — matches a SQL string literal, including escaped '' pairs - * | — alternation: whichever branch matches first at a position wins - * --[^\n]* — matches a single-line SQL comment - * - * Because the regex engine scans left-to-right, a `'` is consumed as a string - * literal before any `--` inside it could match as a comment — giving us - * correct single-pass ordering without a manual state machine. - * - * V1: no block-comment support (deferred to next PR). - */ -const PROTECTED_RANGE_RE = /'(?:[^']|'')*'|--[^\n]*/g; - /** * Numeric-context patterns for positional type inference. * Hoisted to module scope — safe because matchAll() clones the regex internally. @@ -70,21 +61,6 @@ const NUMERIC_PATTERNS: RegExp[] = [ /:([a-zA-Z_]\w*)\s*[+\-*/]/g, ]; -export function getProtectedRanges(sql: string): Array<[number, number]> { - const ranges: Array<[number, number]> = []; - for (const m of sql.matchAll(PROTECTED_RANGE_RE)) { - ranges.push([m.index, m.index + m[0].length]); - } - return ranges; -} - -function isInsideProtectedRange( - offset: number, - ranges: Array<[number, number]>, -): boolean { - return ranges.some(([start, end]) => offset >= start && offset < end); -} - /** * Parse a raw API/SDK error into a structured code + message. * Handles Databricks-style JSON bodies embedded in the message string, @@ -108,25 +84,7 @@ function parseError(raw: string): { code?: string; message: string } { return { message: raw }; } -/** - * Extract parameters from a SQL query - * @param sql - the SQL query to extract parameters from - * @returns an array of parameter names - */ -export function extractParameters( - sql: string, - ranges?: Array<[number, number]>, -): string[] { - const protectedRanges = ranges ?? getProtectedRanges(sql); - const matches = sql.matchAll(/(?(); - for (const match of matches) { - if (!isInsideProtectedRange(match.index, protectedRanges)) { - params.add(match[1]); - } - } - return Array.from(params); -} +export { extractParameters, getProtectedRanges }; // parameters that are injected by the server export const SERVER_INJECTED_PARAMS = ["workspaceId"]; diff --git a/packages/appkit/src/utils/sql-parameters.ts b/packages/appkit/src/utils/sql-parameters.ts new file mode 100644 index 000000000..f57bb5bba --- /dev/null +++ b/packages/appkit/src/utils/sql-parameters.ts @@ -0,0 +1,50 @@ +/** + * Spans of a SQL statement where `:name` is not a parameter: + * '...' string literal (`''` or `\'` escapes) + * "..." string literal (Databricks SQL allows double quotes; `""` or `\"`) + * `...` quoted identifier (`` `` `` escapes) + * --... single-line comment + * + * The regex scans left-to-right and the first branch matching at a position + * wins, so a quote opens a literal before any `--` or other quote inside it + * can match. E.g. in `date_format(ts, "yyyy-MM-dd'T'HH:mm:ss")` the whole + * double-quoted literal is one span, so `:mm` / `:ss` aren't parameters. + * + * V1: no block-comment support. + */ +const PROTECTED_RANGE_RE = + /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`(?:[^`]|``)*`|--[^\n]*/g; + +/** Start/end offsets of every span in {@link PROTECTED_RANGE_RE}. */ +export function getProtectedRanges(sql: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const m of sql.matchAll(PROTECTED_RANGE_RE)) { + ranges.push([m.index, m.index + m[0].length]); + } + return ranges; +} + +export function isInsideProtectedRange( + offset: number, + ranges: Array<[number, number]>, +): boolean { + return ranges.some(([start, end]) => offset >= start && offset < end); +} + +/** + * Names of the `:name` parameters in a SQL statement, ignoring `::` casts and + * anything inside string literals, quoted identifiers and comments. + */ +export function extractParameters( + sql: string, + ranges?: Array<[number, number]>, +): string[] { + const protectedRanges = ranges ?? getProtectedRanges(sql); + const params = new Set(); + for (const match of sql.matchAll(/(? { + test("colons inside a double-quoted literal aren't parameters", () => { + expect( + extractParameters( + `SELECT date_format(ts, "yyyy-MM-dd'T'HH:mm:ss") AS t FROM trips WHERE day >= :start_date`, + ), + ).toEqual(["start_date"]); + }); + + test.each([ + ["single-quoted", `SELECT 'at 10:30' AS t, :a`], + ["'' escape", `SELECT 'it''s 10:30' AS t, :a`], + ["\\' escape", `SELECT 'it\\'s 10:30' AS t, :a`], + ['"" escape', `SELECT "say ""hi"" at 10:30" AS t, :a`], + ["backtick identifier", "SELECT `col:name` AS t, :a"], + ["line comment", "SELECT 1 -- run at 10:30 :not_me\n, :a"], + [":: cast", "SELECT x::int AS t, :a"], + ])("%s", (_, sql) => { + expect(extractParameters(sql)).toEqual(["a"]); + }); + + test("parameters next to literals still count", () => { + expect( + extractParameters( + `SELECT * FROM t WHERE a = :a AND b = 'x:y' AND c = :c`, + ), + ).toEqual(["a", "c"]); + }); +}); + +describe("QueryProcessor with literals containing colons", () => { + const processor = new QueryProcessor(); + const sql = `SELECT date_format(ts, "yyyy-MM-dd'T'HH:mm:ss") AS t FROM trips WHERE day >= :start_date`; + + test("only the real parameter is accepted", () => { + expect( + processor.convertToSQLParameters(sql, { + start_date: { __sql_type: "DATE", value: "2026-01-01" }, + } as never).parameters, + ).toHaveLength(1); + expect(() => + processor.convertToSQLParameters(sql, { + mm: { __sql_type: "STRING", value: "x" }, + } as never), + ).toThrow(/valid: start_date/); + }); +}); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 1a4e1306f..a56045b42 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -51,6 +51,9 @@ export interface BasePlugin { clientConfig?(): Record; + /** Vite plugins added to the dev server (development only). Typed loosely so `shared` doesn't depend on `vite`. */ + vitePlugins?(): unknown[]; + /** * Binds runtime dependencies (telemetry, cache, plugin context) after the * plugin has been constructed. Called by the AppKit core before `setup()`. diff --git a/template/README.md.tmpl b/template/README.md.tmpl index 5d3f9ce20..401fad6e9 100644 --- a/template/README.md.tmpl +++ b/template/README.md.tmpl @@ -15,6 +15,9 @@ A Databricks App powered by [AppKit](https://developers.databricks.com/docs/appk {{- if .plugins.genie}} - **Genie** -- AI/BI Genie conversational interface for natural language data queries {{- end}} +{{- if .plugins.design}} +- **Design** -- Dev-only design pages: `/design` renders `client/src/design/index.tsx`, `/design/` renders `client/src/design/pages/.tsx`, and HTML drafts in `drafts/.html` are served at `/design/drafts/` with their `data-appkit` blocks rendered by real AppKit components (404 in production) +{{- end}} - **Server** -- Express HTTP server with static file serving and Vite dev mode ## Prerequisites diff --git a/template/_gitignore b/template/_gitignore index 29895e7c0..cb3013bfb 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -5,6 +5,7 @@ dist/ build/ .env .databricks/ +.appkit/ .smoke-test/ test-results/ playwright-report/ diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index db53b96b7..aa9e6cae6 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -196,6 +196,17 @@ } } }, + "design": { + "name": "design", + "displayName": "Design Plugin", + "description": "Dev-only design pages: /design renders client/src/design/index.tsx and /design/ renders client/src/design/pages/.tsx, with a readiness signal for an embedding app builder; 404 in production", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [] + }, + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin", diff --git a/template/client/index.html b/template/client/index.html index a3da7c2b9..0ca2f3667 100644 --- a/template/client/index.html +++ b/template/client/index.html @@ -9,6 +9,8 @@ + + {{.projectName}} diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx index 7dad5a351..ec9b8a0bb 100644 --- a/template/client/src/App.tsx +++ b/template/client/src/App.tsx @@ -184,9 +184,12 @@ export default function App() { return ; } +// The template's default home page. `data-appkit-template-home` tells an app +// builder this is still the placeholder (it won't report the app ready while it +// is on screen); drop the attribute, or the page, when you build the real home. function HomePage() { return ( -
+

Welcome to your Databricks App diff --git a/template/client/src/design/index.tsx b/template/client/src/design/index.tsx new file mode 100644 index 000000000..ed27e1f2a --- /dev/null +++ b/template/client/src/design/index.tsx @@ -0,0 +1,40 @@ +{{if .plugins.design -}} +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +/* + * The design page, shown full size at /design (dev only). Build the layout here + * with AppKit components; add more pages as client/src/design/pages/.tsx, + * served at /design/. Each file's default export is the page. Link + * between pages with react-router's : design pages run under + * a router whose basename is /design. If something renders after loading (a + * chart, an animation), hold readiness back with useDesignReady(isDone) from + * '@databricks/appkit-ui/react/beta' so an app builder doesn't reveal it half-drawn. + */ + +// Starter-page marker: while it's exported, /design never reports itself ready +// (GET /design/__status, appkit:design-ready), so an app builder keeps showing +// its own view instead of this placeholder. Delete it when you write the page. +export const designPlaceholder = true; + +export default function DesignPage() { + return ( +
+
+
+

{{.projectName}}

+

A starting point for your design.

+
+ + + Get started + Replace this page with your layout. + + + + + +
+
+ ); +} +{{- end}} diff --git a/template/package.json b/template/package.json index a77ecf172..6a24d0644 100644 --- a/template/package.json +++ b/template/package.json @@ -8,7 +8,7 @@ "node": ">=22.23.4" }, "scripts": { - "start": "NODE_ENV=production node --env-file-if-exists=./.env ./dist/server.js", + "start": "NODE_ENV=production exec node --env-file-if-exists=./.env ./dist/server.js", "dev": "NODE_ENV=development tsx watch --tsconfig ./tsconfig.server.json --env-file-if-exists=./.env ./server/server.ts", "build:client": "tsc -b tsconfig.client.json && vite build --config client/vite.config.ts", "build:server": "tsc -b tsconfig.server.json && tsdown -c tsdown.server.config.ts", diff --git a/template/vitest.config.ts b/template/vitest.config.ts index c99a4069c..92303f1e1 100644 --- a/template/vitest.config.ts +++ b/template/vitest.config.ts @@ -7,6 +7,15 @@ export default defineConfig({ globals: true, environment: 'node', exclude: ['**/node_modules/**', '**/dist/**', '**/.databricks/**'], + server: { + deps: { + // Transform appkit-ui and echarts-for-react instead of letting Node + // load them: echarts-for-react's ESM build uses extensionless + // imports, which Node's ESM loader rejects, so a test importing an + // appkit-ui chart would fail to load. + inline: [/@databricks\/appkit-ui/, 'echarts-for-react'], + }, + }, }, resolve: { alias: { diff --git a/tools/generate-app-templates.ts b/tools/generate-app-templates.ts index d78a0e34b..4ecd99534 100644 --- a/tools/generate-app-templates.ts +++ b/tools/generate-app-templates.ts @@ -121,6 +121,12 @@ const APP_TEMPLATES: AppTemplate[] = [ description: "Node.js app with Lakebase Autoscaling (Postgres) CRUD operations", }, + { + name: "ui-only", + features: ["design"], + description: + "Empty UI-only app (server + dev-only design pages) for quick PoCs", + }, ]; function run(cmd: string, args: string[], opts?: { cwd?: string }): number { diff --git a/tools/generate-design-schema.ts b/tools/generate-design-schema.ts new file mode 100644 index 000000000..2fd584823 --- /dev/null +++ b/tools/generate-design-schema.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env tsx + +/** + * Writes the props schema of every `data-appkit` draft component to + * packages/appkit-ui/src/design/components.schema.json, shipped as + * `@databricks/appkit-ui/design/components.schema.json` so an agent can read + * it from node_modules. The design plugin's tests fail when it's stale. + * + * Usage: pnpm exec tsx tools/generate-design-schema.ts + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { dataAppkitSchema } from "../packages/appkit/src/plugins/design/drafts"; + +const out = path.join( + import.meta.dirname, + "../packages/appkit-ui/src/design/components.schema.json", +); +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, `${JSON.stringify(dataAppkitSchema(), null, 2)}\n`); +console.log(`Wrote ${path.relative(process.cwd(), out)}`);