@frozencloud/studio
v0.2.16
Published
Runtime block editor for Nuxt — edit pages visually in a database-backed dashboard, using your existing components without rewriting them
Readme
@frozencloud/studio
Runtime, database-backed block editor for Nuxt. Edit pages visually in a dashboard using your existing Vue components without rewriting them — no field definitions, no markdown+git+rebuild cycle.
- Your components drive the editor. Prop forms are auto-generated from your stock SFCs (typed props, defaults, JSDoc descriptions, unions, arrays of objects) via nuxt-component-meta. Prop names steer the inputs:
*icon*props get an Iconify picker,html/content/bodyprops get a WYSIWYG editor. - Pages live in Postgres. Trees are stored as Comark AST (JSON) via Drizzle and rendered at runtime — publishing never rebuilds the app.
- Pruvious-style editor. Block tree with icons and slot nesting, live iframe preview of the real page, prop form; click-to-select in the preview, drag/drop, undo/redo, resizable sidebars, named page variants.
- AI built in (optional). Compose sections from your catalog by prompt, or generate entirely new components — compiled server-side, previewed in a sandbox, and gated by approval before they reach a page.
Feature tour
| Area | What you get |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dashboard /studio | Page list grouped into collapsible folders by path segment; create/duplicate/delete/publish |
| Editor /studio/:pageId | 3-pane layout (resizable sidebars, sizes persist): block tree · live preview · prop form |
| Block tree | Per-block icons, hover actions, "Add block" modal (searchable icon grid + per-block "?" docs view), "Add inner block" for components with a default slot |
| Visual editing | Click/hover selection synced both ways with overlay labels, SortableJS drag/drop (slot-aware drop targets), undo/redo, copy/paste, Delete key |
| Prop forms | Generated from JSON schema: string/number/boolean/enum inputs, repeatable groups for arrays of objects, icon picker, rich text (UEditor), slot text |
| Variants | Named snapshots of a page's draft — save, restore (undoable), delete; backed by studio_page_variants |
| Theming /studio/theme | Site-wide runtime theme (Nuxt UI semantic colors + radius) with live sample preview; StudioTheme block re-themes just its nested blocks; preview light/dark toggle in the editor |
| AI tier 1 | "Generate" in the editor: prompt → block tree using only catalog components → preview in iframe → accept/discard |
| AI tier 2 | /studio/components: prompt → new SFC, compiled with the host's vue/compiler-sfc, sandboxed preview, approve/reject |
| Component workbench | /studio/components/:id: monaco editor · sandbox preview · AI chat (streamed, "Apply to editor"); hand-write components without AI; edits force re-approval |
| Runtime mount | Approved components mount client-side on public pages and join the editor catalog like any allowlisted block (SSR deferred) |
Setup
pnpm add @frozencloud/studio drizzle-orm@^1.0.0-rc.3// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@frozencloud/studio"],
studio: {
// allowlist of editor-visible components (dirs or globs, relative to srcDir)
components: ["components/blocks"],
},
components: [
// blocks must be globally registered so the renderer can resolve them by name
{ path: "~/components/blocks", global: true, pathPrefix: false },
"~/components",
],
});The dashboard is built on Nuxt UI (installed by the module) — wrap your app in <UApp> in app.vue if you don't already.
Database
The module never owns a connection or migrations. Hand it your Drizzle instance from a Nitro plugin:
// server/plugins/studio.ts
import pg from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
export default defineNitroPlugin(() => {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
setStudioDatabase(drizzle({ client: pool }));
});Add the schema to your own migration pipeline:
// drizzle.config.ts — point drizzle-kit at the exported schema
export default defineConfig({
dialect: "postgresql",
schema: ["./server/database/schema.ts", "./node_modules/@frozencloud/studio/dist/db.mjs"],
// …
});The schema is also importable as @frozencloud/studio/db (studioPages, studioComponents, studioPageVariants, studioSettings, types).
Auth
Studio routes (/studio/**, /api/_studio/** except the public render endpoint, the approved-components feed and token-gated sandbox documents) are open in dev and forbidden in production until you register an authorizer. With nuxt-auth-utils:
// server/plugins/studio.ts (same plugin as the database)
setStudioAuthorize(async (event) => {
const session = await getUserSession(event);
return !!session.user;
});Any callback (event: H3Event) => boolean | Promise<boolean> works.
Theming
Studio pages adopt Nuxt UI's runtime theming: colors are CSS variables, so they change at runtime — no rebuild.
- Site theme (
/studio/theme): assign Tailwind palettes to the semantic colors (primary,secondary,neutral, …) and pick a radius preset. Stored instudio_settings, applied by the renderer around every studio page (layout chrome included); the dashboard itself is unaffected. Unset keys fall back to the host'sapp.config.tscolors. - Sub-theme blocks: the built-in
StudioThemeblock wraps other blocks in its default slot and re-scopes the same variables for just that section. Nest them — inner themes inherit whatever they don't override. The AI assistant knows about it ("make this section emerald"). - Preview color mode: the editor toolbar toggles the preview iframe between light and dark without touching your dashboard preference.
Custom palettes work too: any palette defined in the host's CSS with @theme static { --color-brand-50: …; } can be typed into the theme API (PUT /api/_studio/site-theme, e.g. { "primary": "brand" }) — the pickers only list Tailwind's built-ins.
AI (optional)
Generation needs one of two credentials at runtime — without one, AI endpoints return a clean 503 and the rest of studio works normally:
| Env var | Provider |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| NUXT_STUDIO_AI_API_KEY (or ANTHROPIC_API_KEY) | Anthropic direct |
| NUXT_AI_GATEWAY_API_KEY (or AI_GATEWAY_API_KEY) | Vercel AI Gateway — model ids are mapped automatically (claude-opus-4-8 → anthropic/claude-opus-4.8) |
Generated components are constrained to plain-JavaScript SFCs with vue-only imports and scoped CSS, compiled and validated server-side, and never served to visitors until approved in /studio/components.
Options
| Option | Default | Description |
| ------------------- | ----------------- | ------------------------------------------------------------------------------------ |
| studio.enabled | true | Master switch. When false the module registers nothing — no routes, pages or code. |
| studio.components | [] | Allowlist of editor-visible components (dirs/globs relative to srcDir). |
| studio.ai.enabled | true | Disable the AI endpoints and editor UI entirely. |
| studio.ai.model | claude-opus-4-8 | Model id (also overridable via NUXT_STUDIO_AI_MODEL). |
Preview tokens are signed with runtimeConfig.studio.previewSecret (override via NUXT_STUDIO_PREVIEW_SECRET; defaults to a per-build random value).
How it works
- Catalog — nuxt-component-meta extracts props/slots from allowlisted SFCs;
/api/_studio/componentsserves them as JSON Schema, which drives the auto-generated prop forms, the block picker docs, and the AI prompts. Approved runtime components merge into the same catalog. - Storage — pages live in
studio_pages(draft tree + published tree asjsonb, versioned); snapshots instudio_page_variants; generated components instudio_components(source + compiled + meta + approval status). - Rendering — a lowest-priority catch-all route resolves unknown paths against the database and renders the published tree with
@comark/vue'sComarkRenderer. Your explicit routes always win; publishing requires no rebuild. A client plugin mounts approved runtime components globally. - Editing — the editor iframe loads the real page with a short-lived signed preview token (draft tree); edits stream into it over a same-origin
postMessageprotocol (tree:update,select,hover,highlight). - AI component pipeline — prompt → SFC → server compile (host's
vue/compiler-sfc, bundler-free output) → pending registry row → opaque-origin sandbox preview (<iframe sandbox="allow-scripts">+ CSP) → approval → public runtime feed.
For the full build log — per-phase decisions, verified exit criteria and gotchas — see PLAN.md.
Contribution
# Install dependencies
vp install
# Generate type stubs
npm run dev:prepare
# Start the playground database, migrate and seed it
cd playground && npm run db:up && npm run db:migrate && npm run db:seed && cd ..
# Develop with the playground
npm run dev
# Lint, type check, test, build
vp check
npm run test:types
vp test
npm run prepackModule changes (src/module.ts routes/pages) need a dev-server restart; if generated route
types stay stale, rebuild the stub and clear the jiti cache:
npx nuxt-module-build build --stub && rm -rf node_modules/.cache/jiti && npx nuxt prepare playground.
