Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@
"test:types": "vue-tsc --noEmit && cd playground/docus && vue-tsc --noEmit"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.71",
"@nuxtjs/mdc": "https://pkg.pr.new/@nuxtjs/mdc@bf09212",
"@iconify-json/lucide": "^1.2.72",
"@nuxtjs/mdc": "v0.18.1",
"@vueuse/core": "^13.9.0",
"defu": "^6.1.4",
"destr": "^2.0.5",
"unstorage": "^1.17.1"
"unstorage": "1.17.1"
},
"devDependencies": {
"@iconify-json/simple-icons": "^1.2.56",
"@iconify-json/simple-icons": "^1.2.57",
"@nuxt/content": "^3.8.0",
"@nuxt/eslint-config": "^1.9.0",
"@nuxt/eslint-config": "^1.10.0",
"@nuxt/kit": "^4.2.0",
"@nuxt/module-builder": "^1.0.2",
"@nuxt/ui": "^4.1.0",
Expand All @@ -63,23 +63,23 @@
"@unhead/vue": "^2.0.19",
"@unpic/vue": "^1.0.0",
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"idb-keyval": "^6.2.2",
"minimark": "^0.2.0",
"modern-monaco": "^0.2.2",
"nuxt-studio": "workspace:*",
"ofetch": "^1.4.1",
"ofetch": "^1.5.1",
"tailwindcss": "^4.1.16",
"vite": "^7.1.12",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-libcss": "^1.1.2",
"vitest": "^3.2.4",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"vue-tsc": "^3.1.2",
"vue-tsc": "^3.1.3",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.19.0",
"packageManager": "pnpm@10.20.0",
"keywords": [
"nuxt",
"content",
Expand Down
2 changes: 1 addition & 1 deletion playground/docus/content/1.getting-started/5.studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Simply type `/` anywhere in the editor to access all Studio features.

One of Studio's standout features is its ability to integrate and customize any complex component directly within the editor.

In other terms, all [Nuxt UI components](/essentials/components) are usable and can be integrated directly from the editor. An editor can also tweak the component properties, slots and styles.
In other terms, all [Nuxt UI components](/essentials/nested/components) are usable and can be integrated directly from the editor. An editor can also tweak the component properties, slots and styles.

::prose-note
You can also create custom components and let the user integrate them from the visual editor.
Expand Down
521 changes: 321 additions & 200 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/app/src/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async function generateDocumentFromYAMLContent(id: string, content: string): Pro

return {
id,
extension: ContentFileExtension.YAML,
extension: getFileExtension(id),
stem: generateStemFromId(id),
meta: {},
...parsed,
Expand Down Expand Up @@ -187,6 +187,7 @@ export async function generateContentFromYAMLDocument(document: DatabaseItem): P
return await stringifyFrontMatter(removeReservedKeysFromDocument(document), '', {
prefix: '',
suffix: '',
lineWidth: 0,
})
}

Expand Down
15 changes: 10 additions & 5 deletions src/app/src/utils/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { MDCRoot } from '@nuxtjs/mdc'
import type { MarkdownRoot } from '@nuxt/content'
import { isDeepEqual } from './object'

export function isEqual(document1: DatabasePageItem, document2: DatabasePageItem) {
export function isEqual(document1: Record<string, unknown>, document2: Record<string, unknown>) {
function withoutLastStyles(body: MarkdownRoot) {
if (body.value[body.value.length - 1]?.[0] === 'style') {
return { ...body, value: body.value.slice(0, -1) }
Expand All @@ -19,25 +19,30 @@ export function isEqual(document1: DatabasePageItem, document2: DatabasePageItem
// Compare body first
if (document1.extension === ContentFileExtension.Markdown) {
const minifiedBody1 = withoutLastStyles(
document1.body.type === 'minimark' ? document1.body : compressTree(document1.body as unknown as MDCRoot),
(document1 as DatabasePageItem).body.type === 'minimark' ? document1.body as MarkdownRoot : compressTree(document1.body as unknown as MDCRoot),
)
const minifiedBody2 = withoutLastStyles(
document2.body.type === 'minimark' ? document2.body : compressTree(document2.body as unknown as MDCRoot),
(document2 as DatabasePageItem).body.type === 'minimark' ? document2.body as MarkdownRoot : compressTree(document2.body as unknown as MDCRoot),
)

if (stringify(minifiedBody1) !== stringify(minifiedBody2)) {
return false
}
}
else if (typeof body1 === 'object' && typeof body2 === 'object') {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
else if (typeof body1 === 'object' && typeof body2 === 'object') {
else if (body1 !== null && body2 !== null && typeof body1 === 'object' && typeof body2 === 'object') {

The code will crash if document body is null due to how JavaScript's typeof operator treats null as 'object', combined with isDeepEqual not handling null values properly.

View Details

Analysis

TypeError in isEqual() when comparing documents with null body

What fails: isEqual() function in src/app/src/utils/database.ts crashes when comparing documents with null body values due to typeof null === 'object' causing incorrect branch selection

How to reproduce:

// Create documents with null body
const doc1 = { id: 'test', extension: 'json', body: null, meta: {} };
const doc2 = { id: 'test', extension: 'json', body: null, meta: {} };
isEqual(doc1, doc2); // Crashes

Result: TypeError: Cannot convert undefined or null to object at Object.keys(null) call in isDeepEqual() function

Expected: Should handle null body values without crashing, falling back to JSON.stringify comparison

Root cause: Line 32 condition typeof body1 === 'object' && typeof body2 === 'object' evaluates to true for null values due to JavaScript's typeof null === 'object' behavior, incorrectly routing null values to isDeepEqual() which calls Object.keys(null)

if (!isDeepEqual(body1 as Record<string, unknown>, body2 as Record<string, unknown>)) {
return false
}
}
else {
// For other file types, we compare the JSON stringified bodies
if (JSON.stringify(body1) !== JSON.stringify(body2)) {
return false
}
}

const data1 = refineDocumentData({ ...documentData1, ...meta1 })
const data2 = refineDocumentData({ ...documentData2, ...meta2 })
const data1 = refineDocumentData({ ...documentData1, ...(meta1 || {}) })
const data2 = refineDocumentData({ ...documentData2, ...(meta2 || {}) })
if (!isDeepEqual(data1, data2)) {
return false
}
Expand Down
5 changes: 5 additions & 0 deletions src/app/src/utils/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export function getDraftStatus(modified?: BaseItem, original?: BaseItem): DraftS
return DraftStatus.Updated
}
}
else if (typeof original === 'object' && typeof modified === 'object') {
if (!isEqual(original as unknown as Record<string, unknown>, modified as unknown as Record<string, unknown>)) {
return DraftStatus.Updated
}
}
else {
if (JSON.stringify(original) !== JSON.stringify(modified)) {
return DraftStatus.Updated
Expand Down
90 changes: 90 additions & 0 deletions src/app/test/unit/utils/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,94 @@ describe('isEqual', () => {

expect(isEqual(document1, document2)).toBe(false)
})

it('should return true for two identical yaml document with different order of keys', () => {
const document1: DatabasePageItem = {
extension: ContentFileExtension.YAML,
description: 'A test document',
title: 'Test Document',
path: '/index',
id: 'content:index.yml',
tags: ['tag1', 'tag2'],
}

const document2: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: 'A test document',
extension: ContentFileExtension.YAML,
tags: ['tag1', 'tag2'],
}

expect(isEqual(document1, document2)).toBe(true)
})

it('should return true if one document has extra key with null/undefined value', () => {
const document1: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: 'A test document',
}
const document2: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: 'A test document',
extra: null,
}
expect(isEqual(document1, document2)).toBe(true)
})

it('should ignore null/undefiend values', () => {
const document1: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: null,
}

const document2: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: undefined,
}

expect(isEqual(document1, document2)).toBe(true)
})

it('should return false if one of documents missing a key', () => {
const document1: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
description: 'A test document',
}
const document2: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
}

expect(isEqual(document1, document2)).toBe(false)
})

it('should return false if array values are different', () => {
const document1: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
title: 'Test Document',
tags: ['tag1', 'tag2'],
}
const document2: DatabasePageItem = {
id: 'content:index.yml',
path: '/index',
tags: ['tag1', 'tag3'],
title: 'Test Document',
}

expect(isEqual(document1, document2)).toBe(false)
})
})
Loading