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.
- 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
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)
-
Development Mode (default in dev)
- Direct filesystem access via server routes
- No auth required
- Changes sync immediately to local files
- No Git operations
-
Production Mode (default in prod)
- OAuth authentication required
- Git provider integration for commits
- Changes pushed to repository
- Triggers CI/CD pipeline
- Nuxt 3: Core framework
- Nuxt Content: Content management layer (peer dependency)
- comark: MDC (Markdown Components) parsing and rendering — produces a compact array-based
ComarkTreeAST - 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
// 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
}
}
}
})GitHub OAuth + GitHub Git Provider:
STUDIO_GITHUB_CLIENT_ID=xxx
STUDIO_GITHUB_CLIENT_SECRET=xxx
STUDIO_GITHUB_MODERATORS=email1@example.com,email2@example.com # OptionalGitLab OAuth + GitLab Git Provider:
STUDIO_GITLAB_APPLICATION_ID=xxx
STUDIO_GITLAB_APPLICATION_SECRET=xxx
STUDIO_GITLAB_MODERATORS=email1@example.com,email2@example.com # OptionalGoogle 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_TOKENCustom Auth (requires PAT):
STUDIO_GITHUB_TOKEN=xxx # or STUDIO_GITLAB_TOKENImportant 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).
# Install dependencies
pnpm install
# Generate type stubs
pnpm dev:prepare
# Build app and service worker
pnpm prepack# 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 apppnpm verify # Runs all checks
pnpm test # Vitest tests
pnpm typecheck # Type checking
pnpm lint # ESLintUser can edit:
- Markdown files with MDC syntax
- YAML files
- JSON files
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 -->{
"title": "Title of the page",
"description": "meta description of the page"
}title: 'Title of the page'
description: 'meta description of the page'Tiptap editor
- Can edit Markdown files with MDC syntax
- TipTap AST is converted to/from
ComarkTree(viacomarkToTiptap.ts/tiptapToComark.ts) and stored in the SQLite database - If we want to display raw markdown of a file, we can use the
generateContentFromDocumentfunction to get the raw markdown (ie. preview page or monaco editor) - If we want to generate a
ComarkTreefrom raw markdown, we can use thegenerateDocumentFromMarkdownContentfunction
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.
Studio integrates AI-powered content assistance using Claude models via Vercel AI Gateway. AI features are optional and require 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 keyEXPERIMENTAL: 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}.mdfiles 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: truein nuxt.config.ts - Define a studio collection in content.config.ts (see example below)
- Enable
- 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
.studiocollection (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',
},
}),
}
})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 withEscapeor 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
.studiocontext 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)
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.)
- 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
/__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.
Collection-specific context files can be loaded during AI operations:
- Query
.studio/{collection-name}.mdfrom 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
Studio builds AI context from multiple sources in a specific order for optimal results:
Context Components (in order of addition):
- File Location: Collection name and file path
- Project Metadata: Title, description, style, tone from config
- Collection Guidelines:
.studio/{collection-name}.mdcontext file (16K chars max, ~4K tokens)- Only loaded for:
improve,continue,simplifymodes - Skipped for:
fix,translatemodes (for performance)
- Only loaded for:
- Cursor Position Hints: Added LAST for recency bias (most important for continue mode)
- Component/Slot Context: When editing MDC component slots
Slot-Specific Guidance:
titleslots: 3-8 words maximum, concise headingsdescriptionslots: One sentence, 15-25 wordsdefaultslots: Substantial content explaining component purposeheader/headingslots: 2-6 words, brief labelsfooterslots: Concluding/supplementary contentcaption/labelslots: 2-8 words, descriptive but concise
AI responses are dynamically sized based on mode and context (1 token ≈ 4 characters):
Transform Modes:
fix: 1.5x original selection lengthimprove: 1.5x original selection lengthtranslate: 1.5x original selection lengthsimplify: 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)
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 headingheading-middle: Mid-heading with text after → inserts 1-3 connecting wordsparagraph-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 sentenceparagraph-middle: Mid-paragraph with text after → 3-8 bridging words onlyparagraph-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.
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
.studiocontext files - Prevents AI from interfering with AI guideline documents
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
continuefor writing,improvefor polishing - Use
fixbeforeimproveif content has errors
Draft Update Prevention:
- Draft updates from AI don't trigger infinite loops
useStudio.tsroute 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)
.studiofiles 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
src/module/src/runtime/server/routes/ai/generate.post.ts- AI generation endpointsrc/module/src/runtime/server/routes/ai/analyze.post.ts- Collection analysissrc/app/src/composables/useAI.ts- AI composablesrc/app/src/utils/tiptap/extensions/ai-completion.ts- Completion extensionsrc/app/src/utils/tiptap/extensions/ai-transform.ts- Transform extensionsrc/app/src/pages/ai.vue- AI tab interfacesrc/app/src/components/content/ContentEditorTipTap.vue- TipTap integration
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
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 |
comarkTreeFromLegacyDocument → mdcToComark → propsMDCToComark |
| 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 |
tiptapToComark → createElement / 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 whenA !== E(B). - Review card (ContentCardReview) — for
Updateddrafts, left = A, right = E(D). ForCreated/Deletedit falls back to a single-pane monaco of whichever side exists. - Conflict editor (ContentEditorConflict) — shown via
draft.checkConflictwhen 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'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.
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.
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
PristineANDrender(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 toPristine→ the banner reappears if drift still exists.
Studio uses nuxt-component-meta to:
- Discover available components in the user's project
- Find props editors for components
- Find slots for components
Studio uses comark to:
- Parse MDC/Markdown content into a
ComarkTree— a compact array-based AST:[tag, attrs, ...children] - Render a
ComarkTreeback to raw markdown (viarenderMarkdownfromcomark/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 withparse()from comarksrc/app/src/utils/tiptap/comarkToTiptap.ts/tiptapToComark.ts— convert between ComarkTree and TipTap JSON in the visual editorsrc/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 oldMarkdownRootformat (produced by@nuxtjs/mdc) and the newComarkTree, allowing Nuxt Content to continue storing documents in its existing format until it natively supports ComarkTree
Studio uses shiki to highlight code in code blocks.
- 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.
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
- src/module/src/module.ts - Main module definition
- src/module/src/auth.ts - Auth provider setup
- src/module/src/dev.ts - Dev mode configuration
- src/app/src/main.ts - Vue app entry point
- src/app/src/service-worker.ts - Service worker for media draft system
- src/module/src/runtime/server/routes/ - Server routes
- Add OAuth config to src/module/src/auth.ts
- Create server route in
src/module/src/runtime/server/routes/auth/ - Add provider type to types
- Update docs
- Create provider implementation in
src/app/src/utils/providers/ - Add provider API client
- Update Git provider types
- Add configuration options
- Define input type in collection schema using
.editor({ input: 'custom' }) - Extend Zod schema types
- Create corresponding form component
- Map input type in form generator
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.
Required:
@nuxt/content- Content layer (peer dependency)comark- MDC parsing/rendering (producesComarkTreeAST)
Core:
unstorage- Storage abstractionidb-keyval- IndexedDB for draftsshiki- Syntax highlighting
Editors:
modern-monaco- Code editorminimark- Legacy MarkdownRoot format (used only in backward-compat layer)
Git Providers:
@octokit/types- GitHub API@gitbeaker/core- GitLab API
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.
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
}
}
})- Nuxt Content - Underlying content management
- MDC Syntax - Component syntax in Markdown
- Nuxt Content Collections - Schema-based content
- TipTap Editor - Visual editor framework
- Official Studio Docs - User-facing documentation
- 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
- 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
- Never mutate nested objects on a
DatabaseItemyou didn't construct fresh. Several flows share references between top-level fields andbody.frontmatter(notably TipTap's{ ...comarkTree.frontmatter, body: comarkTree }spread inContentEditorTipTap.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
onUpdateon mount even with no real edits. This meansdraftItem.modified.bodydiverges structurally fromoriginal.bodyas soon as the editor opens. Don't comparemodifiedandoriginalby reference equality or deep tree equality — always compare via render output (or usecompare.ts:normalizeAttrsDeepfor AST-aware equivalence). @nuxtjs/mdcis patched (seepatches/). Don't assume upstream parser behavior — the patch disables attribute sorting. If the patch is missing afterpnpm install, the formatting banner will fire on most files.