Skip to content

Commit a34fdf2

Browse files
committed
fix(document): upgrade legacy bodies to ComarkTree at read boundaries
Older Studio builds persisted legacy (MarkdownRoot/minimark) bodies in IndexedDB; comark's renderMarkdown expects a ComarkTree and threw on publish/render. Upgrade to ComarkTree at the read boundaries (host DB reads + draft load) via `ensureComarkBody` in the legacy compat layer.
1 parent 457ff0c commit a34fdf2

8 files changed

Lines changed: 158 additions & 21 deletions

File tree

src/app/src/composables/useDraftBase.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
3939
return name as 'studio:draft:document:updated' | 'studio:draft:ai:updated' | 'studio:draft:media:updated'
4040
}
4141

42+
// Older Studio builds persisted legacy bodies (MarkdownRoot/minimark) in IndexedDB;
43+
// upgrade on load so the app only sees comark.
44+
function upgradeLegacyBodies(item: DraftItem): DraftItem {
45+
if (type !== 'document') return item
46+
const ensureComarkBody = host.document.utils.ensureComarkBody
47+
return {
48+
...item,
49+
modified: item.modified ? ensureComarkBody(item.modified as DatabaseItem) : item.modified,
50+
original: item.original ? ensureComarkBody(item.original as DatabaseItem) : item.original,
51+
}
52+
}
53+
4254
async function get(fsPath: string): Promise<DraftItem<T> | undefined> {
4355
return list.value.find(item => item.fsPath === fsPath) as DraftItem<T>
4456
}
@@ -221,7 +233,7 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
221233
await storage.removeItem(key)
222234
return null
223235
}
224-
return item
236+
return upgradeLegacyBodies(item)
225237
}))
226238
})
227239

src/app/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export interface StudioHost {
8686
isMatchingContent: (content: string, document: DatabaseItem) => Promise<boolean>
8787
pickReservedKeys: (document: DatabaseItem) => DatabaseItem
8888
cleanDataKeys: (document: DatabaseItem) => DatabaseItem
89+
// Legacy compat — delete when @nuxt/content returns ComarkTree natively.
90+
ensureComarkBody: (document: DatabaseItem) => DatabaseItem
8991
detectActives: () => Array<{ fsPath: string, title: string }>
9092
}
9193
generate: {

src/module/src/runtime/host.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ensure } from './utils/ensure'
33
import type { CollectionInfo, CollectionItemBase, CollectionSource, DatabaseAdapter } from '@nuxt/content'
44
import type { ContentDatabaseAdapter } from '../types/content'
55
import { getCollectionByFilePath, generateIdFromFsPath, generateRecordDeletion, generateRecordInsert, generateFsPathFromId, getCollectionById } from './utils/collection'
6-
import { applyCollectionSchema, isDocumentMatchingContent, documentFromContent, contentFromDocument, areDocumentsEqual, pickReservedKeysFromDocument, cleanDataKeys, sanitizeDocumentTree, comarkTreeFromLegacyDocument, markdownRootFromComarkTree, isComarkTree } from './utils/document'
6+
import { applyCollectionSchema, isDocumentMatchingContent, documentFromContent, contentFromDocument, areDocumentsEqual, pickReservedKeysFromDocument, cleanDataKeys, sanitizeDocumentTree, markdownRootFromComarkTree, isComarkTree, ensureComarkBody } from './utils/document'
77
import { getHostStyles, getSidebarWidth, adjustFixedElements } from './utils/sidebar'
88
import type { StudioHost, StudioUser, DatabaseItem, MediaItem, Repository } from 'nuxt-studio/app'
99
import type { RouteLocationNormalized, Router } from 'vue-router'
@@ -19,14 +19,6 @@ import { kebabCase } from 'scule'
1919

2020
const serviceWorkerVersion = 'v0.0.5'
2121

22-
function toComarkBody(document: DatabaseItem): DatabaseItem {
23-
if (document.extension !== 'md' || !document.body) return document
24-
if (isComarkTree(document.body)) return document
25-
const comarkTree = comarkTreeFromLegacyDocument(document)
26-
if (!comarkTree) return document
27-
return { ...document, body: comarkTree as unknown }
28-
}
29-
3022
function getLocalColorMode(): 'light' | 'dark' {
3123
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
3224
}
@@ -214,7 +206,7 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
214206
return undefined
215207
}
216208

217-
return toComarkBody(sanitizeDocumentTree({ ...item, fsPath }, collectionInfo))
209+
return ensureComarkBody(sanitizeDocumentTree({ ...item, fsPath }, collectionInfo))
218210
},
219211
list: async (): Promise<DatabaseItem[]> => {
220212
const collections = Object.values(useContentCollections()).filter(collection => collection.name !== 'info')
@@ -225,7 +217,7 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
225217
const source = getCollectionSourceById(document.id, collection.source)
226218
const fsPath = generateFsPathFromId(document.id, source!)
227219

228-
return toComarkBody(sanitizeDocumentTree({ ...document, fsPath }, collection))
220+
return ensureComarkBody(sanitizeDocumentTree({ ...document, fsPath }, collection))
229221
})
230222
}))
231223

@@ -249,7 +241,7 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
249241

250242
await host.document.db.upsert(fsPath, normalizedDocument)
251243

252-
return toComarkBody(sanitizeDocumentTree({ ...normalizedDocument, fsPath }, collectionInfo))
244+
return ensureComarkBody(sanitizeDocumentTree({ ...normalizedDocument, fsPath }, collectionInfo))
253245
},
254246
upsert: async (fsPath: string, document: CollectionItemBase) => {
255247
const collectionInfo = getCollectionByFilePath(fsPath, useContentCollections())
@@ -286,6 +278,7 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
286278
isMatchingContent: async (content: string, document: DatabaseItem) => isDocumentMatchingContent(content, document),
287279
pickReservedKeys: (document: DatabaseItem) => pickReservedKeysFromDocument(document),
288280
cleanDataKeys: (document: DatabaseItem) => cleanDataKeys(document),
281+
ensureComarkBody: (document: DatabaseItem) => ensureComarkBody(document),
289282
detectActives: () => {
290283
// TODO: introduce a new convention to detect data contents [data-content-id!]
291284
const wrappers = document.querySelectorAll('[data-content-id]')

src/module/src/runtime/utils/document/compare.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { doObjectsMatch } from '../object'
55
import { renderMarkdown } from 'comark/render'
66
import { documentFromContent } from './generate'
77
import { cleanDataKeys } from './schema'
8+
import { comarkTreeFromLegacyDocument } from './legacy'
9+
10+
const EMPTY_TREE: ComarkTree = { nodes: [], frontmatter: {}, meta: {} }
11+
12+
// Legacy bodies (MarkdownRoot/minimark, no `.nodes`) make renderMarkdown throw unless upgraded.
13+
function comarkBody(document: Record<string, unknown>): ComarkTree {
14+
return comarkTreeFromLegacyDocument(document as DatabaseItem) ?? EMPTY_TREE
15+
}
816

917
/**
1018
* Sort and normalize every element's attributes alphabetically.
@@ -33,7 +41,7 @@ export async function isDocumentMatchingContent(content: string, document: Datab
3341
if (generatedDocument.extension === ContentFileExtension.Markdown) {
3442
// Compare body nodes only (not frontmatter — that's compared separately via doObjectsMatch below).
3543
const generatedNormalized = normalizeAttrsDeep({ ...(generatedDocument.body as ComarkTree), frontmatter: {} })
36-
const documentNormalized = normalizeAttrsDeep({ ...(document.body as ComarkTree), frontmatter: {} })
44+
const documentNormalized = normalizeAttrsDeep({ ...comarkBody(document), frontmatter: {} })
3745
const generatedBodyStringified = (await renderMarkdown(generatedNormalized)).replace(/\n/g, '')
3846
const documentBodyStringified = (await renderMarkdown(documentNormalized)).replace(/\n/g, '')
3947
if (generatedBodyStringified !== documentBodyStringified) {
@@ -56,7 +64,7 @@ export async function areDocumentsEqual(document1: Record<string, unknown>, docu
5664

5765
// Compare body first
5866
if (document1.extension === ContentFileExtension.Markdown) {
59-
if (await renderMarkdown(body1 as ComarkTree) !== await renderMarkdown(body2 as ComarkTree)) {
67+
if (await renderMarkdown(comarkBody(document1)) !== await renderMarkdown(comarkBody(document2))) {
6068
return false
6169
}
6270
}

src/module/src/runtime/utils/document/generate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import yaml from 'js-yaml'
1212
import { useHostMeta } from '../../composables/useMeta'
1313
import { addPageTypeFields, generateStemFromId, getFileExtension } from './utils'
1414
import { cleanDataKeys } from './schema'
15-
import { unbindComarkTree } from './legacy'
15+
import { comarkTreeFromLegacyDocument, unbindComarkTree } from './legacy'
1616

1717
const logger = consola.withTag('Nuxt Studio')
1818

@@ -157,7 +157,9 @@ export async function contentFromJSONDocument(document: DatabaseItem): Promise<s
157157
}
158158

159159
export async function contentFromMarkdownDocument(document: DatabaseItem): Promise<string | null> {
160-
const body = unbindComarkTree(document.body as unknown as ComarkTree)
160+
const tree = comarkTreeFromLegacyDocument(document)
161+
if (!tree) return '\n'
162+
const body = unbindComarkTree(tree)
161163
const markdown = await renderMarkdown(body, {
162164
blockAttributesStyle: 'frontmatter',
163165
components: {

src/module/src/runtime/utils/document/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727

2828
// Legacy compatibility — delete this section when @nuxt/content natively returns ComarkTree bodies
2929
export {
30+
ensureComarkBody,
3031
comarkTreeFromLegacyDocument,
3132
markdownRootFromComarkTree,
3233
} from './legacy'

src/module/src/runtime/utils/document/legacy.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
* When @nuxt/content releases native ComarkTree body support:
88
* 1. Delete this file
99
* 2. Fix TypeScript errors at call sites:
10-
* - host.ts → remove toComarkBody helper + all db.get/list/create calls to it,
10+
* - host.ts → remove the ensureComarkBody import + all db.get/list/create calls to it,
1111
* remove markdownRootFromComarkTree usage in db.upsert
12-
* - compare.ts → update toMarkdownRoot helper to compare ComarkTrees directly
13-
* - index.ts → remove re-exports of comarkTreeFromLegacyDocument and markdownRootFromComarkTree
12+
* - compare.ts → drop the comarkBody helper and compare ComarkTrees directly
1413
* - generate.ts → remove unbindComarkTree usage in contentFromMarkdownDocument
14+
* - index.ts → remove re-exports of ensureComarkBody, comarkTreeFromLegacyDocument and markdownRootFromComarkTree
15+
* - useDraftBase.ts → remove upgradeLegacyBodies + its host.document.utils.ensureComarkBody call
1516
*/
1617

1718
import type { MarkdownRoot } from '@nuxt/content'
@@ -486,6 +487,15 @@ export function comarkTreeFromLegacyDocument(document: DatabaseItem): ComarkTree
486487
return mdcToComark(body, cleanDataKeys(document) as Record<string, unknown>)
487488
}
488489

490+
// Legacy body (MarkdownRoot/minimark) → ComarkTree, applied at read boundaries so consumers only see comark.
491+
export function ensureComarkBody(document: DatabaseItem): DatabaseItem {
492+
if (document.extension !== 'md' || !document.body) return document
493+
if (isComarkTree(document.body)) return document
494+
const comarkTree = comarkTreeFromLegacyDocument(document)
495+
if (!comarkTree) return document
496+
return { ...document, body: comarkTree as unknown }
497+
}
498+
489499
/**
490500
* Convert a ComarkTree body back to the legacy compressed MarkdownRoot format for DB storage.
491501
* Used at the DB write boundary (db.upsert) to store documents in the current @nuxt/content format.

src/module/test/integration/document.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { describe, it, expect } from 'vitest'
2-
import { contentFromMarkdownDocument, documentFromMarkdownContent } from '../../src/runtime/utils/document'
2+
import { areDocumentsEqual, contentFromMarkdownDocument, documentFromMarkdownContent, ensureComarkBody, isDocumentMatchingContent } from '../../src/runtime/utils/document'
3+
import { markdownRootFromComarkTree } from '../../src/runtime/utils/document/legacy'
4+
import type { ComarkTree } from 'comark'
5+
import type { DatabaseItem } from 'nuxt-studio/app'
6+
7+
// Downgrades a comark document to the minimark body older Studio builds persisted.
8+
async function legacyBodyOf(id: string, content: string): Promise<{ comark: DatabaseItem, legacy: DatabaseItem }> {
9+
const comark = await documentFromMarkdownContent(id, content) as DatabaseItem
10+
const legacy = { ...comark, body: markdownRootFromComarkTree(comark.body as unknown as ComarkTree) as unknown } as DatabaseItem
11+
return { comark, legacy }
12+
}
313

414
describe('Document - Markdown roundtrip Integration Tests', () => {
515
describe('code block with component inside named slot', () => {
@@ -38,4 +48,103 @@ hello
3848
expect(markdown!.trim()).toBe(content.trim())
3949
})
4050
})
51+
52+
describe('non-comark stored bodies (drafts persisted by older Studio builds)', () => {
53+
const content = `# Hello
54+
55+
A paragraph with **bold** text.
56+
`
57+
58+
it('renders a legacy minimark body (no nodes) without throwing', async () => {
59+
const { legacy } = await legacyBodyOf('content:legacy.md', content)
60+
61+
const markdown = await contentFromMarkdownDocument(legacy)
62+
63+
expect(markdown!.trim()).toBe(content.trim())
64+
})
65+
66+
it('renders a raw MarkdownRoot body ({type:"root", children}, no nodes) without throwing', async () => {
67+
const markdownRootBody = {
68+
type: 'root',
69+
children: [
70+
{ type: 'element', tag: 'h1', props: {}, children: [{ type: 'text', value: 'Hello' }] },
71+
{ type: 'element', tag: 'p', props: {}, children: [
72+
{ type: 'text', value: 'A paragraph with ' },
73+
{ type: 'element', tag: 'strong', props: {}, children: [{ type: 'text', value: 'bold' }] },
74+
{ type: 'text', value: ' text.' },
75+
] },
76+
],
77+
}
78+
const document = { id: 'content:legacy.md', extension: 'md', stem: 'legacy', meta: {}, body: markdownRootBody } as unknown as DatabaseItem
79+
80+
const markdown = await contentFromMarkdownDocument(document)
81+
82+
expect(markdown!.trim()).toBe(content.trim())
83+
})
84+
85+
it('renders a legacy body containing an MDC component without throwing', async () => {
86+
const componentContent = '::alert{type="info"}\nhello\n::\n'
87+
const { legacy } = await legacyBodyOf('content:legacy.md', componentContent)
88+
89+
const markdown = await contentFromMarkdownDocument(legacy)
90+
91+
expect(markdown!.trim()).toBe(componentContent.trim())
92+
})
93+
94+
it('returns an empty document for a markdown body that is missing entirely', async () => {
95+
const document = { id: 'content:empty.md', extension: 'md', stem: 'empty', meta: {} } as DatabaseItem
96+
97+
const markdown = await contentFromMarkdownDocument(document)
98+
99+
expect(markdown).toBe('\n')
100+
})
101+
102+
it('treats a legacy body and its comark equivalent as equal', async () => {
103+
const { comark, legacy } = await legacyBodyOf('content:page.md', content)
104+
105+
expect(await areDocumentsEqual(legacy, comark)).toBe(true)
106+
})
107+
108+
it('matches a legacy body against its own raw markdown content', async () => {
109+
const { legacy } = await legacyBodyOf('content:legacy.md', content)
110+
111+
expect(await isDocumentMatchingContent(content, legacy)).toBe(true)
112+
})
113+
})
114+
115+
describe('ensureComarkBody', () => {
116+
it('upgrades a legacy body to a ComarkTree (adds .nodes)', async () => {
117+
const { legacy } = await legacyBodyOf('content:legacy.md', '# Hello\n')
118+
119+
const upgraded = ensureComarkBody(legacy)
120+
121+
expect(Array.isArray((upgraded.body as ComarkTree).nodes)).toBe(true)
122+
})
123+
124+
it('returns an already-comark document untouched (no re-parse)', async () => {
125+
const comark = await documentFromMarkdownContent('content:comark.md', '# Hello\n') as DatabaseItem
126+
127+
expect(ensureComarkBody(comark)).toBe(comark)
128+
})
129+
130+
it('returns a non-markdown document untouched', () => {
131+
const yamlDoc = { id: 'content:data.yml', extension: 'yml', body: { some: 'value' } } as unknown as DatabaseItem
132+
133+
expect(ensureComarkBody(yamlDoc)).toBe(yamlDoc)
134+
})
135+
136+
it('returns a bodyless document untouched', () => {
137+
const document = { id: 'content:empty.md', extension: 'md' } as DatabaseItem
138+
139+
expect(ensureComarkBody(document)).toBe(document)
140+
})
141+
142+
it('is idempotent', async () => {
143+
const { legacy } = await legacyBodyOf('content:legacy.md', '# Hello\n')
144+
145+
const once = ensureComarkBody(legacy)
146+
147+
expect(ensureComarkBody(once)).toBe(once)
148+
})
149+
})
41150
})

0 commit comments

Comments
 (0)