stonecrop
@stonecrop/nuxt

Stonecrop integration

Nuxt Stonecrop

npm version npm downloads

The official Nuxt module for Stonecrop - a schema-driven UI framework with event-driven workflows and hierarchical state management.

What is Stonecrop?

Stonecrop is a schema-driven UI framework that generates forms, tables, and workflows from JSON schemas. You define your data structure once and Stonecrop handles UI generation, state management, and validation.

Key Benefits:

  • Schema-Driven: Define data models in JSON; form and table rendering follows automatically
  • HST State Management: Hierarchical State Tree for complex, nested application state
  • FSM Workflows: XState-powered finite state machines for predictable business logic
  • Nuxt Native: First-class integration with Nuxt 4's architecture
  • Live Validation: Real-time form validation based on schema rules
  • Excel-like Tables: Rich table component with keyboard navigation and inline editing

Module Features

  • Route Generation: Scans your /doctypes folder and registers routes using your own page components
  • Plugin System: Auto-registers Stonecrop composables and utilities
  • Styling: Loads the Stonecrop token floor automatically; restyle by overriding --sc-* variables
  • TypeScript First: Full type safety and IntelliSense support
  • Thin Wrapper: The module is intentionally opinion-free — page rendering, queries, and navigation stay in your application

Quick Setup

Use the Stonecrop CLI to interactively install features:

npx @stonecrop/nuxt init

This will prompt you to select which features to install:

  • @stonecrop/nuxt - Frontend module with schema-driven UI
  • @stonecrop/graphql-client - GraphQL client with Stonecrop integration
  • @stonecrop/nuxt-grafserv - GraphQL server with Grafserv
  • @stonecrop/casl-middleware - CASL authorization
  • @stonecrop/rockfoil - PostGraphile middleware for database-driven GraphQL
  • Sample doctypes - Example doctype files to get started

You can also use flags for non-interactive installation:

# Install everything
npx @stonecrop/nuxt init --frontend --graphql-client --graphql --casl --rockfoil --doctypes --yes

# Install just the frontend module
npx @stonecrop/nuxt init --frontend

# Add GraphQL server to existing setup
npx @stonecrop/nuxt init --graphql

# Add PostGraphile middleware
npx @stonecrop/nuxt init --rockfoil

Option 2: Manual Installation

npx nuxi module add @stonecrop/nuxt

That's it! You can now use Stonecrop in your Nuxt app.

Basic Usage

Define a DocType Schema

Create a JSON schema in /doctypes/task.json:

{
  "name": "task",
  "label": "Task",
  "fields": [
    {
      "fieldname": "title",
      "label": "Title",
      "component": "ATextInput",
      "required": true
    },
    {
      "fieldname": "description",
      "label": "Description",
      "component": "ATextboxInput"
    },
    {
      "fieldname": "completed",
      "label": "Completed",
      "component": "ACheckbox"
    }
  ]
}

The module picks up this file and, if pageComponent is configured, registers a route at the doctype's slug value (or task if no slug is set), passing the parsed schema into route.meta.

Wire Up Your Data Client

After installing the module, add a client-side plugin to connect your data transport. Use useStonecropSetup() in plugins — it's designed for the initialization context where Stonecrop may not be fully ready yet:

// app/plugins/stonecrop.client.ts
import { StonecropClient } from '@stonecrop/graphql-client'

export default defineNuxtPlugin(() => {
  const { registerClient, registerMeta } = useStonecropSetup()

  const client = new StonecropClient({ endpoint: '/graphql' })

  // Register the data client for record fetching
  registerClient(client)

  // Configure metadata fetching for lazy-loaded doctypes
  // Called by useStonecrop() when it needs doctype metadata
  registerMeta(({ segments }) => {
    const doctype = segments[0] // e.g., "task" from /task/123
    return client.getMeta({ doctype })
  })
})

This wires record loading to your GraphQL (or any other) backend. It is the seam Desktop reads through as well. There is no fallback: without a registered client, getRecord and getRecords throw naming setClient, and Desktop skips the read, rendering empty lists and leaving a record on its loading state.

Use the Stonecrop Composable

In your page or component:

<script setup lang="ts">
import taskDoctype from '~/doctypes/task.json'

// HST-reactive form setup — pass doctype + recordId for full integration
const { stonecrop, provideHSTPath, handleHSTChange, formData } = useStonecrop({
  doctype: taskDoctype,
  recordId: 'task-123' // or undefined for new records
})

// Access the hierarchical state tree directly
const store = stonecrop.value?.getStore()
const taskTitle = store?.get('task.task-123.title')
</script>

<template>
  <AForm
    :schema="taskDoctype.fields"
    :data="formData"
    @update="handleHSTChange"
  />
</template>

Understanding Schema-Driven Development

Traditional Approach:

<!-- Manual form creation -->
<template>
  <form>
    <input v-model="task.title" required />
    <textarea v-model="task.description" />
    <input type="checkbox" v-model="task.completed" />
    <button @click="validate">Save</button>
  </form>
</template>

<script setup>
// Manual validation logic
const validate = () => {
  if (!task.title) {
    errors.title = 'Required'
  }
  // ... more validation
}
</script>

Stonecrop Approach:

<!-- Schema generates form automatically -->
<template>
  <AForm :schema="taskSchema" :data="formData" />
</template>

<script setup>
// Validation is automatic from schema
const { formData } = useStonecrop({
  doctype: taskDoctype,
  recordId: taskId
})
</script>

The schema defines:

  • Field types (text input, checkbox, select, etc.)
  • Validation rules (required, patterns, min/max)
  • Labels and help text
  • Relationships between data models

Configuration

Add options to your nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@stonecrop/nuxt'],

  stonecrop: {
    // Point to your own page component. Doctypes that declare a `route` get a page.
    pageComponent: 'pages/StonecropPage.vue',

    // Or supply a custom strategy for full control
    // routeStrategy: (doctypes) => [...],

    // Enable DocBuilder for visual schema editing
    docbuilder: false,
  },
})

The Stonecrop token floor (@stonecrop/themes/default.css) is always loaded — see Theming.

Module Options

OptionTypeDescription
pageComponentstringPath (relative to srcDir) to your page component. The module registers a route for each doctype that declares one, at exactly its route path, passing schema and doctype in route.meta.
routeStrategyRouteStrategyFnCustom function receiving all parsed doctypes; returns a NuxtPage[]. Takes priority over pageComponent.
docbuilderbooleanEnable the DocBuilder feature at /docbuilder. Defaults to false.
doctypesDirstringOverride the doctypes directory path. Defaults to doctypes/ inside srcDir.

If neither pageComponent nor routeStrategy is configured the module logs a warning and skips doctype route registration.

Theming

The module loads @stonecrop/themes/default.css into nuxt.options.css unconditionally. There is no option to change or disable it: that sheet is the only definition of the --sc-* variables every Stonecrop component reads, so opting out could only produce an unstyled app.

Restyle by overriding the variables in your own CSS:

/* app/assets/tokens.css */
:root {
	--sc-primary-color: #6d28d9;
	--sc-brand-color: #111827;
	--sc-font-family: 'IBM Plex Sans', sans-serif;
}

Your declaration wins. The floor wraps its own in the stonecrop.tokens cascade layer, and an unlayered declaration beats a layered one regardless of specificity or source order — so this works no matter where your stylesheet sits in nuxt.options.css. Keep the overrides on :root; scoping them to a container changes how they resolve outside it.

See the @stonecrop/themes README for the full token list and the rules on adding one.

Route Generation

Default: routes the doctypes declare

The module scans your doctypes/ folder and registers a route for each doctype carrying a route key, at exactly that path. A doctype without one gets no page.

doctypes/
  ├── task.json         { "route": "/task/:id" }   → /task/:id   the record
  ├── tasks.json        { "route": "/task" }       → /task       the collection
  └── task-comment.json no route                   → no page

Most doctypes are not meant to be visited. A child table's rows are edited inside their parent and a link target is reached through the record pointing at it, so neither needs a URL of its own.

stonecrop-schema generate writes these keys for you: an entity and its aggregate share one URL segment, and a doctype the schema shows as rows owned by another gets none. The generated file is yours after that — add, change or delete a route and regeneration leaves it alone.

Each route's meta contains the parsed doctype:

route.meta.schema   // ParsedDoctype['fields']
route.meta.doctype  // ParsedDoctype['data']

Your page component receives these via useRoute():

<script setup lang="ts">
const route = useRoute()
const schema = route.meta.schema   // array of field definitions
const doctype = route.meta.doctype // full doctype JSON object
</script>

Custom route strategy

For full control — multiple routes per doctype, conditional skipping, custom meta — provide a RouteStrategyFn:

import type { RouteStrategyFn } from '@stonecrop/nuxt'
import { resolve } from 'path'

const myStrategy: RouteStrategyFn = (doctypes) =>
  doctypes
    .flatMap(({ fileName, data, fields }) => [
      {
        name: `${fileName}-list`,
        path: `/${data.slug ?? fileName.toLowerCase()}`,
        file: resolve('./pages/ListPage.vue'),
        meta: { schema: fields, doctype: data, viewMode: 'list' },
      },
      {
        name: `${fileName}-detail`,
        path: `/${data.slug ?? fileName.toLowerCase()}/:id`,
        file: resolve('./pages/DetailPage.vue'),
        meta: { schema: fields, doctype: data, viewMode: 'detail' },
      },
    ])

export default defineNuxtConfig({
  stonecrop: { routeStrategy: myStrategy },
})

Plugin Registration

The module auto-registers:

  • useStonecropRegistry() - Composable for wiring data clients and getMeta after plugin install
  • useStonecrop() - Main composable for HST integration (from @stonecrop/stonecrop)
  • Pinia store configuration
  • AForm and ATable component registration (from @stonecrop/aform)

Why Schema-Driven?

Problem: Building CRUD applications is repetitive. Every data model needs:

  • Forms for creating/editing
  • Tables for listing
  • Validation logic
  • State management
  • API integration

Solution: Define the structure once, generate everything automatically.

Benefits:

  • Faster Development: Write less boilerplate code
  • Consistency: All forms/tables follow the same patterns
  • Fewer Bugs: Validation and state management are centralized
  • Self-Documenting: Schemas serve as data model documentation
  • Easy Updates: Change schema, UI updates automatically

useStonecropSetup() — Configuring the Framework in Plugins

When you need to configure Stonecrop during Nuxt plugin initialization (before components mount), use useStonecropSetup(). This composable is designed specifically for the plugin context and returns values that may be undefined if Stonecrop hasn't finished initializing.

// app/plugins/stonecrop.client.ts
import { StonecropClient } from '@stonecrop/graphql-client'

export default defineNuxtPlugin(() => {
  const { isReady, registerClient, registerMeta, registerDoctype } = useStonecropSetup()

  // Check if Stonecrop is ready (module plugins run before project plugins)
  if (!isReady) {
    console.warn('Stonecrop not ready - ensure @stonecrop/nuxt module is installed')
    return
  }

  const client = new StonecropClient({ endpoint: '/graphql' })

  // Register the data client
  registerClient(client)

  // Configure metadata fetching for lazy-loaded doctypes
  registerMeta(({ segments }) => {
    const doctype = segments[0]
    return client.getMeta({ doctype })
  })

  // Optionally pre-load doctypes into the Registry
  const planDoctype = Doctype.fromObject({ name: 'plan', fields: [...] })
  registerDoctype(planDoctype)
})

useStonecropSetup() vs useStonecropRegistry()

ContextUseBehavior
Plugin/initializationuseStonecropSetup()Returns values that may be undefined; provides isReady check
Component/runtimeuseStonecropRegistry()Throws if Stonecrop isn't initialized (expected to be ready)

useStonecropSetup() API

Property/MethodTypeDescription
registryRegistry | undefinedThe Registry instance (undefined if not ready)
stonecropStonecrop | undefinedThe Stonecrop instance (undefined if not ready)
isReadybooleantrue when both registry and stonecrop are available
registerClient(client)(client: DataClient) => voidSet the data client for record fetching. Throws if stonecrop not available.
registerMeta(fn)(fn: (ctx) => Doctype) => voidSet the getMeta function on the Registry. Throws if registry not available.
registerDoctype(doctype)(doctype: Doctype) => voidPre-load a doctype into the Registry. Throws if registry not available.

useStonecropRegistry() — Using the Framework in Components

useStonecropRegistry() is for component/runtime context where the framework is expected to be fully initialized. Use it in components to access the configured registry and stonecrop instances.

// In a component or composable
const { registry, stonecrop, dispatchAction } = useStonecropRegistry()

// Access doctype metadata
const plan = registry.getDoctype('plan')

// Dispatch actions
await dispatchAction({ name: 'plan' }, 'SUBMIT', [recordId])

Note: If called before Stonecrop is initialized (e.g., in a plugin), this throws with guidance to use useStonecropSetup() instead.

useStonecropRegistry() API

Property/MethodTypeDescription
registryRegistryThe Registry instance for doctype management.
stonecropStonecropThe Stonecrop instance for HST and operation log access. Throws if not initialized.
setMeta(fn)(fn: (ctx) => Doctype | Promise<Doctype>) => voidSets the getMeta function on the Registry. Called by useStonecrop({ doctype: 'slug' }) to lazy-load that doctype's metadata. ctx = { path, segments }.
setClient(client)(client: DataClient) => voidSet the data client for record fetching. Throws if stonecrop not available.
getClient()() => DataClient | undefinedGet the currently configured client.
dispatchAction(doctype, action, args)Promise<{ success, data, error }>Dispatch an action via the configured client, storing the result's record (the server's read of the record after the action). Returns error if doctype not found in registry.

Advanced Features

Hierarchical State Tree (HST)

HST provides path-based state addressing:

const store = stonecrop.getStore()

// Set nested values with dot notation
store.set('project.proj-1.tasks.task-1.completed', true)

// Get values anywhere in the tree
const completed = store.get('project.proj-1.tasks.task-1.completed')

// Navigate the tree hierarchy
const taskNode = store.getNode('project.proj-1.tasks.task-1')
const ancestor = taskNode.getAncestor() // Returns project node
const breadcrumbs = taskNode.getBreadcrumbs()

XState Integration

Define workflows as finite state machines:

import { createMachine } from 'xstate'

const taskMachine = createMachine({
  id: 'task',
  initial: 'draft',
  states: {
    draft: {
      on: { SUBMIT: 'pending' }
    },
    pending: {
      on: {
        APPROVE: 'completed',
        REJECT: 'draft'
      }
    },
    completed: {
      type: 'final'
    }
  }
})

// Stonecrop persists state machine data in HST

Examples

All runnable examples live in documentation at /playground:

  • Workflow — grafserv + guarded transitions over session-scoped in-memory storage (Orders/Users), Desktop rail panels
  • Countries — doctypes introspected from the public countries GraphQL API, same Desktop shell
  • DocBuilder — public doctype prototyping at /docbuilder

pnpm run dev (alias for dev:documentation) runs the docs site on port 3002.

Contribution

This package is a workspace member, so its dependencies resolve only under pnpm. Run these from the repository root, or from nuxt/ for the scripts below.

# Install dependencies, from the repository root
pnpm install

# Generate type stubs
pnpm run dev:prepare

# Develop the documentation site (docs + playgrounds)
pnpm run dev

# Build the documentation site
pnpm run dev:build

# Run ESLint
pnpm run lint

# Run Vitest
pnpm run test
pnpm run test:watch

Testing the CLI Locally

To test the npx @stonecrop/nuxt init command from another directory outside this project:

1. Build the monorepo (from stonecrop root):

cd /path/to/stonecrop
pnpm install
pnpm run build

2. Create a test Nuxt project (in a separate directory):

cd /tmp  # or any directory outside stonecrop
npx nuxi init my-test-app
cd my-test-app
npm install

3. Run the CLI using the local package:

# Option A: Run from within the nuxt package directory (simplest)
# This ensures Node can find the dependencies
cd /path/to/stonecrop/nuxt
node bin/init.mjs init --cwd /tmp/my-test-app

# Option B: Use pnpm link (from the test project)
cd /path/to/stonecrop/nuxt
pnpm link --global
cd /tmp/my-test-app
pnpm link --global @stonecrop/nuxt
npx stonecrop-nuxt init

# Option C: Use npm pack to create a tarball (simulates real npm install)
cd /path/to/stonecrop/nuxt
npm pack
cd /tmp/my-test-app
npm install /path/to/stonecrop/nuxt/stonecrop-nuxt-<version>.tgz
npx stonecrop-nuxt init
Note: Option A uses --cwd to specify the target directory while running from within the nuxt package where dependencies are available. Options B and C install the package into the test project so dependencies are resolved correctly.

4. Interactive testing:

The CLI will detect that you're in a Nuxt project and prompt for features:

🌱 Stonecrop Nuxt Installer

✔ Nuxt project detected

? Select features to install
  ◉ @stonecrop/nuxt - Frontend module
  ◯ @stonecrop/graphql-client - GraphQL client
  ◯ @stonecrop/nuxt-grafserv - GraphQL server
  ◯ @stonecrop/casl-middleware - Authorization
  ◯ @stonecrop/rockfoil - PostGraphile middleware
  ◉ Sample doctypes

5. Verify the installation:

After running the installer, check:

  • package.json has the new dependencies
  • nuxt.config.ts has the module configuration
  • doctypes/ folder contains sample schemas (if selected)
  • server/ folder contains GraphQL files (if selected)