Skip to content

Latest commit

 

History

History
722 lines (546 loc) · 31 KB

File metadata and controls

722 lines (546 loc) · 31 KB

Nuxt Studio

Overview

Nuxt Studio is an open-source, self-hostable Nuxt module that enables visual content editing in production for Nuxt Content websites. Originally a premium hosted platform, it's now a free MIT-licensed module that runs entirely on your own infrastructure.

Key Concept: This module adds a full-featured CMS editor directly into your Nuxt Content application, allowing non-technical users to edit content and commit changes to Git without needing local development tools.

Core Features

  • Visual Editors: TipTap-based Notion-like editor for Markdown with MDC component support, form-based editor for YAML/JSON
  • Code Editor: Monaco editor with syntax highlighting for direct file editing
  • Real-time Preview: See changes instantly on production website
  • Git Integration: Commits directly to GitHub/GitLab repositories
  • Multi-provider Auth: GitHub, GitLab, Google OAuth, or custom authentication
  • Media Management: Visual media library with drag-and-drop support
  • Development Mode: Local filesystem sync for development
  • Production Mode: OAuth + Git publishing for deployed sites
  • i18n Support: 25 languages built-in

Architecture

Project Structure

studio/
├── src/
│   ├── app/           # Vue app for the Studio editor interface
│   │   ├── src/       # Vue components, composables, utils
│   │   └── service-worker.ts
│   └── module/        # Nuxt module code
│       └── src/
│           ├── runtime/  # Runtime server routes & plugins
│           └── module.ts # Main module definition
├── playground/        # Development examples
│   ├── docus/         # Full-featured example
│   └── minimal/       # Minimal example
├── docs/              # Documentation site (also a Nuxt Content app)

Two Operating Modes

  1. Development Mode (default in dev)

    • Direct filesystem access via server routes
    • No auth required
    • Changes sync immediately to local files
    • No Git operations
  2. Production Mode (default in prod)

    • OAuth authentication required
    • Git provider integration for commits
    • Changes pushed to repository
    • Triggers CI/CD pipeline

Key Technologies

  • Nuxt 3: Core framework
  • Nuxt Content: Content management layer (peer dependency)
  • comark: MDC (Markdown Components) parsing and rendering — produces a compact array-based ComarkTree AST
  • TipTap: Visual WYSIWYG editor
  • Monaco Editor: Code editor
  • Vue Router: SPA routing inside Studio
  • IndexedDB: Client-side draft storage via idb-keyval
  • Service Worker: Offline support and caching
  • Shiki: Syntax highlighting
  • Zod: Schema validation for forms

Configuration

Basic Setup

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/content', 'nuxt-studio'],

  studio: {
    route: '/_studio',  // Admin route

    // Git repository config (required for production)
    repository: {
      provider: 'github',  // 'github' | 'gitlab'
      owner: 'username',
      repo: 'repo-name',
      branch: 'main',
      rootDir: '',         // For monorepos
      private: true        // Request private repo access
    },

    // i18n
    i18n: {
      defaultLocale: 'en'  // 25 languages available
    },

    // Component filtering
    meta: {
      components: {
        include: ['Content*'],  // Whitelist
        exclude: ['Hidden*']    // Blacklist
      }
    }
  }
})

Environment Variables

GitHub OAuth + GitHub Git Provider:

STUDIO_GITHUB_CLIENT_ID=xxx
STUDIO_GITHUB_CLIENT_SECRET=xxx
STUDIO_GITHUB_MODERATORS=email1@example.com,email2@example.com  # Optional

GitLab OAuth + GitLab Git Provider:

STUDIO_GITLAB_APPLICATION_ID=xxx
STUDIO_GITLAB_APPLICATION_SECRET=xxx
STUDIO_GITLAB_MODERATORS=email1@example.com,email2@example.com  # Optional

Google OAuth (requires PAT):

STUDIO_GOOGLE_CLIENT_ID=xxx
STUDIO_GOOGLE_CLIENT_SECRET=xxx
STUDIO_GOOGLE_MODERATORS=email1@example.com,email2@example.com  # Required!
STUDIO_GITHUB_TOKEN=xxx  # or STUDIO_GITLAB_TOKEN

Custom Auth (requires PAT):

STUDIO_GITHUB_TOKEN=xxx  # or STUDIO_GITLAB_TOKEN

Authentication vs Git Providers

Important distinction:

  • Auth Providers: Control who can login (GitHub OAuth, GitLab OAuth, Google OAuth, Custom)
  • Git Providers: Control where content is committed (GitHub, GitLab)

You can mix and match: e.g., Google OAuth for auth + GitHub for Git operations (requires PAT).

Development Workflow

Setup

# Install dependencies
pnpm install

# Generate type stubs
pnpm dev:prepare

# Build app and service worker
pnpm prepack

Running Locally

# Terminal 1: Start the Vue app dev server
pnpm dev:app  # Runs on :5151

# Terminal 2: Start the Nuxt playground
pnpm dev      # Runs on :3000, points to :5151 for Studio app

Testing

pnpm verify      # Runs all checks
pnpm test        # Vitest tests
pnpm typecheck   # Type checking
pnpm lint        # ESLint

Key Concepts

File types

User can edit:

  • Markdown files with MDC syntax
  • YAML files
  • JSON files

Markdown with MDC syntax

Vue components in Markdown

Studio leverages Nuxt Content's MDC syntax for embedding Vue components in Markdown with props and slots.

::component-name
---
prop: value
---
#slot-name
Slot content here
::

Frontmatter

Frontmatter is a convention of Markdown-based CMS to provide meta-data to pages, like description or title. In Nuxt Content, the frontmatter uses the YAML syntax with key: value pairs.

---
title: 'Title of the page'
description: 'meta description of the page'
---

<!-- Content of the page in markdown (MDC) format -->

JSON files

{
  "title": "Title of the page",
  "description": "meta description of the page"
}

YAML files

title: 'Title of the page'
description: 'meta description of the page'

Editors

Tiptap editor

  • Can edit Markdown files with MDC syntax
  • TipTap AST is converted to/from ComarkTree (via comarkToTiptap.ts / tiptapToComark.ts) and stored in the SQLite database
  • If we want to display raw markdown of a file, we can use the generateContentFromDocument function to get the raw markdown (ie. preview page or monaco editor)
  • If we want to generate a ComarkTree from raw markdown, we can use the generateDocumentFromMarkdownContent function

Form editor

  • Can edit YAML files
  • Can edit JSON files
  • A vmodel form is generated based on the collection schema
  • Every time the form is updated, the content is converted to pure json to be stored in the database

Code editor

  • Markdown files with MDC syntax
  • YAML files
  • JSON files
  • The code editor is the only one that can edit raw content, this is a debug editor we don't want to improve it contrary to the other editors.

AI Features

Studio integrates AI-powered content assistance using Claude models via Vercel AI Gateway. AI features are optional and require configuration.

Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  studio: {
    ai: {
      apiKey: process.env.AI_GATEWAY_API_KEY,
      context: {
        title: 'My Project',
        description: 'Project description',
        style: 'Technical and concise',
        tone: 'Professional',
        collection: {
          name: 'studio'  // Internal collection name for context files
          folder: '.studio'  // Folder where the context files are stored in the content folder
        }
      },
      experimental: {
        collectionContext: true  // Enable collection-specific AI context (requires studio collection setup)
      }
    }
  }
})

Environment Variable:

AI_GATEWAY_API_KEY=xxx  # Vercel AI Gateway API key

AI Tab (Configuration)

EXPERIMENTAL: This feature requires enabling ai.experimental.collectionContext and defining a studio collection in content.config.ts.

The AI tab (/ai route) is for configuration only - it manages AI context files stored in an internal .studio collection:

  • Purpose: Generate and manage AI writing style guides per collection
  • Context Files: .studio/{collection-name}.md files that contextualize AI for each collection
  • Analysis: Uses Claude Sonnet 4.5 to analyze collection content and generate comprehensive style guides
  • Requirements:
    • Enable ai.experimental.collectionContext: true in nuxt.config.ts
    • Define a studio collection in content.config.ts (see example below)
  • UI:
    • List all collections with status badges (Generated/Not generated)
    • Editor for viewing/editing context files
    • Refresh button to regenerate context from collection
    • "Analyze Collection" button for new context files

Key Behavior:

  • Context files are in the .studio collection (internal, not user-facing)
  • Users access AI tab directly by clicking the AI tab button (only visible when experimental flag is enabled)
  • Selecting files from preview always goes to Content tab (not AI tab)
  • AI tab is independent from user content workflow

Required Collection Setup:

// content.config.ts
export default defineContentConfig({
  collections: {
    studio: defineCollection({
      type: 'page',
      source: {
        include: '.studio/**/*.md',
      },
    }),
  }
})

AI Completion (TipTap Extension)

In-editor AI text completion that auto-suggests continuations while typing:

Features:

  • Auto-triggers 500ms after user stops typing
  • Uses Claude Haiku 4.5 for speed (~300-500ms response)
  • Shows suggestions as gray italic text at cursor
  • Accept with Tab, dismiss with Escape or continue typing
  • Manual trigger with Cmd/Ctrl+J

Configuration:

  • Toggle in footer: sparkles button (only visible when AI enabled)
  • Stored in preferences: enableAICompletion (default: true)
  • Automatically disabled for .studio context files (prevents AI interfering with AI guidelines)

Implementation:

  • Extension: src/app/src/utils/tiptap/extensions/ai-completion.ts
  • Context: Sends 400 characters before cursor + 200 characters after cursor
  • Debounced: Waits 500ms of no typing before requesting
  • Max output: 40 tokens (~1 sentence)

AI Transform Actions

Selection-based content improvements via bubble toolbar:

Modes:

  • Fix: Grammar and spelling correction
  • Improve: Enhanced clarity and engagement
  • Simplify: Simpler words and shorter sentences
  • Translate: Translate to another language (default: French)

Features:

  • Available in TipTap bubble toolbar when text is selected
  • Uses Claude Sonnet 4.5 for quality
  • Shows accept/decline buttons after transformation
  • Max selection: 500 characters
  • Selection auto-trims to exclude structural elements (lists, code blocks, MDC components)
  • Only processes inline content (preserves document structure)

Implementation:

  • Extension: src/app/src/utils/tiptap/extensions/ai-transform.ts
  • Component: src/app/src/components/content/ContentEditorAIValidation.vue
  • Preserves all markdown formatting (bold, italic, links, etc.)

Model Selection Strategy

  • Continue mode: Claude Haiku 4.5 (optimized for speed)
  • Transform modes: Claude Sonnet 4.5 (optimized for quality)
  • Context size: 500 chars for continue, full selection for transforms

Server Routes

/__nuxt_studio/ai/generate   # POST - AI generation endpoint
/__nuxt_studio/ai/analyze    # POST - Collection analysis endpoint

Authentication: Skipped in dev mode, requires session in production.

Context Loading (Future)

Collection-specific context files can be loaded during AI operations:

  • Query .studio/{collection-name}.md from studio collection
  • Include in AI prompt for contextualized responses
  • Currently commented out (lines 94-101 in generate.post.ts)
  • Ready to enable when needed for better AI personalization

Context Building System

Studio builds AI context from multiple sources in a specific order for optimal results:

Context Components (in order of addition):

  1. File Location: Collection name and file path
  2. Project Metadata: Title, description, style, tone from config
  3. Collection Guidelines: .studio/{collection-name}.md context file (16K chars max, ~4K tokens)
    • Only loaded for: improve, continue, simplify modes
    • Skipped for: fix, translate modes (for performance)
  4. Cursor Position Hints: Added LAST for recency bias (most important for continue mode)
  5. Component/Slot Context: When editing MDC component slots

Slot-Specific Guidance:

  • title slots: 3-8 words maximum, concise headings
  • description slots: One sentence, 15-25 words
  • default slots: Substantial content explaining component purpose
  • header/heading slots: 2-6 words, brief labels
  • footer slots: Concluding/supplementary content
  • caption/label slots: 2-8 words, descriptive but concise

Token Calculation Logic

AI responses are dynamically sized based on mode and context (1 token ≈ 4 characters):

Transform Modes:

  • fix: 1.5x original selection length
  • improve: 1.5x original selection length
  • translate: 1.5x original selection length
  • simplify: 0.7x original selection length

Continue Mode (context-aware):

  • paragraph-new: 120-150 tokens (1-2 complete sentences)
    • 150 tokens after headings (expects substantial intro)
  • sentence-new: 90 tokens (one complete sentence)
  • Other contexts: 60 tokens (default completion)

Cursor Position Awareness

Studio detects cursor position to generate contextually appropriate completions:

  • heading-new: Starting a new heading → generates short, concise heading (no full sentences)
  • heading-continue: End of heading → completes the heading
  • heading-middle: Mid-heading with text after → inserts 1-3 connecting words
  • paragraph-new: Starting new paragraph → generates opening sentence
    • Special: After heading → generates intro paragraph related to heading topic
  • sentence-new: Starting new sentence within paragraph → one complete sentence
  • paragraph-middle: Mid-paragraph with text after → 3-8 bridging words only
  • paragraph-continue: Mid-sentence → completes current sentence with punctuation

This ensures AI never generates headings when you're writing paragraphs, or full sentences when you need a few bridging words.

Security & Safety

Prompt Injection Protection:

  • System prompts explicitly instruct AI to treat user text as content, not instructions
  • Prevents selected text from being interpreted as commands
  • Each mode has dedicated system prompt with safety rules

API Key Security:

  • API key stored as environment variable only
  • Never exposed to client-side code
  • All AI requests proxied through server routes

Context Isolation:

  • AI completion automatically disabled when editing .studio context files
  • Prevents AI from interfering with AI guideline documents

Limitations & Best Practices

Known Limitations:

  • Max selection for transforms: 500 characters
  • Collection context capped at 16,000 characters (~4K tokens)
  • Continue mode uses 400 chars before + 200 chars after cursor for context
  • AI may require 1-2 attempts for perfect results

Best Practices:

  • Create collection-specific context files for better AI results
  • Use descriptive project metadata (title, description, style, tone)
  • For long content, transform in smaller selections
  • Use continue for writing, improve for polishing
  • Use fix before improve if content has errors

Important Implementation Details

Draft Update Prevention:

  • Draft updates from AI don't trigger infinite loops
  • useStudio.ts route handler only calls hooks when document is unfocused or not current
  • Prevents: AI update → draft update → hook → tree rebuild → update → hook (loop)

Route Handling:

  • Preview selections always route to Content tab (not AI tab)
  • .studio files are internal config, never shown in preview
  • AI tab accessed only by direct navigation

Performance Optimizations:

  • Haiku for completions: ~300-500ms (vs 2-3s with Sonnet)
  • Reduced context: 600 chars total (400 before + 200 after) for continue mode
  • Skips context file loading for continue mode

Key Files

  • src/module/src/runtime/server/routes/ai/generate.post.ts - AI generation endpoint
  • src/module/src/runtime/server/routes/ai/analyze.post.ts - Collection analysis
  • src/app/src/composables/useAI.ts - AI composable
  • src/app/src/utils/tiptap/extensions/ai-completion.ts - Completion extension
  • src/app/src/utils/tiptap/extensions/ai-transform.ts - Transform extension
  • src/app/src/pages/ai.vue - AI tab interface
  • src/app/src/components/content/ContentEditorTipTap.vue - TipTap integration

Draft system

In production mode:

  • Exisiting db files is stored in SQLite browser side database by Nuxt Content. It's loaded by a dump file.
  • Markdown files are stored as ComarkTree (the array-based AST produced by the comark parser)
  • YAML and JSON files are stored as pure json
  • Drafts files and meta are stored client-side in IndexedDB
  • Drafts files content is merged with the existing db files in the browser before being rendered => app is rerendered with updated content in db => this is the preview you see in the browser

In development mode:

  • There is no draft system, changes are synced with the server filesystem directly

Content states (the same markdown, five representations)

A single piece of content exists in several distinct forms as it flows from disk through Studio. Knowing which one is on each side of a comparison is essential when debugging the "Formatting applied" banner, conflict detection, or any round-trip discrepancy.

# Name What it is Where it lives Who transforms it
A Raw file on disk Plain text exactly as the user authored it Git remote (fetched via GitHub/GitLab API), or local filesystem in dev mode Nobody — just bytes
B Stored ComarkTree (original.body)today The AST @nuxt/content built at SQLite-build time, then bridged to ComarkTree on read Browser SQLite (loaded from the dump), then in-memory under draftItem.original comarkTreeFromLegacyDocumentmdcToComarkpropsMDCToComark
B bis Stored ComarkTree (original.body)future, post-comark-native @nuxt/content The same immutable "as-stored" snapshot — but built directly by comark at @nuxt/content build time, no MDC bridge in between Browser SQLite, then in-memory under draftItem.original (same shape as what was stored) None at runtime — Studio just reads it. The upstream comark.parse ran once at build time and the result was stored as-is. The whole legacy.ts bridge file can be deleted at that point.
C TipTap document JSON Editor-internal representation of the same content TipTap editor state, in memory only comarkToTiptap(original.body) on mount; lives as long as the editor is open
D Edited ComarkTree (modified.body) Result of bouncing state C back through tiptapToComark after every editor onUpdate (which fires even on harmless normalizations like wrapping inline nodes) In-memory draftItem.modified; persisted to IndexedDB as the draft tiptapToComarkcreateElement / createVideoElement / createImageElement / createLinkElement / link-mark branch in createTextElement — all use buildAttrs to preserve incoming attr order, no hardcoded reordering
E Rendered markdown string Any ComarkTree (B or D) serialized back to text via contentFromMarkdownDocument → comark renderMarkdown Computed on demand by host.document.generate.contentFromDocument Pure render; comark preserves insertion order — no Studio-imposed sort

Mapping to UI surfaces:

  • TipTap editor — shows state C rendered visually. v-models on modified, so anything you type updates D.
  • Monaco code editor — shows E(D) (the round-tripped string of the current draft). Edits parse back via documentFromContent (comark.parse) into a fresh D.
  • MDC formatting banner (ContentEditor.vue + ContentEditorDiff) — left pane = A (raw remote file), right pane = E(B) (round-trip of original). Fires when A !== E(B).
  • Review card (ContentCardReview) — for Updated drafts, left = A, right = E(D). For Created/Deleted it falls back to a single-pane monaco of whichever side exists.
  • Conflict editor (ContentEditorConflict) — shown via draft.checkConflict when the remote moved on while a draft existed locally. Left = E(B), right = A (re-fetched).

Where each state can drift from the others:

  • A vs E(B) — drifts when comark applies its canonical syntax rules to a tree (see below). This is expected and triggers the formatting banner, not a conflict.
  • E(B) vs E(D) — drifts when any TipTap serializer rebuilds in a different order from original.body.
  • B vs D structurally — diverge as soon as TipTap mounts and emits its first onUpdate, even with no user edits.

When triaging any new version-mismatch bug, ask: which two states are we comparing, and which transform between them is wrong?

Comark normalization rules (why A ≠ E(B) can be expected)

Comark's render is not a byte-perfect round-trip of the input — it normalizes MDC syntax to a canonical form defined by the comark SPEC.

These are intentional. A file authored loosely (4 inline props, wrong colon depth, …) goes through comark.parse → renderMarkdown and comes out as canonical comark. That difference between A and E(B) is exactly what the MDC formatting banner is for.

Conflict detection vs. formatting drift — the distinction

Two separate checks run when a file is opened, and they answer different questions:

Check Question it answers Compared values Where in the code
Conflict Did the file change on the remote while we had a draft? Raw A at load time vs raw A re-fetched draft.ts:checkConflict
Formatting banner Does the author's text differ from comark's canonical form? Raw A vs E(B) ContentEditor.vue + areContentEqual

The two must not be conflated. Conflict detection must operate on raw text (A vs A) — before any comark parsing — otherwise comark's syntactic normalization gets misread as a remote change and the conflict UI fires when there's no actual remote drift.

Apply formatting — banner UX rules

The ComarkFormattingBanner (shown inside the editor) lets the user explicitly accept comark's canonical form for a Pristine draft without typing anything. The rules:

  • Visible only when the draft is Pristine AND render(B) !== A (formatting drift detected).
  • Hidden once the draft status is Updated — whether the user got there by editing or by clicking the Apply button. Editing already publishes the canonical form on save; there's nothing to "apply" after that point.
  • Revert clears the apply intent (formattingApplied: false) and rolls back to Pristine → the banner reappears if drift still exists.

External helpers

nuxt-component-meta

Studio uses nuxt-component-meta to:

  • Discover available components in the user's project
  • Find props editors for components
  • Find slots for components

comark

Studio uses comark to:

  • Parse MDC/Markdown content into a ComarkTree — a compact array-based AST: [tag, attrs, ...children]
  • Render a ComarkTree back to raw markdown (via renderMarkdown from comark/render)
  • Apply plugins during parsing: emoji, syntax highlighting (shiki themes), and table of contents

Key files:

  • src/module/src/runtime/utils/document/generate.ts — parses markdown files server-side with parse() from comark
  • src/app/src/utils/tiptap/comarkToTiptap.ts / tiptapToComark.ts — convert between ComarkTree and TipTap JSON in the visual editor
  • src/app/src/utils/comark.ts — helpers to traverse ComarkTree nodes (isElement, getTag, getAttrs, getChildren)
  • src/module/src/runtime/utils/document/legacy.ts — backward-compatible conversion layer between the old MarkdownRoot format (produced by @nuxtjs/mdc) and the new ComarkTree, allowing Nuxt Content to continue storing documents in its existing format until it natively supports ComarkTree

shiki

Studio uses shiki to highlight code in code blocks.

Nuxt Content

  • Gives information about the content of the website and the collections.
  • Provides the database adapter to the Studio.
  • Provides the content collections to the Studio.
  • Provides the schema of the collections to the Studio (form generation)
  • Provides the query builder to the Studio.

Server Routes

Development Mode:

  • /__nuxt_studio/dev/content/* - File operations
  • /__nuxt_studio/dev/public/* - Media operations

Production Mode:

  • /__nuxt_studio/auth/* - OAuth callbacks
  • Service routes use Git provider APIs

Important Files

Common Tasks

Adding a New Auth Provider

  1. Add OAuth config to src/module/src/auth.ts
  2. Create server route in src/module/src/runtime/server/routes/auth/
  3. Add provider type to types
  4. Update docs

Adding a New Git Provider

  1. Create provider implementation in src/app/src/utils/providers/
  2. Add provider API client
  3. Update Git provider types
  4. Add configuration options

Adding a Custom Form Input

  1. Define input type in collection schema using .editor({ input: 'custom' })
  2. Extend Zod schema types
  3. Create corresponding form component
  4. Map input type in form generator

Adding a new TipTap component serializer

When adding a new node type to src/app/src/utils/tiptap/tiptapToComark.ts (e.g. a new MDC component the editor handles natively), always use buildAttrs from props.ts to construct the output props. Do NOT rebuild props key-by-key in a hardcoded or any custom order.

Key Dependencies

Required:

  • @nuxt/content - Content layer (peer dependency)
  • comark - MDC parsing/rendering (produces ComarkTree AST)

Core:

  • unstorage - Storage abstraction
  • idb-keyval - IndexedDB for drafts
  • shiki - Syntax highlighting

Editors:

  • modern-monaco - Code editor
  • minimark - Legacy MarkdownRoot format (used only in backward-compat layer)

Git Providers:

  • @octokit/types - GitHub API
  • @gitbeaker/core - GitLab API

Patched dependencies (patches/)

Studio carries a pnpm patch (patches/@nuxtjs__mdc@<version>.patch, registered in pnpm-workspace.yaml under patchedDependencies) that removes rehype-sort-attribute-values and rehype-sort-attributes from @nuxtjs/mdc's default rehype pipeline. Without this patch, every MDC component's attrs would be sorted alphabetically at parse time — silently reshuffling the author's intent and forcing the formatting banner to fire on every file open whose attrs aren't already in alphabetical order.

When bumping the @nuxtjs/mdc version, the patch will likely need to be re-created (pnpm patch @nuxtjs/mdc@<new-version>, drop the same two plugins in dist/runtime/parser/options.js, then pnpm patch-commit <tmp-dir>). The retire path is once @nuxt/content natively supports ComarkTree (see the comment at the top of legacy.ts) — at that point the patch becomes unnecessary along with the whole MDC bridge.

SSR Requirements

Studio requires server-side rendering for authentication routes. While you can pre-render pages with nitro.prerender, the site must be deployed on a platform that supports SSR (not static hosting like GitHub Pages).

Use hybrid rendering to pre-render pages while keeping auth routes server-side:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/'],
      crawlLinks: true
    }
  }
})

Related Documentation

Debugging Tips

  • Enable debug mode in footer menu to see TipTap JSON ↔ MDC AST ↔ Markdown conversion
  • Check browser IndexedDB for draft storage
  • Use Vue DevTools to inspect Studio state
  • Check service worker logs for medias issues
  • Verify OAuth callback URLs match exactly (including protocol)
  • Ensure PAT has correct permissions for repository operations

Assumptions for AI Assistants

  • Nuxt Studio always runs inside a Nuxt app
  • Nuxt Content is required and authoritative for content structure
  • Studio does not replace Nuxt Content querying or rendering
  • All persisted changes ultimately go through Git (for the moment)
  • Temporary changes are stored in IndexedDB and synced with the SQLite db in production mode and sync with filesystem in development mode
  • The Studio UI is a separate Vue x Vite app embedded via the Nuxt module

Gotchas

  • Never mutate nested objects on a DatabaseItem you didn't construct fresh. Several flows share references between top-level fields and body.frontmatter (notably TipTap's { ...comarkTree.frontmatter, body: comarkTree } spread in ContentEditorTipTap.vue). An in-place mutation in a "normalize" step (e.g. seo.title = seo.title || result.title) will back-propagate through the shared reference and silently corrupt the body's frontmatter. Always shallow-clone the nested object first: const seo = { ...(result.seo || {}) }.
  • TipTap emits an initial onUpdate on mount even with no real edits. This means draftItem.modified.body diverges structurally from original.body as soon as the editor opens. Don't compare modified and original by reference equality or deep tree equality — always compare via render output (or use compare.ts:normalizeAttrsDeep for AST-aware equivalence).
  • @nuxtjs/mdc is patched (see patches/). Don't assume upstream parser behavior — the patch disables attribute sorting. If the patch is missing after pnpm install, the formatting banner will fire on most files.