Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e61a17659f | ||
|
|
ad6237dce2 | ||
|
|
f52f1ff070 | ||
|
|
7b096346cc | ||
|
|
bc3b7d7e92 | ||
|
|
a2682c4c09 | ||
|
|
22545b49cc | ||
|
|
a8afd8bfe2 | ||
|
|
dc489601be | ||
|
|
7758d4bbd5 |
@@ -21,7 +21,8 @@
|
|||||||
"@json-render/mcp",
|
"@json-render/mcp",
|
||||||
"@json-render/svelte",
|
"@json-render/svelte",
|
||||||
"@json-render/solid",
|
"@json-render/solid",
|
||||||
"@json-render/react-three-fiber"
|
"@json-render/react-three-fiber",
|
||||||
|
"@json-render/yaml"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
|
|||||||
@@ -13,34 +13,43 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
name: Lint, Type Check & Build
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: "pnpm"
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
|
||||||
- name: Install dependencies
|
test:
|
||||||
run: pnpm install --frozen-lockfile
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build packages
|
||||||
|
run: pnpm turbo run build --filter='./packages/*'
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
- name: Lint
|
typecheck:
|
||||||
run: pnpm lint
|
name: Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
- name: Type check
|
steps:
|
||||||
run: pnpm type-check
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
- name: Test
|
- uses: actions/setup-node@v4
|
||||||
run: pnpm test
|
with:
|
||||||
|
node-version: 20
|
||||||
- name: Build
|
cache: pnpm
|
||||||
run: pnpm build
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm type-check
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ function Dashboard({ spec }) {
|
|||||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||||
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||||
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
|
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
|
||||||
|
| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform |
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# web
|
# web
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/yaml@0.14.0
|
||||||
|
- @json-render/codegen@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface PromptOptions {
|
|||||||
system?: string; // Custom system message intro
|
system?: string; // Custom system message intro
|
||||||
customRules?: string[]; // Additional rules to append
|
customRules?: string[]; // Additional rules to append
|
||||||
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
|
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
|
||||||
|
editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"])
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpecValidationResult<T> {
|
interface SpecValidationResult<T> {
|
||||||
@@ -480,9 +481,10 @@ function buildUserPrompt(options: UserPromptOptions): string
|
|||||||
|
|
||||||
interface UserPromptOptions {
|
interface UserPromptOptions {
|
||||||
prompt: string; // The user's text prompt
|
prompt: string; // The user's text prompt
|
||||||
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
|
currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode)
|
||||||
state?: Record<string, unknown> | null; // Runtime state context to include
|
state?: Record<string, unknown> | null; // Runtime state context to include
|
||||||
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
||||||
|
editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"])
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -492,14 +494,15 @@ interface UserPromptOptions {
|
|||||||
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Refinement (patch-only mode)
|
### Refinement (edit modes)
|
||||||
|
|
||||||
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
|
When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff).
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
prompt: "add a dark mode toggle",
|
prompt: "add a dark mode toggle",
|
||||||
currentSpec: existingSpec,
|
currentSpec: existingSpec,
|
||||||
|
editModes: ["patch", "merge"],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -514,6 +517,93 @@ const userPrompt = buildUserPrompt({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Edit Modes
|
||||||
|
|
||||||
|
Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
buildEditInstructions,
|
||||||
|
buildEditUserPrompt,
|
||||||
|
isNonEmptySpec,
|
||||||
|
type EditMode,
|
||||||
|
type EditConfig,
|
||||||
|
} from '@json-render/core';
|
||||||
|
|
||||||
|
type EditMode = "patch" | "merge" | "diff";
|
||||||
|
```
|
||||||
|
|
||||||
|
### buildEditInstructions
|
||||||
|
|
||||||
|
Generate the prompt section describing available edit modes. Supports both JSON and YAML formats.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string
|
||||||
|
|
||||||
|
const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json");
|
||||||
|
```
|
||||||
|
|
||||||
|
### buildEditUserPrompt
|
||||||
|
|
||||||
|
Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildEditUserPrompt(options: BuildEditUserPromptOptions): string
|
||||||
|
|
||||||
|
interface BuildEditUserPromptOptions {
|
||||||
|
prompt: string;
|
||||||
|
currentSpec?: Spec | null;
|
||||||
|
config?: EditConfig;
|
||||||
|
format: "json" | "yaml";
|
||||||
|
maxPromptLength?: number;
|
||||||
|
serializer?: (spec: Spec) => string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### isNonEmptySpec
|
||||||
|
|
||||||
|
Check whether a value is a non-empty spec (has a root string and at least one element).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function isNonEmptySpec(spec: unknown): spec is Spec
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep Merge and Diff
|
||||||
|
|
||||||
|
Format-agnostic utilities for merging and diffing spec objects.
|
||||||
|
|
||||||
|
### deepMergeSpec
|
||||||
|
|
||||||
|
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { deepMergeSpec } from '@json-render/core';
|
||||||
|
|
||||||
|
function deepMergeSpec(
|
||||||
|
base: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Record<string, unknown>
|
||||||
|
|
||||||
|
const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### diffToPatches
|
||||||
|
|
||||||
|
Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { diffToPatches } from '@json-render/core';
|
||||||
|
|
||||||
|
function diffToPatches(
|
||||||
|
oldObj: Record<string, unknown>,
|
||||||
|
newObj: Record<string, unknown>,
|
||||||
|
basePath?: string
|
||||||
|
): JsonPatch[]
|
||||||
|
|
||||||
|
const patches = diffToPatches(oldSpec, newSpec);
|
||||||
|
// [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }]
|
||||||
|
```
|
||||||
|
|
||||||
## evaluateVisibility
|
## evaluateVisibility
|
||||||
|
|
||||||
Evaluates a visibility condition against the state model.
|
Evaluates a visibility condition against the state model.
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/yaml")
|
||||||
|
|
||||||
|
# @json-render/yaml
|
||||||
|
|
||||||
|
YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
|
||||||
|
|
||||||
|
## Prompt Generation
|
||||||
|
|
||||||
|
### yamlPrompt
|
||||||
|
|
||||||
|
Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function yamlPrompt(
|
||||||
|
catalog: Catalog,
|
||||||
|
options?: YamlPromptOptions
|
||||||
|
): string
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { yamlPrompt } from "@json-render/yaml";
|
||||||
|
|
||||||
|
const systemPrompt = yamlPrompt(catalog, {
|
||||||
|
mode: "standalone",
|
||||||
|
customRules: ["Always use dark theme"],
|
||||||
|
editModes: ["merge"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### YamlPromptOptions
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>system</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td><code>{'\"You are a UI generator that outputs YAML.\"'}</code></td>
|
||||||
|
<td>Custom system message intro</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>mode</code></td>
|
||||||
|
<td><code>{'\"standalone\" | \"inline\"'}</code></td>
|
||||||
|
<td><code>{'\"standalone\"'}</code></td>
|
||||||
|
<td>Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>customRules</code></td>
|
||||||
|
<td><code>{'string[]'}</code></td>
|
||||||
|
<td><code>{'[]'}</code></td>
|
||||||
|
<td>Additional rules appended to the prompt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>editModes</code></td>
|
||||||
|
<td><code>{'EditMode[]'}</code></td>
|
||||||
|
<td><code>{'[\"merge\"]'}</code></td>
|
||||||
|
<td>Edit modes to document in the prompt (patch, merge, diff)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## AI SDK Transform
|
||||||
|
|
||||||
|
### createYamlTransform
|
||||||
|
|
||||||
|
Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createYamlTransform(
|
||||||
|
options?: YamlTransformOptions
|
||||||
|
): TransformStream<StreamChunk, StreamChunk>
|
||||||
|
```
|
||||||
|
|
||||||
|
Recognized fence types:
|
||||||
|
|
||||||
|
- <code>{'```yaml-spec'}</code> -- Full YAML spec, parsed progressively
|
||||||
|
- <code>{'```yaml-edit'}</code> -- Partial YAML, deep-merged with current spec
|
||||||
|
- <code>{'```yaml-patch'}</code> -- RFC 6902 JSON Patch lines
|
||||||
|
- <code>{'```diff'}</code> -- Unified diff against serialized spec
|
||||||
|
|
||||||
|
### pipeYamlRender
|
||||||
|
|
||||||
|
Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function pipeYamlRender<T>(
|
||||||
|
stream: ReadableStream<T>,
|
||||||
|
options?: YamlTransformOptions
|
||||||
|
): ReadableStream<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pipeYamlRender } from "@json-render/yaml";
|
||||||
|
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||||
|
|
||||||
|
const stream = createUIMessageStream({
|
||||||
|
execute: async ({ writer }) => {
|
||||||
|
writer.merge(pipeYamlRender(result.toUIMessageStream()));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createUIMessageStreamResponse({ stream });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Parser
|
||||||
|
|
||||||
|
### createYamlStreamCompiler
|
||||||
|
|
||||||
|
Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createYamlStreamCompiler<T>(
|
||||||
|
initial?: Partial<T>
|
||||||
|
): YamlStreamCompiler<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createYamlStreamCompiler } from "@json-render/yaml";
|
||||||
|
|
||||||
|
const compiler = createYamlStreamCompiler<Spec>();
|
||||||
|
|
||||||
|
compiler.push("root: main\n");
|
||||||
|
compiler.push("elements:\n main:\n type: Card\n");
|
||||||
|
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
### YamlStreamCompiler
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Returns</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>push(chunk)</code></td>
|
||||||
|
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
||||||
|
<td>Push a chunk of text, returns current result and new patches</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>flush()</code></td>
|
||||||
|
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
||||||
|
<td>Flush remaining buffer, return final result</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>getResult()</code></td>
|
||||||
|
<td><code>T</code></td>
|
||||||
|
<td>Get the current compiled result</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>getPatches()</code></td>
|
||||||
|
<td><code>{'JsonPatch[]'}</code></td>
|
||||||
|
<td>Get all patches applied so far</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>reset(initial?)</code></td>
|
||||||
|
<td><code>void</code></td>
|
||||||
|
<td>Reset to initial state</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Fence Constants
|
||||||
|
|
||||||
|
Exported string constants for fence detection in custom parsers:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Constant</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_SPEC_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-spec\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_EDIT_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-edit\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_PATCH_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-patch\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DIFF_FENCE</code></td>
|
||||||
|
<td><code>{'\"```diff\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>FENCE_CLOSE</code></td>
|
||||||
|
<td><code>{'\"```\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Re-exports from @json-render/core
|
||||||
|
|
||||||
|
### diffToPatches
|
||||||
|
|
||||||
|
Generate RFC 6902 JSON Patch operations that transform one object into another.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function diffToPatches(
|
||||||
|
oldObj: Record<string, unknown>,
|
||||||
|
newObj: Record<string, unknown>,
|
||||||
|
basePath?: string
|
||||||
|
): JsonPatch[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### deepMergeSpec
|
||||||
|
|
||||||
|
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function deepMergeSpec(
|
||||||
|
base: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Record<string, unknown>
|
||||||
|
```
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||||
import { CopyPageButton } from "@/components/copy-page-button";
|
import { CopyPageButton } from "@/components/copy-page-button";
|
||||||
|
import { TableOfContents } from "@/components/table-of-contents";
|
||||||
|
|
||||||
export default function DocsLayout({
|
export default function DocsLayout({
|
||||||
children,
|
children,
|
||||||
@@ -10,7 +11,7 @@ export default function DocsLayout({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocsMobileNav />
|
<DocsMobileNav />
|
||||||
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
|
<div className="max-w-7xl mx-auto px-6 py-8 lg:py-12 flex gap-12">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||||
<DocsSidebar />
|
<DocsSidebar />
|
||||||
@@ -23,6 +24,11 @@ export default function DocsLayout({
|
|||||||
</div>
|
</div>
|
||||||
<article>{children}</article>
|
<article>{children}</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* On this page */}
|
||||||
|
<aside className="w-44 shrink-0 hidden xl:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
|
||||||
|
<TableOfContents />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ExampleCard({ example }: { example: Example }) {
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col rounded-xl border border-border bg-card text-card-foreground overflow-hidden transition-colors hover:border-foreground/25">
|
||||||
|
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||||
|
<h3 className="font-semibold leading-none">{example.title}</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{example.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{example.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[11px]">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center gap-3 pt-2">
|
||||||
|
{example.demoUrl && (
|
||||||
|
<a
|
||||||
|
href={example.demoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 3h6v6" />
|
||||||
|
<path d="M10 14 21 3" />
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
</svg>
|
||||||
|
Live Demo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={getGitHubUrl(example)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
|
</svg>
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExamplesPage() {
|
||||||
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = activeTag
|
||||||
|
? examples.filter((e) => e.tags.includes(activeTag))
|
||||||
|
: examples;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-6xl px-6 py-16">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Examples
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-lg text-muted-foreground">
|
||||||
|
Explore json-render across frameworks, renderers, and use cases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
activeTag === null
|
||||||
|
? "border-foreground bg-foreground text-background"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
activeTag === tag
|
||||||
|
? "border-foreground bg-foreground text-background"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((example) => (
|
||||||
|
<ExampleCard key={example.slug} example={example} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="py-12 text-center text-muted-foreground">
|
||||||
|
No examples match the selected filter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
|||||||
|
|
||||||
GitHub repository: https://github.com/vercel-labs/json-render
|
GitHub repository: https://github.com/vercel-labs/json-render
|
||||||
Documentation: https://json-render.dev/docs
|
Documentation: https://json-render.dev/docs
|
||||||
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
|
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
|
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||||
|
|
||||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,73 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { buildUserPrompt } from "@json-render/core";
|
import type { Spec, EditMode } from "@json-render/core";
|
||||||
|
import {
|
||||||
|
buildUserPrompt,
|
||||||
|
buildEditUserPrompt,
|
||||||
|
isNonEmptySpec,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import { yamlPrompt } from "@json-render/yaml";
|
||||||
|
import { stringify as yamlStringify } from "yaml";
|
||||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
const PLAYGROUND_RULES = [
|
||||||
customRules: [
|
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||||
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
||||||
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
||||||
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
];
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const MAX_PROMPT_LENGTH = 500;
|
const MAX_PROMPT_LENGTH = 500;
|
||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
|
function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
|
||||||
|
if (isYaml) {
|
||||||
|
return yamlPrompt(playgroundCatalog, {
|
||||||
|
mode: "standalone",
|
||||||
|
customRules: PLAYGROUND_RULES,
|
||||||
|
editModes: editModes ?? ["merge"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return playgroundCatalog.prompt({
|
||||||
|
customRules: PLAYGROUND_RULES,
|
||||||
|
editModes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildYamlUserPrompt(
|
||||||
|
prompt: string,
|
||||||
|
previousSpec?: Spec | null,
|
||||||
|
editModes?: EditMode[],
|
||||||
|
): string {
|
||||||
|
if (isNonEmptySpec(previousSpec)) {
|
||||||
|
return buildEditUserPrompt({
|
||||||
|
prompt,
|
||||||
|
currentSpec: previousSpec,
|
||||||
|
config: { modes: editModes ?? ["merge"] },
|
||||||
|
format: "yaml",
|
||||||
|
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||||
|
serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
|
||||||
|
return [
|
||||||
|
userText,
|
||||||
|
"",
|
||||||
|
"Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
// Get client IP for rate limiting
|
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
// Check rate limits (minute and daily)
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
minuteRateLimit.limit(ip),
|
minuteRateLimit.limit(ip),
|
||||||
dailyRateLimit.limit(ip),
|
dailyRateLimit.limit(ip),
|
||||||
@@ -48,22 +89,34 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prompt, context } = await req.json();
|
const { prompt, context, format, editModes } = await req.json();
|
||||||
|
const isYaml = format === "yaml";
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const systemPrompt = getSystemPrompt(isYaml, editModes);
|
||||||
prompt,
|
const userPrompt = isYaml
|
||||||
currentSpec: context?.previousSpec,
|
? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
|
||||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
: buildUserPrompt({
|
||||||
});
|
prompt,
|
||||||
|
currentSpec: context?.previousSpec,
|
||||||
|
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||||
|
editModes,
|
||||||
|
});
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||||
system: SYSTEM_PROMPT,
|
system: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemPrompt,
|
||||||
|
providerOptions: {
|
||||||
|
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream the text, then append token usage metadata at the end
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const textStream = result.textStream;
|
const textStream = result.textStream;
|
||||||
|
|
||||||
@@ -72,7 +125,6 @@ export async function POST(req: Request) {
|
|||||||
for await (const chunk of textStream) {
|
for await (const chunk of textStream) {
|
||||||
controller.enqueue(encoder.encode(chunk));
|
controller.enqueue(encoder.encode(chunk));
|
||||||
}
|
}
|
||||||
// Append usage metadata after stream completes
|
|
||||||
try {
|
try {
|
||||||
const usage = await result.usage;
|
const usage = await result.usage;
|
||||||
const meta = JSON.stringify({
|
const meta = JSON.stringify({
|
||||||
@@ -80,10 +132,12 @@ export async function POST(req: Request) {
|
|||||||
promptTokens: usage.inputTokens,
|
promptTokens: usage.inputTokens,
|
||||||
completionTokens: usage.outputTokens,
|
completionTokens: usage.outputTokens,
|
||||||
totalTokens: usage.totalTokens,
|
totalTokens: usage.totalTokens,
|
||||||
|
cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
|
||||||
|
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
|
||||||
});
|
});
|
||||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||||
} catch {
|
} catch {
|
||||||
// Usage not available — skip silently
|
// Usage not available
|
||||||
}
|
}
|
||||||
controller.close();
|
controller.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSearchIndex } from "@/lib/search-index";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = await getSearchIndex();
|
||||||
|
const terms = q.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
const results = index
|
||||||
|
.map((entry) => {
|
||||||
|
const titleLower = entry.title.toLowerCase();
|
||||||
|
const contentLower = entry.content.toLowerCase();
|
||||||
|
|
||||||
|
const titleMatch = terms.every((t) => titleLower.includes(t));
|
||||||
|
const contentMatch = terms.every((t) => contentLower.includes(t));
|
||||||
|
|
||||||
|
if (!titleMatch && !contentMatch) return null;
|
||||||
|
|
||||||
|
let snippet = "";
|
||||||
|
if (contentMatch) {
|
||||||
|
const firstTermIdx = Math.min(
|
||||||
|
...terms.map((t) => {
|
||||||
|
const idx = contentLower.indexOf(t);
|
||||||
|
return idx === -1 ? Infinity : idx;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (firstTermIdx !== Infinity) {
|
||||||
|
const start = Math.max(0, firstTermIdx - 40);
|
||||||
|
const end = Math.min(entry.content.length, firstTermIdx + 120);
|
||||||
|
snippet =
|
||||||
|
(start > 0 ? "..." : "") +
|
||||||
|
entry.content.slice(start, end).replace(/\n/g, " ") +
|
||||||
|
(end < entry.content.length ? "..." : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: entry.title,
|
||||||
|
href: entry.href,
|
||||||
|
section: entry.section,
|
||||||
|
snippet,
|
||||||
|
score: titleMatch ? 2 : 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
r,
|
||||||
|
): r is {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
section: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
} => r !== null,
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(({ title, href, section, snippet }) => ({
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
section,
|
||||||
|
snippet,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ results },
|
||||||
|
{ headers: { "Cache-Control": "public, max-age=60" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ function getHighlighter() {
|
|||||||
if (!highlighterPromise) {
|
if (!highlighterPromise) {
|
||||||
highlighterPromise = createHighlighter({
|
highlighterPromise = createHighlighter({
|
||||||
themes: [vercelLightTheme, vercelDarkTheme],
|
themes: [vercelLightTheme, vercelDarkTheme],
|
||||||
langs: ["json", "tsx", "typescript"],
|
langs: ["json", "tsx", "typescript", "yaml"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return highlighterPromise;
|
return highlighterPromise;
|
||||||
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang: "json" | "tsx" | "typescript";
|
lang: "json" | "tsx" | "typescript" | "yaml";
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
hideCopyButton?: boolean;
|
hideCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,10 +261,10 @@ export function DocsChat({
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
// Cmd+I to open sidebar and focus prompt, Escape to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((prev) => {
|
setOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -497,12 +497,12 @@ export function DocsChat({
|
|||||||
{!open && (
|
{!open && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||||
aria-label="Ask AI"
|
aria-label="Ask AI"
|
||||||
>
|
>
|
||||||
Ask AI
|
Ask AI
|
||||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||||
<span>⌘</span>K
|
<span>⌘</span>I
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { Search } from "./search";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetContent,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/playground", label: "Playground" },
|
||||||
|
{ href: "/examples", label: "Examples" },
|
||||||
|
{ href: "/docs", label: "Docs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function GitHubLink({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://github.com/vercel-labs/json-render"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
|
</svg>
|
||||||
|
<span>12k</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === "/playground") {
|
if (href === "/playground") {
|
||||||
return pathname === "/playground";
|
return pathname === "/playground";
|
||||||
}
|
}
|
||||||
|
if (href === "/examples") {
|
||||||
|
return pathname.startsWith("/examples");
|
||||||
|
}
|
||||||
if (href === "/docs") {
|
if (href === "/docs") {
|
||||||
return pathname.startsWith("/docs");
|
return pathname.startsWith("/docs");
|
||||||
}
|
}
|
||||||
@@ -62,48 +104,81 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-4">
|
|
||||||
<Link
|
{/* Desktop nav */}
|
||||||
href="/playground"
|
<nav className="hidden sm:flex items-center gap-4">
|
||||||
className={cn(
|
{navLinks.map((link) => (
|
||||||
"text-sm transition-colors",
|
<Link
|
||||||
isActive("/playground")
|
key={link.href}
|
||||||
? "text-primary font-medium"
|
href={link.href}
|
||||||
: "text-muted-foreground hover:text-foreground",
|
className={cn(
|
||||||
)}
|
"text-sm transition-colors",
|
||||||
>
|
isActive(link.href)
|
||||||
<span className="sm:hidden">Play</span>
|
? "text-primary"
|
||||||
<span className="hidden sm:inline">Playground</span>
|
: "text-muted-foreground hover:text-foreground",
|
||||||
</Link>
|
)}
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className={cn(
|
|
||||||
"text-sm transition-colors",
|
|
||||||
isActive("/docs")
|
|
||||||
? "text-primary font-medium"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/vercel-labs/json-render"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
{link.label}
|
||||||
</svg>
|
</Link>
|
||||||
<span>12k</span>
|
))}
|
||||||
</a>
|
<Search />
|
||||||
|
<GitHubLink />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile nav */}
|
||||||
|
<div className="flex sm:hidden items-center gap-3">
|
||||||
|
<Search />
|
||||||
|
<GitHubLink />
|
||||||
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
|
<SheetTrigger
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<line x1="4" x2="20" y1="12" y2="12" />
|
||||||
|
<line x1="4" x2="20" y1="6" y2="6" />
|
||||||
|
<line x1="4" x2="20" y1="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="overflow-y-auto p-6">
|
||||||
|
<SheetTitle className="mb-6">Menu</SheetTitle>
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"block py-2.5 text-sm transition-colors",
|
||||||
|
isActive(link.href)
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="my-3 border-t border-border" />
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<span className="text-sm text-muted-foreground">Theme</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { useUIStream, type TokenUsage } from "@json-render/react";
|
|
||||||
import type { Spec } from "@json-render/core";
|
import type { Spec } from "@json-render/core";
|
||||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { stringify as yamlStringify } from "yaml";
|
||||||
|
import type { EditMode } from "@json-render/core";
|
||||||
|
import {
|
||||||
|
usePlaygroundStream,
|
||||||
|
type StreamFormat,
|
||||||
|
type TokenUsage,
|
||||||
|
} from "@/lib/use-playground-stream";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -22,10 +28,10 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
|
|||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||||
|
|
||||||
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
|
type Tab = "spec" | "nested" | "stream" | "catalog" | "visual";
|
||||||
type RenderView = "preview" | "code";
|
type RenderView = "preview" | "code";
|
||||||
type MobileView =
|
type MobileView =
|
||||||
| "json"
|
| "spec"
|
||||||
| "nested"
|
| "nested"
|
||||||
| "stream"
|
| "stream"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
@@ -40,6 +46,79 @@ interface Version {
|
|||||||
status: "generating" | "complete" | "error";
|
status: "generating" | "complete" | "error";
|
||||||
usage: TokenUsage | null;
|
usage: TokenUsage | null;
|
||||||
rawLines: string[];
|
rawLines: string[];
|
||||||
|
format: StreamFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaygroundControls({
|
||||||
|
format,
|
||||||
|
setFormat,
|
||||||
|
editModes,
|
||||||
|
setEditModes,
|
||||||
|
showClear,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
format: StreamFormat;
|
||||||
|
setFormat: (f: StreamFormat) => void;
|
||||||
|
editModes: EditMode[];
|
||||||
|
setEditModes: React.Dispatch<React.SetStateAction<EditMode[]>>;
|
||||||
|
showClear: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
||||||
|
{(["jsonl", "yaml"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFormat(f)}
|
||||||
|
className={`px-1.5 py-0.5 transition-colors ${
|
||||||
|
format === f
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
||||||
|
{(["patch", "merge", "diff"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => {
|
||||||
|
setEditModes((prev) =>
|
||||||
|
prev.includes(m)
|
||||||
|
? prev.length > 1
|
||||||
|
? prev.filter((x) => x !== m)
|
||||||
|
: prev
|
||||||
|
: [...prev, m],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`px-1.5 py-0.5 transition-colors ${
|
||||||
|
editModes.includes(m)
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showClear && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,13 +179,15 @@ export function Playground() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("json");
|
const [activeTab, setActiveTab] = useState<Tab>("spec");
|
||||||
const [catalogSection, setCatalogSection] = useState<
|
const [catalogSection, setCatalogSection] = useState<
|
||||||
"components" | "actions"
|
"components" | "actions"
|
||||||
>("components");
|
>("components");
|
||||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||||
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
||||||
|
const [format, setFormat] = useState<StreamFormat>("jsonl");
|
||||||
|
const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -124,12 +205,13 @@ export function Playground() {
|
|||||||
rawLines: streamRawLines,
|
rawLines: streamRawLines,
|
||||||
send,
|
send,
|
||||||
clear,
|
clear,
|
||||||
} = useUIStream({
|
} = usePlaygroundStream({
|
||||||
api: "/api/generate",
|
api: "/api/generate",
|
||||||
|
format,
|
||||||
|
editModes,
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
console.error("Generation error:", err);
|
console.error("Generation error:", err);
|
||||||
toast.error(err.message || "Generation failed. Please try again.");
|
toast.error(err.message || "Generation failed. Please try again.");
|
||||||
// Mark the version as errored
|
|
||||||
if (generatingVersionIdRef.current) {
|
if (generatingVersionIdRef.current) {
|
||||||
const erroredVersionId = generatingVersionIdRef.current;
|
const erroredVersionId = generatingVersionIdRef.current;
|
||||||
setVersions((prev) =>
|
setVersions((prev) =>
|
||||||
@@ -140,7 +222,7 @@ export function Playground() {
|
|||||||
generatingVersionIdRef.current = null;
|
generatingVersionIdRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as Parameters<typeof useUIStream>[0]);
|
});
|
||||||
|
|
||||||
// Get the selected version
|
// Get the selected version
|
||||||
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
||||||
@@ -215,6 +297,7 @@ export function Playground() {
|
|||||||
status: "generating",
|
status: "generating",
|
||||||
usage: null,
|
usage: null,
|
||||||
rawLines: [],
|
rawLines: [],
|
||||||
|
format,
|
||||||
};
|
};
|
||||||
|
|
||||||
generatingVersionIdRef.current = newVersionId;
|
generatingVersionIdRef.current = newVersionId;
|
||||||
@@ -224,7 +307,7 @@ export function Playground() {
|
|||||||
|
|
||||||
// Pass the current tree as context so the API can iterate on it
|
// Pass the current tree as context so the API can iterate on it
|
||||||
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
||||||
}, [inputValue, isStreaming, send]);
|
}, [inputValue, isStreaming, send, format]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -250,9 +333,16 @@ export function Playground() {
|
|||||||
[selectedVersionId, isStreaming],
|
[selectedVersionId, isStreaming],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jsonCode = currentTree
|
const specCode = useMemo(() => {
|
||||||
? JSON.stringify(currentTree, null, 2)
|
if (!currentTree)
|
||||||
: "// waiting...";
|
return format === "yaml" ? "# waiting..." : "// waiting...";
|
||||||
|
if (format === "yaml") {
|
||||||
|
return yamlStringify(currentTree, { indent: 2 }).trimEnd();
|
||||||
|
}
|
||||||
|
return JSON.stringify(currentTree, null, 2);
|
||||||
|
}, [currentTree, format]);
|
||||||
|
|
||||||
|
const specLang = format === "yaml" ? "yaml" : "json";
|
||||||
|
|
||||||
const nestedCode = useMemo(() => {
|
const nestedCode = useMemo(() => {
|
||||||
if (!currentTree || !currentTree.root) return "// waiting...";
|
if (!currentTree || !currentTree.root) return "// waiting...";
|
||||||
@@ -275,7 +365,7 @@ export function Playground() {
|
|||||||
const componentName = element.type;
|
const componentName = element.type;
|
||||||
|
|
||||||
const propsObj: Record<string, unknown> = {};
|
const propsObj: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(element.props)) {
|
for (const [k, v] of Object.entries(element.props ?? {})) {
|
||||||
if (v !== null && v !== undefined) {
|
if (v !== null && v !== undefined) {
|
||||||
propsObj[k] = v;
|
propsObj[k] = v;
|
||||||
}
|
}
|
||||||
@@ -321,6 +411,15 @@ ${jsx}
|
|||||||
}`;
|
}`;
|
||||||
}, [currentTree]);
|
}, [currentTree]);
|
||||||
|
|
||||||
|
// Determine syntax lang for raw stream based on selected version's format
|
||||||
|
const streamLang = isSelectedVersionGenerating
|
||||||
|
? format === "yaml"
|
||||||
|
? "yaml"
|
||||||
|
: "json"
|
||||||
|
: selectedVersion?.format === "yaml"
|
||||||
|
? "yaml"
|
||||||
|
: "json";
|
||||||
|
|
||||||
// Chat pane content
|
// Chat pane content
|
||||||
const chatPane = (
|
const chatPane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
@@ -392,9 +491,15 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
<div className="mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{version.usage.totalTokens.toLocaleString()} tokens
|
{formatTokens(
|
||||||
|
version.usage.promptTokens - version.usage.cachedTokens,
|
||||||
|
)}{" "}
|
||||||
|
in · {formatTokens(version.usage.completionTokens)} out
|
||||||
|
{version.usage.cachedTokens > 0
|
||||||
|
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -425,20 +530,18 @@ ${jsx}
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
{versions.length > 0 ? (
|
<PlaygroundControls
|
||||||
<button
|
format={format}
|
||||||
onClick={() => {
|
setFormat={setFormat}
|
||||||
setVersions([]);
|
editModes={editModes}
|
||||||
setSelectedVersionId(null);
|
setEditModes={setEditModes}
|
||||||
clear();
|
showClear={versions.length > 0}
|
||||||
}}
|
onClear={() => {
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
setVersions([]);
|
||||||
>
|
setSelectedVersionId(null);
|
||||||
Clear
|
clear();
|
||||||
</button>
|
}}
|
||||||
) : (
|
/>
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -492,18 +595,18 @@ ${jsx}
|
|||||||
const copyText =
|
const copyText =
|
||||||
activeTab === "stream"
|
activeTab === "stream"
|
||||||
? currentRawLines.join("\n")
|
? currentRawLines.join("\n")
|
||||||
: activeTab === "json"
|
: activeTab === "spec"
|
||||||
? jsonCode
|
? specCode
|
||||||
: activeTab === "nested"
|
: activeTab === "nested"
|
||||||
? nestedCode
|
? nestedCode
|
||||||
: activeTab === "visual"
|
: activeTab === "visual"
|
||||||
? jsonCode
|
? specCode
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const codePane = (
|
const codePane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
(tab) => (
|
(tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -514,7 +617,7 @@ ${jsx}
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -679,7 +782,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang="json"
|
lang={streamLang}
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -691,7 +794,12 @@ ${jsx}
|
|||||||
) : activeTab === "nested" ? (
|
) : activeTab === "nested" ? (
|
||||||
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
||||||
) : (
|
) : (
|
||||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock
|
||||||
|
code={specCode}
|
||||||
|
lang={specLang}
|
||||||
|
fillHeight
|
||||||
|
hideCopyButton
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -790,7 +898,7 @@ ${jsx}
|
|||||||
: 0}
|
: 0}
|
||||||
</button>
|
</button>
|
||||||
{/* Code tabs */}
|
{/* Code tabs */}
|
||||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
(tab) => (
|
(tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -801,7 +909,7 @@ ${jsx}
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -982,7 +1090,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang="json"
|
lang={streamLang}
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -998,8 +1106,13 @@ ${jsx}
|
|||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
) : mobileView === "json" ? (
|
) : mobileView === "spec" ? (
|
||||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock
|
||||||
|
code={specCode}
|
||||||
|
lang={specLang}
|
||||||
|
fillHeight
|
||||||
|
hideCopyButton
|
||||||
|
/>
|
||||||
) : mobileView === "preview" ? (
|
) : mobileView === "preview" ? (
|
||||||
currentTree && currentTree.root ? (
|
currentTree && currentTree.root ? (
|
||||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||||
@@ -1075,20 +1188,18 @@ ${jsx}
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
{versions.length > 0 ? (
|
<PlaygroundControls
|
||||||
<button
|
format={format}
|
||||||
onClick={() => {
|
setFormat={setFormat}
|
||||||
setVersions([]);
|
editModes={editModes}
|
||||||
setSelectedVersionId(null);
|
setEditModes={setEditModes}
|
||||||
clear();
|
showClear={versions.length > 0}
|
||||||
}}
|
onClear={() => {
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
setVersions([]);
|
||||||
>
|
setSelectedVersionId(null);
|
||||||
Clear
|
clear();
|
||||||
</button>
|
}}
|
||||||
) : (
|
/>
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -1165,9 +1276,16 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
<div className="mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{version.usage.totalTokens.toLocaleString()} tokens
|
{formatTokens(
|
||||||
|
version.usage.promptTokens -
|
||||||
|
version.usage.cachedTokens,
|
||||||
|
)}{" "}
|
||||||
|
in · {formatTokens(version.usage.completionTokens)} out
|
||||||
|
{version.usage.cachedTokens > 0
|
||||||
|
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
section: string;
|
||||||
|
snippet: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Search() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
} else {
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.results);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// aborted or network error
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter" && results[activeIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(results[activeIndex].href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = listRef.current?.querySelector("[data-active='true']");
|
||||||
|
active?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
const hasQuery = query.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="hidden sm:flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
Search docs
|
||||||
|
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||||
|
<span>⌘</span>K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Search docs"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="gap-0 p-0 sm:max-w-lg"
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">Search documentation</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2 border-b px-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search docs..."
|
||||||
|
className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
|
||||||
|
>
|
||||||
|
{loading && hasQuery ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : hasQuery && results.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No results found.
|
||||||
|
</p>
|
||||||
|
) : !hasQuery ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Type to search documentation...
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
results.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.href}
|
||||||
|
data-active={i === activeIndex}
|
||||||
|
onClick={() => navigate(item.href)}
|
||||||
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
|
||||||
|
i === activeIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{item.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{item.section}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.snippet && (
|
||||||
|
<span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{item.snippet}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Heading = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getHeadings(): Heading[] {
|
||||||
|
const article = document.querySelector("article");
|
||||||
|
if (!article) return [];
|
||||||
|
const elements = article.querySelectorAll("h2[id], h3[id]");
|
||||||
|
return Array.from(elements).map((el) => ({
|
||||||
|
id: el.id,
|
||||||
|
text: el.textContent?.replace(/#$/, "").trim() ?? "",
|
||||||
|
level: el.tagName === "H3" ? 3 : 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOfContents() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [headings, setHeadings] = useState<Heading[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setHeadings(getHeadings()), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headings.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveId(entry.target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "0px 0px -75% 0px", threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const h of headings) {
|
||||||
|
const el = document.getElementById(h.id);
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
if (headings.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="On this page">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||||
|
On this page
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{headings.map((h) => (
|
||||||
|
<li key={h.id}>
|
||||||
|
<a
|
||||||
|
href={`#${h.id}`}
|
||||||
|
className={cn(
|
||||||
|
"block text-xs leading-relaxed py-0.5 transition-colors",
|
||||||
|
h.level === 3 && "pl-3",
|
||||||
|
activeId === h.id
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{h.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,63 +45,7 @@ export const docsNavigation: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Examples",
|
title: "Examples",
|
||||||
items: [
|
items: [{ title: "Browse All Examples", href: "/examples" }],
|
||||||
{
|
|
||||||
title: "Chat",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Dashboard",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "React Native",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "React PDF",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "React Email",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Remotion",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Svelte",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Vue",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Renders with Vite (Vue / React / Svelte / Solid)",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "MCP App",
|
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Guides",
|
title: "Guides",
|
||||||
@@ -144,6 +88,7 @@ export const docsNavigation: NavSection[] = [
|
|||||||
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
|
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
|
||||||
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
|
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
|
||||||
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
|
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
|
||||||
|
{ title: "@json-render/yaml", href: "/docs/api/yaml" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
export type Example = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
githubPath: string;
|
||||||
|
demoUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GITHUB_BASE =
|
||||||
|
"https://github.com/vercel-labs/json-render/tree/main/examples";
|
||||||
|
|
||||||
|
export const examples: Example[] = [
|
||||||
|
{
|
||||||
|
slug: "chat",
|
||||||
|
title: "Chat",
|
||||||
|
description:
|
||||||
|
"AI chat app with tool calling, streaming UI, and rich components powered by the AI SDK.",
|
||||||
|
tags: ["React", "Next.js", "AI"],
|
||||||
|
githubPath: "examples/chat",
|
||||||
|
demoUrl: "https://chat-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "dashboard",
|
||||||
|
title: "Dashboard",
|
||||||
|
description:
|
||||||
|
"AI-generated dashboard with drag-and-drop, charts, and real-time data binding.",
|
||||||
|
tags: ["React", "Next.js", "AI"],
|
||||||
|
githubPath: "examples/dashboard",
|
||||||
|
demoUrl: "https://dashboard-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "no-ai",
|
||||||
|
title: "No AI",
|
||||||
|
description:
|
||||||
|
"Static specs rendered without any AI — forms, cards, tables, and more from hardcoded JSON.",
|
||||||
|
tags: ["React", "Next.js"],
|
||||||
|
githubPath: "examples/no-ai",
|
||||||
|
demoUrl: "https://no-ai-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "svelte",
|
||||||
|
title: "Svelte",
|
||||||
|
description:
|
||||||
|
"Svelte renderer demo with counter, todo list, and two-way data binding.",
|
||||||
|
tags: ["Svelte", "Vite"],
|
||||||
|
githubPath: "examples/svelte",
|
||||||
|
demoUrl: "https://svelte-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "svelte-chat",
|
||||||
|
title: "Svelte Chat",
|
||||||
|
description: "AI chat app built with SvelteKit and the Svelte renderer.",
|
||||||
|
tags: ["Svelte", "SvelteKit", "AI"],
|
||||||
|
githubPath: "examples/svelte-chat",
|
||||||
|
demoUrl: "https://json-render-svelte-chat-demo.labs.vercel.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "vue",
|
||||||
|
title: "Vue",
|
||||||
|
description:
|
||||||
|
"Vue renderer demo with counter, todo list, and two-way data binding.",
|
||||||
|
tags: ["Vue", "Vite"],
|
||||||
|
githubPath: "examples/vue",
|
||||||
|
demoUrl: "https://vue-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "solid",
|
||||||
|
title: "Solid",
|
||||||
|
description:
|
||||||
|
"Solid renderer demo with counter, todo list, and two-way data binding.",
|
||||||
|
tags: ["Solid", "Vite"],
|
||||||
|
githubPath: "examples/solid",
|
||||||
|
demoUrl: "https://solid-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "vite-renderers",
|
||||||
|
title: "Multi-Framework Renderers",
|
||||||
|
description:
|
||||||
|
"Same spec rendered with React, Vue, Svelte, and Solid side by side — hot-swappable at runtime.",
|
||||||
|
tags: ["React", "Vue", "Svelte", "Solid", "Vite"],
|
||||||
|
githubPath: "examples/vite-renderers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "react-email",
|
||||||
|
title: "React Email",
|
||||||
|
description:
|
||||||
|
"Generate HTML and plain-text emails from json-render specs using React Email.",
|
||||||
|
tags: ["React", "Email"],
|
||||||
|
githubPath: "examples/react-email",
|
||||||
|
demoUrl: "https://react-email-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "react-pdf",
|
||||||
|
title: "React PDF",
|
||||||
|
description:
|
||||||
|
"Generate PDF documents from json-render specs with @react-pdf/renderer.",
|
||||||
|
tags: ["React", "PDF"],
|
||||||
|
githubPath: "examples/react-pdf",
|
||||||
|
demoUrl: "https://react-pdf-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "react-three-fiber",
|
||||||
|
title: "React Three Fiber",
|
||||||
|
description:
|
||||||
|
"3D scenes generated from json-render specs using Three.js and React Three Fiber.",
|
||||||
|
tags: ["React", "3D"],
|
||||||
|
githubPath: "examples/react-three-fiber",
|
||||||
|
demoUrl: "https://react-three-fiber-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "react-native",
|
||||||
|
title: "React Native",
|
||||||
|
description:
|
||||||
|
"Mobile app rendering json-render specs with Expo and React Native.",
|
||||||
|
tags: ["React Native", "Expo"],
|
||||||
|
githubPath: "examples/react-native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "remotion",
|
||||||
|
title: "Remotion",
|
||||||
|
description: "Generate videos from json-render specs using Remotion.",
|
||||||
|
tags: ["React", "Video"],
|
||||||
|
githubPath: "examples/remotion",
|
||||||
|
demoUrl: "https://remotion-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "image",
|
||||||
|
title: "Image",
|
||||||
|
description:
|
||||||
|
"Generate OG images and social cards from json-render specs using Satori.",
|
||||||
|
tags: ["React", "Image"],
|
||||||
|
githubPath: "examples/image",
|
||||||
|
demoUrl: "https://image-demo.json-render.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "mcp",
|
||||||
|
title: "MCP App",
|
||||||
|
description:
|
||||||
|
"MCP server that serves shadcn UIs to Claude, ChatGPT, Cursor, and VS Code.",
|
||||||
|
tags: ["React", "MCP", "Vite"],
|
||||||
|
githubPath: "examples/mcp",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allTags = Array.from(
|
||||||
|
new Set(examples.flatMap((e) => e.tags)),
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
export function getGitHubUrl(example: Example): string {
|
||||||
|
return `${GITHUB_BASE}/${example.slug}`;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
|
|
||||||
// Top-level
|
// Top-level
|
||||||
playground: "Playground",
|
playground: "Playground",
|
||||||
|
examples: "Examples",
|
||||||
|
|
||||||
// Docs
|
// Docs
|
||||||
docs: "Introduction",
|
docs: "Introduction",
|
||||||
@@ -58,6 +59,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
"docs/api/jotai": "@json-render/jotai API",
|
"docs/api/jotai": "@json-render/jotai API",
|
||||||
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
|
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
|
||||||
"docs/api/xstate": "@json-render/xstate API",
|
"docs/api/xstate": "@json-render/xstate API",
|
||||||
|
"docs/api/yaml": "@json-render/yaml API",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { docsNavigation } from "./docs-navigation";
|
||||||
|
import { mdxToCleanMarkdown } from "./mdx-to-markdown";
|
||||||
|
|
||||||
|
export type IndexEntry = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
section: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: IndexEntry[] | null = null;
|
||||||
|
|
||||||
|
function stripMarkdown(md: string): string {
|
||||||
|
return (
|
||||||
|
md
|
||||||
|
// Remove fenced code blocks entirely
|
||||||
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
|
// Remove inline code
|
||||||
|
.replace(/`[^`]+`/g, "")
|
||||||
|
// Remove markdown links, keep text
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||||
|
// Remove heading markers
|
||||||
|
.replace(/^#{1,6}\s+/gm, "")
|
||||||
|
// Remove bold/italic markers
|
||||||
|
.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
|
||||||
|
// Remove HTML tags
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
// Collapse whitespace
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mdxFileForSlug(slug: string): string {
|
||||||
|
const docsRoot = join(process.cwd(), "app", "(main)", "docs");
|
||||||
|
if (slug === "/docs") {
|
||||||
|
return join(docsRoot, "page.mdx");
|
||||||
|
}
|
||||||
|
const rest = slug.replace(/^\/docs\/?/, "");
|
||||||
|
return join(docsRoot, ...rest.split("/"), "page.mdx");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchIndex(): Promise<IndexEntry[]> {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const entries: IndexEntry[] = [];
|
||||||
|
|
||||||
|
for (const section of docsNavigation) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (item.external) continue;
|
||||||
|
try {
|
||||||
|
const raw = await readFile(mdxFileForSlug(item.href), "utf-8");
|
||||||
|
const md = mdxToCleanMarkdown(raw);
|
||||||
|
const content = stripMarkdown(md);
|
||||||
|
entries.push({
|
||||||
|
title: item.title,
|
||||||
|
href: item.href,
|
||||||
|
section: section.title,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
entries.push({
|
||||||
|
title: item.title,
|
||||||
|
href: item.href,
|
||||||
|
section: section.title,
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = entries;
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Spec, JsonPatch } from "@json-render/core";
|
||||||
|
import { setByPath, getByPath, removeByPath } from "@json-render/core";
|
||||||
|
|
||||||
|
export function setSpecValue(
|
||||||
|
newSpec: Spec,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
if (path === "/root") {
|
||||||
|
newSpec.root = value as string;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === "/state") {
|
||||||
|
newSpec.state = value as Record<string, unknown>;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/state/")) {
|
||||||
|
if (!newSpec.state) newSpec.state = {};
|
||||||
|
setByPath(
|
||||||
|
newSpec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
if (value == null || typeof value !== "object") return;
|
||||||
|
const el = value as Record<string, unknown>;
|
||||||
|
newSpec.elements[elementKey] = {
|
||||||
|
...el,
|
||||||
|
type: typeof el.type === "string" ? el.type : "",
|
||||||
|
props: el.props != null && typeof el.props === "object" ? el.props : {},
|
||||||
|
children: Array.isArray(el.children) ? el.children : [],
|
||||||
|
} as Spec["elements"][string];
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const newElement = { ...element };
|
||||||
|
setByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
"/" + pathParts.slice(1).join("/"),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSpecValue(newSpec: Spec, path: string): void {
|
||||||
|
if (path === "/state") {
|
||||||
|
delete newSpec.state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/state/") && newSpec.state) {
|
||||||
|
removeByPath(
|
||||||
|
newSpec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
delete newSpec.elements[elementKey];
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const newElement = { ...element };
|
||||||
|
removeByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
"/" + pathParts.slice(1).join("/"),
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpecValue(spec: Spec, path: string): unknown {
|
||||||
|
if (path === "/root") return spec.root;
|
||||||
|
if (path === "/state") return spec.state;
|
||||||
|
if (path.startsWith("/state/") && spec.state) {
|
||||||
|
return getByPath(
|
||||||
|
spec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getByPath(spec as unknown as Record<string, unknown>, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSpec(spec: Spec): void {
|
||||||
|
if (
|
||||||
|
spec.state === null ||
|
||||||
|
(spec.state !== undefined && typeof spec.state !== "object")
|
||||||
|
) {
|
||||||
|
spec.state = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(spec.elements)) {
|
||||||
|
const el = spec.elements[key];
|
||||||
|
if (!el || typeof el !== "object") {
|
||||||
|
delete spec.elements[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (el.props == null || typeof el.props !== "object") {
|
||||||
|
spec.elements[key] = { ...el, props: {} } as Spec["elements"][string];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(spec.elements[key]!.children)) {
|
||||||
|
spec.elements[key] = {
|
||||||
|
...spec.elements[key]!,
|
||||||
|
children: [],
|
||||||
|
} as Spec["elements"][string];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySpecPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||||
|
const newSpec = {
|
||||||
|
...spec,
|
||||||
|
elements: { ...spec.elements },
|
||||||
|
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||||
|
};
|
||||||
|
switch (patch.op) {
|
||||||
|
case "add":
|
||||||
|
case "replace":
|
||||||
|
setSpecValue(newSpec, patch.path, patch.value);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
removeSpecValue(newSpec, patch.path);
|
||||||
|
break;
|
||||||
|
case "move":
|
||||||
|
if (patch.from) {
|
||||||
|
const moveValue = getSpecValue(newSpec, patch.from);
|
||||||
|
removeSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, moveValue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "copy":
|
||||||
|
if (patch.from) {
|
||||||
|
setSpecValue(newSpec, patch.path, getSpecValue(newSpec, patch.from));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "test":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
normalizeSpec(newSpec);
|
||||||
|
return newSpec;
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import type { Spec, JsonPatch, EditMode } from "@json-render/core";
|
||||||
|
import { deepMergeSpec, diffToPatches } from "@json-render/core";
|
||||||
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
||||||
|
import { applyPatch as applyUnifiedDiff } from "diff";
|
||||||
|
import {
|
||||||
|
createYamlStreamCompiler,
|
||||||
|
YAML_SPEC_FENCE,
|
||||||
|
YAML_EDIT_FENCE,
|
||||||
|
YAML_PATCH_FENCE,
|
||||||
|
DIFF_FENCE,
|
||||||
|
FENCE_CLOSE,
|
||||||
|
} from "@json-render/yaml";
|
||||||
|
import { applySpecPatch } from "./spec-patch";
|
||||||
|
|
||||||
|
export type StreamFormat = "jsonl" | "yaml";
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePlaygroundStreamOptions {
|
||||||
|
api: string;
|
||||||
|
format: StreamFormat;
|
||||||
|
editModes?: EditMode[];
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onComplete?: (spec: Spec) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePlaygroundStreamReturn {
|
||||||
|
spec: Spec | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
usage: TokenUsage | null;
|
||||||
|
rawLines: string[];
|
||||||
|
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSONL helpers ──
|
||||||
|
|
||||||
|
type ParsedLine =
|
||||||
|
| { type: "patch"; patch: JsonPatch }
|
||||||
|
| { type: "usage"; usage: TokenUsage }
|
||||||
|
| { type: "json-edit"; mergeObj: Record<string, unknown> }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function parseLine(line: string): ParsedLine {
|
||||||
|
try {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("//")) return null;
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
return {
|
||||||
|
type: "usage",
|
||||||
|
usage: {
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed.__json_edit === true) {
|
||||||
|
const mergeObj = { ...parsed };
|
||||||
|
delete mergeObj.__json_edit;
|
||||||
|
return { type: "json-edit", mergeObj };
|
||||||
|
}
|
||||||
|
return { type: "patch", patch: parsed as JsonPatch };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
|
||||||
|
|
||||||
|
// ── Hook ──
|
||||||
|
|
||||||
|
export function usePlaygroundStream({
|
||||||
|
api,
|
||||||
|
format,
|
||||||
|
editModes,
|
||||||
|
onError,
|
||||||
|
onComplete,
|
||||||
|
}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
|
||||||
|
const [spec, setSpec] = useState<Spec | null>(null);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [usage, setUsage] = useState<TokenUsage | null>(null);
|
||||||
|
const [rawLines, setRawLines] = useState<string[]>([]);
|
||||||
|
const rawLinesRef = useRef<string[]>([]);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
const formatRef = useRef(format);
|
||||||
|
formatRef.current = format;
|
||||||
|
const editModesRef = useRef(editModes);
|
||||||
|
editModesRef.current = editModes;
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setSpec(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (prompt: string, context?: Record<string, unknown>) => {
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setIsStreaming(true);
|
||||||
|
setError(null);
|
||||||
|
setUsage(null);
|
||||||
|
rawLinesRef.current = [];
|
||||||
|
setRawLines([]);
|
||||||
|
|
||||||
|
const previousSpec = context?.previousSpec as Spec | undefined;
|
||||||
|
let currentSpec: Spec =
|
||||||
|
previousSpec && previousSpec.root
|
||||||
|
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
||||||
|
: { root: "", elements: {} };
|
||||||
|
setSpec(currentSpec);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(api, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
format: formatRef.current,
|
||||||
|
editModes: editModesRef.current,
|
||||||
|
}),
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP error: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) errorMessage = errorData.message;
|
||||||
|
else if (errorData.error) errorMessage = errorData.error;
|
||||||
|
} catch {
|
||||||
|
// use default
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error("No response body");
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
if (formatRef.current === "yaml") {
|
||||||
|
// ── YAML streaming ──
|
||||||
|
let fenceState: FenceState = "outside";
|
||||||
|
const compiler = createYamlStreamCompiler<Record<string, unknown>>();
|
||||||
|
let yamlEditAccumulated = "";
|
||||||
|
let yamlPatchAccumulated = "";
|
||||||
|
let diffAccumulated = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Check for usage metadata (appended after stream)
|
||||||
|
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
setUsage({
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLinesRef.current.push(line);
|
||||||
|
|
||||||
|
if (fenceState === "outside") {
|
||||||
|
if (
|
||||||
|
trimmed === YAML_SPEC_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_SPEC_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-spec";
|
||||||
|
compiler.reset();
|
||||||
|
} else if (
|
||||||
|
trimmed === YAML_EDIT_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_EDIT_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-edit";
|
||||||
|
yamlEditAccumulated = "";
|
||||||
|
} else if (
|
||||||
|
trimmed === YAML_PATCH_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_PATCH_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-patch";
|
||||||
|
yamlPatchAccumulated = "";
|
||||||
|
} else if (
|
||||||
|
trimmed === DIFF_FENCE ||
|
||||||
|
trimmed.startsWith(DIFF_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "diff";
|
||||||
|
diffAccumulated = "";
|
||||||
|
}
|
||||||
|
} else if (trimmed === FENCE_CLOSE || trimmed === "````") {
|
||||||
|
if (fenceState === "yaml-spec") {
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
if (result && typeof result === "object" && result.root) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-edit") {
|
||||||
|
try {
|
||||||
|
const editObj = yamlParse(yamlEditAccumulated);
|
||||||
|
if (editObj && typeof editObj === "object") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
editObj as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid YAML edit
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-patch") {
|
||||||
|
for (const patchLine of yamlPatchAccumulated.split("\n")) {
|
||||||
|
const t = patchLine.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try {
|
||||||
|
const patch = JSON.parse(t) as JsonPatch;
|
||||||
|
if (patch.op) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else if (fenceState === "diff") {
|
||||||
|
try {
|
||||||
|
const specYaml = yamlStringify(currentSpec, { indent: 2 });
|
||||||
|
const patched = applyUnifiedDiff(specYaml, diffAccumulated);
|
||||||
|
if (typeof patched === "string") {
|
||||||
|
const parsed = yamlParse(patched);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
parsed as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff apply or reparse failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fenceState = "outside";
|
||||||
|
} else if (fenceState === "yaml-spec") {
|
||||||
|
const { newPatches } = compiler.push(line + "\n");
|
||||||
|
if (newPatches.length > 0) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-edit") {
|
||||||
|
yamlEditAccumulated += line + "\n";
|
||||||
|
} else if (fenceState === "yaml-patch") {
|
||||||
|
yamlPatchAccumulated += line + "\n";
|
||||||
|
} else if (fenceState === "diff") {
|
||||||
|
diffAccumulated += line + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRawLines([...rawLinesRef.current]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const trimmed = buffer.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
setUsage({
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-spec") {
|
||||||
|
compiler.push(buffer);
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
if (result && typeof result === "object" && result.root) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── JSONL streaming ──
|
||||||
|
let jsonlDiffState: "outside" | "diff" = "outside";
|
||||||
|
let jsonlDiffAccumulated = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Diff fence detection within JSONL mode
|
||||||
|
if (jsonlDiffState === "outside") {
|
||||||
|
if (
|
||||||
|
trimmed === DIFF_FENCE ||
|
||||||
|
trimmed.startsWith(DIFF_FENCE + " ")
|
||||||
|
) {
|
||||||
|
jsonlDiffState = "diff";
|
||||||
|
jsonlDiffAccumulated = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
jsonlDiffState === "diff" &&
|
||||||
|
(trimmed === FENCE_CLOSE || trimmed === "````")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const specJson = JSON.stringify(currentSpec, null, 2);
|
||||||
|
const patched = applyUnifiedDiff(
|
||||||
|
specJson,
|
||||||
|
jsonlDiffAccumulated,
|
||||||
|
);
|
||||||
|
if (typeof patched === "string") {
|
||||||
|
const parsed = JSON.parse(patched);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
parsed as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff apply failed
|
||||||
|
}
|
||||||
|
jsonlDiffState = "outside";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonlDiffState === "diff") {
|
||||||
|
jsonlDiffAccumulated += line + "\n";
|
||||||
|
rawLinesRef.current.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard JSONL line parsing
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (!result) continue;
|
||||||
|
if (result.type === "usage") {
|
||||||
|
setUsage(result.usage);
|
||||||
|
} else if (result.type === "json-edit") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
result.mergeObj,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else {
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
currentSpec = applySpecPatch(currentSpec, result.patch);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRawLines([...rawLinesRef.current]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const trimmed = buffer.trim();
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (result) {
|
||||||
|
if (result.type === "usage") {
|
||||||
|
setUsage(result.usage);
|
||||||
|
} else if (result.type === "json-edit") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
result.mergeObj,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else {
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
currentSpec = applySpecPatch(currentSpec, result.patch);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current?.(currentSpec);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
setError(error);
|
||||||
|
onErrorRef.current?.(error);
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { spec, isStreaming, error, usage, rawLines, send, clear };
|
||||||
|
}
|
||||||
@@ -35,16 +35,26 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
|||||||
h2: ({ children }: { children?: React.ReactNode }) => {
|
h2: ({ children }: { children?: React.ReactNode }) => {
|
||||||
const id = slugify(extractText(children));
|
const id = slugify(extractText(children));
|
||||||
return (
|
return (
|
||||||
<h2 id={id} className="text-xl font-semibold mt-12 mb-4">
|
<h2 id={id} className="group text-xl font-semibold mt-12 mb-4">
|
||||||
{children}
|
<a href={`#${id}`} className="no-underline hover:no-underline">
|
||||||
|
{children}
|
||||||
|
<span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
|
||||||
|
#
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
h3: ({ children }: { children?: React.ReactNode }) => {
|
h3: ({ children }: { children?: React.ReactNode }) => {
|
||||||
const id = slugify(extractText(children));
|
const id = slugify(extractText(children));
|
||||||
return (
|
return (
|
||||||
<h3 id={id} className="text-lg font-medium mt-8 mb-3">
|
<h3 id={id} className="group text-lg font-medium mt-8 mb-3">
|
||||||
{children}
|
<a href={`#${id}`} className="no-underline hover:no-underline">
|
||||||
|
{children}
|
||||||
|
<span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
|
||||||
|
#
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@json-render/codegen": "workspace:*",
|
"@json-render/codegen": "workspace:*",
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
"@json-render/react": "workspace:*",
|
"@json-render/react": "workspace:*",
|
||||||
|
"@json-render/yaml": "workspace:*",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/mdx": "^3.1.1",
|
"@mdx-js/mdx": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -34,9 +35,9 @@
|
|||||||
"bash-tool": "1.3.14",
|
"bash-tool": "1.3.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"geist": "1.7.0",
|
"geist": "1.7.0",
|
||||||
"just-bash": "2.9.6",
|
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unist-util-visit": "5.1.0",
|
"unist-util-visit": "5.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Vercel AI Gateway
|
||||||
|
# Automatically authenticated when deployed on Vercel
|
||||||
|
# For local development, get your key from https://vercel.com/ai-gateway
|
||||||
|
AI_GATEWAY_API_KEY=
|
||||||
|
|
||||||
|
# AI Model Configuration
|
||||||
|
# Default: anthropic/claude-haiku-4.5
|
||||||
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# example-chat
|
# example-chat
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/shadcn@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { agent } from "@/lib/agent";
|
import { agent } from "@/lib/agent";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
@@ -6,10 +7,35 @@ import {
|
|||||||
type UIMessage,
|
type UIMessage,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
import { pipeJsonRender } from "@json-render/core";
|
import { pipeJsonRender } from "@json-render/core";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const uiMessages: UIMessage[] = body.messages;
|
const uiMessages: UIMessage[] = body.messages;
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:chat:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:chat:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-chat",
|
"name": "example-chat",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
"streamdown": "^2.2.0",
|
"streamdown": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -4,3 +4,10 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard
|
|||||||
# AI (optional - for UI generation)
|
# AI (optional - for UI generation)
|
||||||
AI_GATEWAY_API_KEY=
|
AI_GATEWAY_API_KEY=
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# example-dashboard
|
# example-dashboard
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/codegen@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { buildUserPrompt } from "@json-render/core";
|
import { buildUserPrompt } from "@json-render/core";
|
||||||
import { dashboardCatalog } from "@/lib/render/catalog";
|
import { dashboardCatalog } from "@/lib/render/catalog";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = dashboardCatalog.prompt();
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { prompt, context } = await req.json();
|
const { prompt, context } = await req.json();
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
|
|||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:dashboard:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:dashboard:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-dashboard",
|
"name": "example-dashboard",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Vercel AI Gateway
|
||||||
|
# Automatically authenticated when deployed on Vercel
|
||||||
|
# For local development, get your key from https://vercel.com/ai-gateway
|
||||||
|
AI_GATEWAY_API_KEY=
|
||||||
|
|
||||||
|
# AI Model Configuration
|
||||||
|
# Default: anthropic/claude-haiku-4.5
|
||||||
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-image
|
# example-image
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/image@0.14.0
|
||||||
|
|
||||||
## 0.1.4
|
## 0.1.4
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||||
import { imageCatalog } from "@/lib/catalog";
|
import { imageCatalog } from "@/lib/catalog";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = imageCatalog.prompt();
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { prompt, startingSpec } = (await req.json()) as {
|
const { prompt, startingSpec } = (await req.json()) as {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
startingSpec?: Spec | null;
|
startingSpec?: Spec | null;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:image:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:image:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-image",
|
"name": "example-image",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# example-mcp
|
# example-mcp
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/mcp@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/shadcn@0.14.0
|
||||||
|
|
||||||
## 0.1.3
|
## 0.1.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-mcp",
|
"name": "example-mcp",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# example-no-ai
|
# example-no-ai
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/shadcn@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-no-ai",
|
"name": "example-no-ai",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY=
|
|||||||
# Override the default model used for email generation
|
# Override the default model used for email generation
|
||||||
# Default: anthropic/claude-haiku-4.5
|
# Default: anthropic/claude-haiku-4.5
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-react-email
|
# example-react-email
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react-email@0.14.0
|
||||||
|
|
||||||
## 0.1.3
|
## 0.1.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||||
import { emailCatalog } from "@/lib/catalog";
|
import { emailCatalog } from "@/lib/catalog";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = emailCatalog.prompt();
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { prompt, startingSpec } = (await req.json()) as {
|
const { prompt, startingSpec } = (await req.json()) as {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
startingSpec?: Spec | null;
|
startingSpec?: Spec | null;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:react-email:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:react-email:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-react-email",
|
"name": "example-react-email",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-react-native
|
# example-react-native
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react-native@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-react-native",
|
"name": "example-react-native",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,3 +3,10 @@ AI_GATEWAY_API_KEY=
|
|||||||
|
|
||||||
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
|
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
|
||||||
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
|
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-react-pdf
|
# example-react-pdf
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react-pdf@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { streamText } from "ai";
|
|||||||
import { gateway } from "@ai-sdk/gateway";
|
import { gateway } from "@ai-sdk/gateway";
|
||||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||||
import { pdfCatalog } from "@/lib/catalog";
|
import { pdfCatalog } from "@/lib/catalog";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@@ -10,6 +12,30 @@ const SYSTEM_PROMPT = pdfCatalog.prompt();
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { prompt, startingSpec } = (await req.json()) as {
|
const { prompt, startingSpec } = (await req.json()) as {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
startingSpec?: Spec | null;
|
startingSpec?: Spec | null;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:react-pdf:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:react-pdf:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-react-pdf",
|
"name": "example-react-pdf",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# example-react-three-fiber
|
# example-react-three-fiber
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/react-three-fiber@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useSyncExternalStore, useCallback } from "react";
|
||||||
import { defineCatalog } from "@json-render/core";
|
import { defineCatalog } from "@json-render/core";
|
||||||
import type { ComputedFunction } from "@json-render/core";
|
import type { ComputedFunction } from "@json-render/core";
|
||||||
import { schema, defineRegistry } from "@json-render/react";
|
import { schema, defineRegistry } from "@json-render/react";
|
||||||
@@ -109,9 +109,27 @@ function highlightJson(json: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
function subscribeToResize(cb: () => void) {
|
||||||
|
window.addEventListener("resize", cb);
|
||||||
|
return () => window.removeEventListener("resize", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIsMobile() {
|
||||||
|
return typeof window !== "undefined"
|
||||||
|
? window.innerWidth < MOBILE_BREAKPOINT
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIsMobile() {
|
||||||
|
return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
const LIST_WIDTH = 220;
|
const LIST_WIDTH = 220;
|
||||||
const JSON_WIDTH = 380;
|
const JSON_WIDTH = 380;
|
||||||
const HEADER_HEIGHT = 40;
|
const HEADER_HEIGHT = 40;
|
||||||
|
const MOBILE_HEADER_HEIGHT = 48;
|
||||||
|
|
||||||
const headerStyle: React.CSSProperties = {
|
const headerStyle: React.CSSProperties = {
|
||||||
height: HEADER_HEIGHT,
|
height: HEADER_HEIGHT,
|
||||||
@@ -132,7 +150,236 @@ const headerStyle: React.CSSProperties = {
|
|||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
function MobileLayout() {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [showJson, setShowJson] = useState(false);
|
||||||
|
const [showScenes, setShowScenes] = useState(false);
|
||||||
|
const selected = scenes[selectedIndex]!;
|
||||||
|
|
||||||
|
const closePanels = useCallback(() => {
|
||||||
|
setShowJson(false);
|
||||||
|
setShowScenes(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100dvh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "#0a0a0a",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: MOBILE_HEADER_HEIGHT,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
borderBottom: "1px solid #1e1e1e",
|
||||||
|
background: "#0f0f0f",
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowScenes((v) => !v);
|
||||||
|
setShowJson(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "6px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: "50%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#555",
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "ui-monospace, monospace",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.description}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowJson((v) => !v);
|
||||||
|
setShowScenes(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "6px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "ui-monospace, monospace",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, position: "relative", minHeight: 0 }}>
|
||||||
|
<ThreeCanvas
|
||||||
|
key={selectedIndex}
|
||||||
|
spec={selected.spec}
|
||||||
|
registry={registry}
|
||||||
|
functions={computedFunctions}
|
||||||
|
shadows
|
||||||
|
camera={{ position: [0, 0, 5], fov: 50 }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showScenes && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={closePanels}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "75%",
|
||||||
|
maxWidth: 280,
|
||||||
|
background: "#0f0f0f",
|
||||||
|
borderRight: "1px solid #1e1e1e",
|
||||||
|
zIndex: 11,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflowY: "auto",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ ...headerStyle, height: 36 }}>Scenes</div>
|
||||||
|
<div style={{ padding: "6px 0" }}>
|
||||||
|
{scenes.map((scene, i) => (
|
||||||
|
<button
|
||||||
|
key={scene.name}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIndex(i);
|
||||||
|
setShowScenes(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 16px",
|
||||||
|
fontSize: 14,
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
background:
|
||||||
|
i === selectedIndex
|
||||||
|
? "rgba(255,255,255,0.08)"
|
||||||
|
: "transparent",
|
||||||
|
color: i === selectedIndex ? "#fff" : "#888",
|
||||||
|
fontWeight: i === selectedIndex ? 500 : 400,
|
||||||
|
cursor: "pointer",
|
||||||
|
borderLeft:
|
||||||
|
i === selectedIndex
|
||||||
|
? "2px solid #fff"
|
||||||
|
: "2px solid transparent",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showJson && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={closePanels}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "85%",
|
||||||
|
maxWidth: 400,
|
||||||
|
background: "#0d0d0d",
|
||||||
|
borderLeft: "1px solid #1e1e1e",
|
||||||
|
zIndex: 11,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
margin: 0,
|
||||||
|
padding: 14,
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "auto",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
|
||||||
|
color: "#EDEDED",
|
||||||
|
tabSize: 2,
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightJson(JSON.stringify(selected.spec, null, 2)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopLayout() {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const selected = scenes[selectedIndex]!;
|
const selected = scenes[selectedIndex]!;
|
||||||
|
|
||||||
@@ -236,3 +483,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
return isMobile ? <MobileLayout /> : <DesktopLayout />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-react-three-fiber",
|
"name": "example-react-three-fiber",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY=
|
|||||||
# Override the default model used for video generation
|
# Override the default model used for video generation
|
||||||
# Default: anthropic/claude-haiku-4.5
|
# Default: anthropic/claude-haiku-4.5
|
||||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-remotion
|
# example-remotion
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/remotion@0.14.0
|
||||||
|
|
||||||
## 0.1.7
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { getVideoPrompt } from "@/lib/catalog";
|
import { getVideoPrompt } from "@/lib/catalog";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
@@ -10,6 +12,30 @@ const MAX_PROMPT_LENGTH = 500;
|
|||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { prompt } = await req.json();
|
const { prompt } = await req.json();
|
||||||
|
|
||||||
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:remotion:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:remotion:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-remotion",
|
"name": "example-remotion",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
"remotion": "4.0.418",
|
"remotion": "4.0.418",
|
||||||
"shiki": "^3.21.0",
|
"shiki": "^3.21.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# example-solid
|
# example-solid
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/solid@0.14.0
|
||||||
|
|
||||||
## 0.1.1
|
## 0.1.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>json-render solid example</title>
|
<title>json-render solid example</title>
|
||||||
|
<meta property="og:title" content="Solid Example | json-render" />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Solid Example | json-render" />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-solid",
|
"name": "example-solid",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -1,5 +1,13 @@
|
|||||||
# com.example.json-render-demo
|
# com.example.json-render-demo
|
||||||
|
|
||||||
|
## 0.0.9
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
|
||||||
## 0.0.8
|
## 0.0.8
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "com.example.json-render-demo",
|
"name": "com.example.json-render-demo",
|
||||||
"version": "0.0.8",
|
"version": "0.0.9",
|
||||||
"description": "Test",
|
"description": "Test",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "~~proprietary~~",
|
"license": "~~proprietary~~",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# com.example.json-render-fullpage-demo
|
# com.example.json-render-fullpage-demo
|
||||||
|
|
||||||
|
## 0.0.9
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
|
||||||
## 0.0.8
|
## 0.0.8
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "com.example.json-render-fullpage-demo",
|
"name": "com.example.json-render-fullpage-demo",
|
||||||
"version": "0.0.8",
|
"version": "0.0.9",
|
||||||
"description": "Full-page Stripe App example (alpha)",
|
"description": "Full-page Stripe App example (alpha)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "~~proprietary~~",
|
"license": "~~proprietary~~",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Vercel AI Gateway
|
||||||
|
# Automatically authenticated when deployed on Vercel
|
||||||
|
# For local development, get your key from https://vercel.com/ai-gateway
|
||||||
|
AI_GATEWAY_API_KEY=
|
||||||
|
|
||||||
|
# AI Model Configuration
|
||||||
|
# Default: anthropic/claude-haiku-4.5
|
||||||
|
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||||
|
|
||||||
|
# Rate Limiting (Upstash Redis)
|
||||||
|
# Optional - rate limiting is disabled when these are not set
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
RATE_LIMIT_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_PER_DAY=100
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# svelte-chat
|
# svelte-chat
|
||||||
|
|
||||||
|
## 0.0.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/svelte@0.14.0
|
||||||
|
|
||||||
## 0.0.4
|
## 0.0.4
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "svelte-chat",
|
"name": "svelte-chat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-svelte": "^0.500.0",
|
"lucide-svelte": "^0.500.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta property="og:title" content="Svelte Chat Example | json-render" />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Svelte Chat Example | json-render" />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op rate limiter for when Redis is not configured
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:svelte-chat:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Requests per day (fixed window)
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:svelte-chat:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { agent } from "$lib/agent";
|
import { agent } from "$lib/agent";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "$lib/rate-limit";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
@@ -9,6 +10,30 @@ import { pipeJsonRender } from "@json-render/core";
|
|||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
|
minuteRateLimit.limit(ip),
|
||||||
|
dailyRateLimit.limit(ip),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!minuteResult.success || !dailyResult.success) {
|
||||||
|
const isMinuteLimit = !minuteResult.success;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: isMinuteLimit
|
||||||
|
? "Too many requests. Please wait a moment before trying again."
|
||||||
|
: "Daily limit reached. Please try again tomorrow.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const uiMessages: UIMessage[] = body.messages;
|
const uiMessages: UIMessage[] = body.messages;
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,13 @@
|
|||||||
# example-svelte
|
# example-svelte
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/svelte@0.14.0
|
||||||
|
|
||||||
## 0.1.4
|
## 0.1.4
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>json-render svelte example</title>
|
<title>json-render svelte example</title>
|
||||||
|
<meta property="og:title" content="Svelte Example | json-render" />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Svelte Example | json-render" />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-svelte",
|
"name": "example-svelte",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 17 KiB |
@@ -1,5 +1,16 @@
|
|||||||
# vite-renderers
|
# vite-renderers
|
||||||
|
|
||||||
|
## 0.1.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/solid@0.14.0
|
||||||
|
- @json-render/svelte@0.14.0
|
||||||
|
- @json-render/vue@0.14.0
|
||||||
|
|
||||||
## 0.1.5
|
## 0.1.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>@json-render multi-renderer demo</title>
|
<title>@json-render multi-renderer demo</title>
|
||||||
|
<meta property="og:title" content="Multi-Renderer Example | json-render" />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Multi-Renderer Example | json-render" />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-renderers",
|
"name": "vite-renderers",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
@@ -1,5 +1,13 @@
|
|||||||
# example-vue
|
# example-vue
|
||||||
|
|
||||||
|
## 0.1.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/vue@0.14.0
|
||||||
|
|
||||||
## 0.1.5
|
## 0.1.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>json-render vue example</title>
|
<title>json-render vue example</title>
|
||||||
|
<meta property="og:title" content="Vue Example | json-render" />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Vue Example | json-render" />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "example-vue",
|
"name": "example-vue",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -25,14 +25,16 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||||
"ci:publish": "pnpm run build && changeset publish"
|
"ci:publish": "pnpm run build && changeset publish",
|
||||||
|
"generate:og": "npx tsx scripts/generate-og-images.mts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "2.29.8",
|
"@changesets/cli": "2.29.8",
|
||||||
|
"@resvg/resvg-js": "2.6.2",
|
||||||
|
"@solidjs/testing-library": "^0.8.10",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@solidjs/testing-library": "^0.8.10",
|
|
||||||
"@testing-library/svelte": "^5.2.0",
|
"@testing-library/svelte": "^5.2.0",
|
||||||
"@types/react": "^19.2.3",
|
"@types/react": "^19.2.3",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"satori": "0.25.0",
|
||||||
"solid-js": "^1.9.11",
|
"solid-js": "^1.9.11",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"turbo": "^2.7.4",
|
"turbo": "^2.7.4",
|
||||||
|
|||||||