skills/migrate-mdc-to-comark/SKILL.md
--- name: migrate-mdc-to-comark description: Port a legacy MDC-based PR onto the new comark-based `main`. Use when a contributor's PR was opened against the old `@nuxtjs/mdc` API (any commit before `feat(mdc): comark migration #355`) and now needs to be rebased / merged with the current Nuxt Studio main branch, which uses `comark` everywhere. allowed-tools: Read, Write, Edit, Glob, Grep, Bash --- # Migrating a Legacy MDC PR to Comark Since commit `5464103e feat(mdc): comark migration (#355)` (
npx skillsauth add nuxt-content/nuxt-studio skills/migrate-mdc-to-comarkInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Since commit 5464103e feat(mdc): comark migration (#355) (merged 2026-05-13), Nuxt Studio's main branch no longer uses @nuxtjs/mdc. The entire conversion layer, document utilities, and TipTap bridges were rewritten on top of comark.
Any PR opened against main before this commit will conflict on merge. Do not simply resolve the conflicts mechanically — most legacy API calls have semantic equivalents in comark that must be re-implemented.
This skill is the reverse of the previous "merge-main-on-comark" skill: the comark branch is now main, and legacy PRs need to be ported forward.
Run these checks at the root of the PR branch:
# Legacy imports — any match means the PR predates the comark migration
grep -rE "from '@nuxtjs/mdc'|from 'remark-mdc'|from 'unist-util-visit'|from '@nuxt/content/runtime'" src/
# Legacy function names
grep -rE "generateDocumentFromContent|generateDocumentFromMarkdownContent|generateDocumentFromYAMLContent|generateDocumentFromJSONContent|generateContentFromDocument|generateContentFromMarkdownDocument|generateContentFromYAMLDocument|generateContentFromJSONDocument|mdcToTiptap|tiptapToMDC|compressTree|decompressTree|parseMarkdown|stringifyMarkdown|parseFrontMatter|stringifyFrontMatter|remarkEmojiPlugin" src/
# Legacy file references (these files were deleted on main)
ls src/app/src/utils/tiptap/mdcToTiptap.ts src/app/src/utils/tiptap/tiptapToMdc.ts 2>/dev/null
If anything matches, the PR is legacy.
| Legacy (pre-#355) | Comark (current main) |
|---|---|
| MDCRoot, MarkdownRoot from @nuxt/content | ComarkTree from comark |
| MDCNode, MDCElement, MDCText, MDCComment from @nuxtjs/mdc | ComarkNode, ComarkElement, ComarkComment from comark |
| MDC element: { type: 'element', tag: 'note', props: {…}, children: [...] } | Tuple: ['note', attrs, ...children] |
| MDC text: { type: 'text', value: 'hello' } | Plain string: 'hello' |
| MDC comment: { type: 'comment', value: 'txt' } | Tuple: [null, {}, 'txt'] |
Located in src/module/src/runtime/utils/document/generate.ts:
| Legacy | Comark |
|---|---|
| generateDocumentFromContent(id, content, opts) | documentFromContent(id, content, opts) |
| generateDocumentFromMarkdownContent(...) | documentFromMarkdownContent(...) |
| generateDocumentFromYAMLContent(...) | documentFromYAMLContent(...) |
| generateDocumentFromJSONContent(...) | documentFromJSONContent(...) |
| generateContentFromDocument(doc) | contentFromDocument(doc) |
| generateContentFromMarkdownDocument(doc) | contentFromMarkdownDocument(doc) |
| generateContentFromYAMLDocument(doc) | contentFromYAMLDocument(doc) |
| generateContentFromJSONDocument(doc) | contentFromJSONDocument(doc) |
The shape generate-X-from-Y was reversed to Y-from-X for symmetry.
Located in src/app/src/utils/tiptap/:
| Legacy file (deleted on main) | Comark replacement |
|---|---|
| mdcToTiptap.ts | comarkToTiptap.ts |
| tiptapToMdc.ts | tiptapToComark.ts |
| mdcToTiptap(body, frontmatterData, opts) | comarkToTiptap(tree, opts) — options collapsed to a single object; frontmatter is part of the tree |
| tiptapToMDC(json, opts) → { body, data } | tiptapToComark(json, opts) → ComarkTree — returns one tree with frontmatter embedded |
| Legacy | Comark |
|---|---|
| import { parseMarkdown } from '@nuxtjs/mdc/runtime/parser/index' | import { parse } from 'comark' |
| import { stringifyMarkdown } from '@nuxtjs/mdc/runtime' | import { renderMarkdown } from 'comark/render' |
| parseFrontMatter / stringifyFrontMatter from remark-mdc | js-yaml (yaml.load / yaml.dump) for pure YAML/JSON files; for Markdown, frontmatter is now embedded in ComarkTree.frontmatter |
| compressTree / decompressTree from @nuxt/content/runtime | No longer used in app code — see §4 "Legacy bridge" |
| visit from unist-util-visit | Walk the tuple tree manually using the helpers from src/app/src/utils/comark.ts |
// Legacy
parseMarkdown(content, {
contentHeading: …,
highlight: { theme },
remark: {
plugins: {
'emoji': { instance: remarkEmojiPlugin },
'remark-mdc': { options: { autoUnwrap: true } },
},
},
})
// Comark
import { parse } from 'comark'
import comarkEmoji from 'comark/plugins/emoji'
import highlight from 'comark/plugins/highlight'
import tocPlugin from 'comark/plugins/toc'
parse(content, {
autoClose: false,
autoUnwrap: true,
plugins: [
comarkEmoji(),
highlight({ themes: { default, dark, light } }),
tocPlugin({ depth: 2, searchDepth: 2, title: '', links: [] }),
],
})
For rendering back to markdown:
// Legacy
const markdown = await stringifyMarkdown(body, data, {
frontMatter: { options: { lineWidth: 0 } },
plugins: { remarkMDC: { options: { autoUnwrap: true } } },
})
// Comark
const markdown = await renderMarkdown(tree, {
blockAttributesStyle: 'frontmatter',
components: { br: () => ':br' },
})
package.json)Removed from runtime deps: @nuxtjs/mdc, remark-mdc (moved to devDeps), unist-util-visit.
Added: comark (pulled from pkg.pr.new while it stabilises). js-yaml is now used directly for YAML.
minimark is also a devDep only — do not import it in runtime code.
If the PR adds any of the removed runtime deps, drop them; if it uses one, swap to the comark equivalent.
When walking or building a ComarkTree, use the helpers in src/app/src/utils/comark.ts:
import { isElement, isComment, getTag, getAttrs, getChildren } from '../../utils/comark'
// Element check
if (isElement(node)) {
const tag = getTag(node) // 'note', 'a', 'paragraph', …
const attrs = getAttrs(node) // Record<string, unknown>
const children = getChildren(node)
}
// Comment check
if (isComment(node)) {
// node === [null, {}, 'comment text']
}
// Text is just a string — no helper needed
if (typeof node === 'string') { … }
Do not reimplement these checks with raw array indexing in PR code; reuse the helpers.
Common visit-style replacement:
// Legacy
visit(document.body, (node) => node.type === 'element' && node.tag === 'a', (node) => {
Reflect.deleteProperty(node.props, 'rel')
})
// Comark — manual recursion
function walk(node: ComarkNode) {
if (isElement(node)) {
if (getTag(node) === 'a') delete (node[1] as Record<string, unknown>).rel
for (const child of getChildren(node)) walk(child)
}
}
tree.nodes.forEach(walk)
The DB layer in @nuxt/content still stores MarkdownRoot (compressed minimark). Studio bridges this at the DB boundaries with two helpers in src/module/src/runtime/utils/document/legacy.ts:
comarkTreeFromLegacyDocument(document) — used in host.ts at db.get, db.list, db.create to upgrade legacy bodies on read.markdownRootFromComarkTree(tree) — used in host.ts at db.upsert to downgrade back for storage.If the PR touches host.ts or DB-adjacent code, the body type at that boundary may still be MarkdownRoot. Use isComarkTree(body) (exported from document/index.ts) to branch:
const body = document.body
const stored = isComarkTree(body)
? markdownRootFromComarkTree(body)
: body // already MarkdownRoot
This bridge is temporary. The header comment of legacy.ts lists the cleanup steps for when @nuxt/content ships native ComarkTree storage — do not extend it.
ContentEditorTipTap.vue now flows through a single tree. Compare:
// Legacy: two separate values (body + data) and manual compression / toc
const frontmatterJson = cleanDataKeys(document.value!)
const newTiptapJSON = mdcToTiptap(
document.value?.body as unknown as MDCRoot,
frontmatterJson,
{ hasNuxtUI: hasNuxtUI.value },
)
const { body, data } = await tiptapToMDC(cleanedTiptap, { highlightTheme })
const compressedBody = compressTree(body)
const toc = generateToc(body, { searchDepth: 2, depth: 2 } as Toc)
const updatedDocument = {
...document.value!,
...data,
body: { ...compressedBody, toc },
}
// Comark: one tree carries frontmatter + nodes + toc
const comarkTree = document.value!.body
if (!comarkTree) return
const newTiptapJSON = comarkToTiptap(comarkTree, { hasNuxtUI: hasNuxtUI.value })
const comarkTree = await tiptapToComark(cleanedTiptap, { highlightTheme })
const updatedDocument = {
...document.value!,
...comarkTree.frontmatter,
body: comarkTree,
}
Key points:
ComarkTree. There is no separate data return value.compressTree, no manual generateToc — the tree carries everything.host.document.utils.areEqual and host.document.generate.contentFromDocument are now async (the comark renderer is async).Files deleted on main but modified in the PR. The most common are:
src/app/src/utils/tiptap/mdcToTiptap.tssrc/app/src/utils/tiptap/tiptapToMdc.tssrc/app/test/unit/utils/tiptap/mdcToTiptap.test.tsResolution: accept the deletion (git rm <file>). Port the PR's intent into the comark equivalents (comarkToTiptap.ts, tiptapToComark.ts, comarkToTiptap.test.ts).
Common files: ContentEditorTipTap.vue, ContentEditorTipTapDebug.vue, useStudio.ts, useDraftBase.ts, useDraftDocuments.ts, host.ts, compare.ts, generate.ts, index.ts (document utils), actions.test.ts, tiptap.test.ts.
Resolution: keep HEAD (main / comark) as the base. Cherry-pick the semantic intent of incoming changes — translate any legacy MDC API calls to comark equivalents using the mapping in §2.
comarkToTiptap.ts / tiptapToComark.ts (option threading, adding new node types, helper extraction, JSDoc style).@nuxtjs/mdc to comark in arbitrary Nuxt projects (this skill is the Studio-specific PR-port flavour).ComarkTree.git rm the deleted files; reimplement intent in comarkToTiptap.ts / tiptapToComark.tsparseMarkdown / stringifyMarkdown with parse / renderMarkdown and update the plugin shapevisit(...) walks with manual tuple walks using isElement / getChildren / getAttrscompressTree / generateToc / decompressTree calls in app code — ComarkTree carries this natively...comarkTree.frontmatterareEqual and contentFromDocument as async at every call site@nuxtjs/mdc, remark-mdc (runtime), unist-util-visit, minimark (runtime) from added depsisComarkTree + comarkTreeFromLegacyDocument / markdownRootFromComarkTreepnpm typecheck && pnpm lint && pnpm test to verifydevelopment
Converts hardcoded Vue components in a Nuxt Content markdown file into slot-based, Studio-editable MDC components. Use when a user wants their Nuxt Content page to be visually editable in Nuxt Studio's TipTap editor.
tools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------