Skip to content
Open
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
2 changes: 1 addition & 1 deletion playground/docus/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineNuxtConfig({
owner: 'nuxt-content',
repo: 'studio',
branch: 'main',
rootDir: 'playground/docus',
rootDir: 'playground/docus/content',
private: false,
},
},
Expand Down
2 changes: 1 addition & 1 deletion playground/minimal/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default defineNuxtConfig({
owner: 'nuxt-content',
repo: 'studio',
branch: 'main',
rootDir: 'playground/minimal',
rootDir: 'playground/minimal/content',
private: false,
},
},
Expand Down
23 changes: 17 additions & 6 deletions src/app/src/composables/useDraftBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
const list = ref<DraftItem<DatabaseItem | MediaItem>[]>([])
const current = ref<DraftItem<DatabaseItem | MediaItem> | null>(null)

const remotePathPrefix = type === 'media' ? 'public' : 'content'
const remotePathPrefix = type === 'media' ? 'public' : ''
const hostDb = type === 'media' ? host.media : host.document.db
const hookName = `studio:draft:${type}:updated` as const
const areDocumentsEqual = host.document.utils.areEqual
Expand All @@ -31,13 +31,26 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
return list.value.find(item => item.fsPath === fsPath) as DraftItem<T>
}

// Helper to fetch file with fallback strategy
async function fetchRemoteFile(fsPath: string): Promise<GitFile | null> {
const path = joinURL(remotePathPrefix, fsPath)
let remoteFile = await gitProvider.api.fetchFile(path, { cached: true })

if (!remoteFile && type === 'document' && !path.startsWith('content/')) {
Copy link

Choose a reason for hiding this comment

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

Suggested change
if (!remoteFile && type === 'document' && !path.startsWith('content/')) {
// Fallback strategy: if document fetch fails and path doesn't already start with 'content/',
// and rootDir doesn't already end with 'content' or 'content/', try prepending 'content/'
const rootDirNormalized = host.repository.rootDir.endsWith('/') ? host.repository.rootDir.slice(0, -1) : host.repository.rootDir
if (!remoteFile && type === 'document' && !path.startsWith('content/') && !rootDirNormalized.endsWith('content')) {

The fallback logic for fetching remote files will create double-nested paths when rootDir already includes 'content/', causing fallback attempts to fail to find documents that exist at the correct location.

View Details

Analysis

Double-nested content path when rootDir already ends with 'content'

What fails: fetchRemoteFile() in useDraftBase.ts creates a double-nested 'content' path when the fallback strategy is triggered and rootDir already ends with 'content'

How to reproduce: With rootDir: 'playground/docus/content' (current playground setup) and a missing document file:

  1. fetchRemoteFile('blog/post.md') attempts first fetch with path: 'blog/post.md'
  2. GitHub path resolves to: 'playground/docus/content/blog/post.md'
  3. If first fetch fails, fallback triggers and creates path: 'content/blog/post.md'
  4. GitHub path resolves to: 'playground/docus/content/content/blog/post.md'

Result: Fallback attempt fails because the double-nested path doesn't exist, even though the file exists at the first attempted path

Expected: Fallback strategy should only trigger when rootDir doesn't already end with 'content', to avoid double-nesting. The fallback was designed to support configurations where rootDir doesn't include 'content' (e.g., 'playground/docus'), where the fallback path would correctly resolve to 'playground/docus/content/blog/post.md'.

Fix: Updated condition at line 40 in src/app/src/composables/useDraftBase.ts to check if rootDir already ends with 'content' before attempting the fallback strategy.

const standardPath = joinURL('content', fsPath)
remoteFile = await gitProvider.api.fetchFile(standardPath, { cached: true })
}

return remoteFile
}

async function create(fsPath: string, item: T, original?: T, { rerender = true }: { rerender?: boolean } = {}): Promise<DraftItem<T>> {
const existingItem = list.value?.find(draft => draft.fsPath === fsPath)
if (existingItem) {
throw new Error(`Draft file already exists for document at ${fsPath}`)
}

const remoteFile = await gitProvider.api.fetchFile(joinURL(remotePathPrefix, fsPath), { cached: true }) as GitFile
const remoteFile = await fetchRemoteFile(fsPath) as GitFile

const draftItem: DraftItem<T> = {
fsPath,
Expand Down Expand Up @@ -83,8 +96,7 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
list.value = list.value.filter(item => item.fsPath !== fsPath)
}
else {
// TODO: check if remote file has been updated
const remoteFile = await gitProvider.api.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile
const remoteFile = await fetchRemoteFile(fsPath) as GitFile

deleteDraftItem = {
fsPath: existingDraftItem.fsPath,
Expand All @@ -97,8 +109,7 @@ export function useDraftBase<T extends DatabaseItem | MediaItem>(
}
}
else {
// TODO: check if gh file has been updated
const remoteFile = await gitProvider.api.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile
const remoteFile = await fetchRemoteFile(fsPath) as GitFile

deleteDraftItem = {
fsPath,
Expand Down
9 changes: 6 additions & 3 deletions src/app/src/composables/useDraftDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { DraftStatus } from '../types/draft'
import type { useGitProvider } from './useGitProvider'
import { createSharedComposable } from '@vueuse/core'
import { useHooks } from './useHooks'
import { joinURL } from 'ufo'
import { documentStorage as storage } from '../utils/storage'
import { getFileExtension } from '../utils/file'
import { useDraftBase } from './useDraftBase'
Expand Down Expand Up @@ -112,14 +111,18 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, gitPr
async function listAsRawFiles(): Promise<RawFile[]> {
const files = [] as RawFile[]
for (const draftItem of list.value) {
// FIX: Do NOT force 'content/' prefix. Use fsPath directly.
// This allows custom paths from remote repositories (e.g. '1.docs/...')
const filePath = draftItem.fsPath

if (draftItem.status === DraftStatus.Deleted) {
files.push({ path: joinURL('content', draftItem.fsPath), content: null, status: draftItem.status, encoding: 'utf-8' })
files.push({ path: filePath, content: null, status: draftItem.status, encoding: 'utf-8' })
continue
}

const content = await generateContentFromDocument(draftItem.modified as DatabaseItem)
files.push({
path: joinURL('content', draftItem.fsPath),
path: filePath,
content: content!,
status: draftItem.status,
encoding: 'utf-8',
Expand Down
5 changes: 3 additions & 2 deletions src/app/src/composables/useDraftMedias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,15 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, gitProvi
if (draftItem.status === DraftStatus.Pristine) {
continue
}
const filePath = draftItem.fsPath

if (draftItem.status === DraftStatus.Deleted) {
files.push({ path: joinURL('public', draftItem.fsPath), content: null, status: draftItem.status, encoding: 'base64' })
files.push({ path: filePath, content: null, status: draftItem.status, encoding: 'base64' })
continue
}

const content = (await draftItem.modified?.raw as string).replace(/^data:\w+\/\w+;base64,/, '')
files.push({ path: joinURL('public', draftItem.fsPath), content, status: draftItem.status, encoding: 'base64' })
files.push({ path: filePath, content, status: draftItem.status, encoding: 'base64' })
}

return files
Expand Down
164 changes: 121 additions & 43 deletions src/module/src/runtime/host.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { ensure } from './utils/ensure'
import type { CollectionInfo, CollectionItemBase, CollectionSource, DatabaseAdapter } from '@nuxt/content'
import { joinURL, withoutTrailingSlash } from 'ufo'
import type { CollectionInfo, CollectionItemBase, DatabaseAdapter, ResolvedCollectionSource } from '@nuxt/content'
import type { ContentDatabaseAdapter } from '../types/content'
import { getCollectionByFilePath, generateIdFromFsPath, generateRecordDeletion, generateRecordInsert, generateFsPathFromId, getCollectionById } from './utils/collection'
import { normalizeDocument, isDocumentMatchingContent, generateDocumentFromContent, generateContentFromDocument, areDocumentsEqual, pickReservedKeysFromDocument, removeReservedKeysFromDocument } from './utils/document'
Expand Down Expand Up @@ -43,13 +44,6 @@ function getHostStyles(): Record<string, Record<string, string>> & { css?: strin
'body[data-studio-active][data-expand-sidebar]': {
marginLeft: `${currentWidth}px`,
},
// 'body[data-studio-active][data-expand-toolbar]': {
// marginTop: '60px',
// },
// 'body[data-studio-active][data-expand-sidebar][data-expand-toolbar]': {
// marginLeft: `${currentWidth}px`,
// marginTop: '60px',
// },
}
}

Expand Down Expand Up @@ -89,20 +83,41 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
}

function useContentCollections(): Record<string, CollectionInfo> {
return Object.fromEntries(
Object.entries(useContent().collections).filter(([, collection]) => {
if (!collection.source.length || collection.source.some((source: CollectionSource) => source.repository || (source as unknown as { _custom: boolean })._custom)) {
return false
}
return true
}),
)
const allCollections = useContent().collections || {}
return allCollections
}

function useContentCollectionQuery(collection: string) {
return useContent().queryCollection(collection)
}

// Helper to deduce fsPath from ID when source config is missing
function getFsPathFromId(id: string, collectionName: string, source?: ResolvedCollectionSource): string {
if (source) {
return generateFsPathFromId(id, source)
}
// Fallback: remove collection name from ID
const regex = new RegExp(`^${collectionName}[/:]`)
return id.replace(regex, '')
}

function stripRootDir(path: string): string {
if (!repository.rootDir) return path
const rootDir = withoutTrailingSlash(repository.rootDir)
if (path.startsWith(rootDir + '/')) {
return path.substring(rootDir.length + 1)
}
if (path === rootDir) {
return ''
}
return path
}

function prependRootDir(path: string): string {
if (!repository.rootDir) return path
return joinURL(repository.rootDir, path)
}

const host: StudioHost = {
meta: {
dev: false,
Expand Down Expand Up @@ -189,12 +204,26 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
document: {
db: {
get: async (fsPath: string): Promise<DatabaseItem | undefined> => {
const collectionInfo = getCollectionByFilePath(fsPath, useContentCollections())
// Add rootDir to match collection pattern
const fullPath = prependRootDir(fsPath)
const collections = useContentCollections()
const collectionInfo = getCollectionByFilePath(fullPath, collections)

if (!collectionInfo) {
// FALLBACK: Try to guess collection by ID
for (const [name, _] of Object.entries(collections)) {
const id = `${name}/${fullPath}`
const item = await useContentCollectionQuery(name).where('id', '=', id).first()
if (item) {
return { ...item, fsPath }
}
}

console.error(`[Nuxt Studio] Collection not found for fsPath: ${fsPath} (full: ${fullPath}).`)
throw new Error(`Collection not found for fsPath: ${fsPath}`)
}

const id = generateIdFromFsPath(fsPath, collectionInfo)
const id = generateIdFromFsPath(fullPath, collectionInfo)
const item = await useContentCollectionQuery(collectionInfo.name).where('id', '=', id).first()

if (!item) {
Expand All @@ -212,30 +241,34 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
const documents = await useContentCollectionQuery(collection.name).all() as DatabaseItem[]

return documents.map((document) => {
const source = getCollectionSourceById(document.id, collection.source)
const fsPath = generateFsPathFromId(document.id, source!)
const sources = (Array.isArray(collection.source) ? collection.source : [collection.source]) as ResolvedCollectionSource[]
const source = getCollectionSourceById(document.id, sources)

// Use fallback if source is missing (likely remote repo)
const fsPath = getFsPathFromId(document.id, collection.name, source)

return {
...document,
fsPath,
fsPath: stripRootDir(fsPath),
}
})
}).filter(Boolean) as DatabaseItem[]
}))

return documentsByCollection.flat()
},
create: async (fsPath: string, content: string) => {
const existingDocument = await host.document.db.get(fsPath)
const fullPath = prependRootDir(fsPath)
const existingDocument = await host.document.db.get(fsPath).catch(() => null)
if (existingDocument) {
throw new Error(`Cannot create document with fsPath "${fsPath}": document already exists.`)
}

const collectionInfo = getCollectionByFilePath(fsPath, useContentCollections())
const collectionInfo = getCollectionByFilePath(fullPath, useContentCollections())
if (!collectionInfo) {
throw new Error(`Collection not found for fsPath: ${fsPath}`)
}

const id = generateIdFromFsPath(fsPath, collectionInfo!)
const id = generateIdFromFsPath(fullPath, collectionInfo!)
const document = await generateDocumentFromContent(id, content)
const normalizedDocument = normalizeDocument(id, collectionInfo, document!)

Expand All @@ -247,27 +280,56 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
}
},
upsert: async (fsPath: string, document: CollectionItemBase) => {
const collectionInfo = getCollectionByFilePath(fsPath, useContentCollections())
const fullPath = prependRootDir(fsPath)
const collections = useContentCollections()
let collectionInfo = getCollectionByFilePath(fullPath, collections)

// Fallback: try to get collection from document ID if path matching failed
if (!collectionInfo && document.id) {
try {
collectionInfo = getCollectionById(document.id, collections)
}
catch {
// ignore
}
}

if (!collectionInfo) {
throw new Error(`Collection not found for fsPath: ${fsPath}`)
throw new Error(`Collection not found for fsPath: ${fsPath} (full: ${fullPath})`)
}

const id = generateIdFromFsPath(fsPath, collectionInfo)
// Prefer existing document ID, otherwise generate one
const id = document.id || generateIdFromFsPath(fullPath, collectionInfo)

const normalizedDocument = normalizeDocument(id, collectionInfo, document)

await useContentDatabaseAdapter(collectionInfo.name).exec(generateRecordDeletion(collectionInfo, id))
await useContentDatabaseAdapter(collectionInfo.name).exec(generateRecordInsert(collectionInfo, normalizedDocument))
},
delete: async (fsPath: string) => {
const collection = getCollectionByFilePath(fsPath, useContentCollections())
if (!collection) {
const fullPath = prependRootDir(fsPath)
const collections = useContentCollections()
let collectionInfo = getCollectionByFilePath(fullPath, collections)

if (!collectionInfo) {
// Fallback: brute-force find collection by checking if guessed ID exists
for (const [name, _] of Object.entries(collections)) {
const id = `${name}/${fullPath}`
const item = await useContentCollectionQuery(name).where('id', '=', id).first()
if (item) {
collectionInfo = collections[name]
break
}
}
}

if (!collectionInfo) {
throw new Error(`Collection not found for fsPath: ${fsPath}`)
}

const id = generateIdFromFsPath(fsPath, collection)
const id = generateIdFromFsPath(fullPath, collectionInfo)

await useContentDatabaseAdapter(collection.name).exec(generateRecordDeletion(collection, id))
await useContentDatabaseAdapter(collectionInfo.name).exec(generateRecordDeletion(collectionInfo, id))
},
},
utils: {
Expand All @@ -276,23 +338,30 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
pickReservedKeys: (document: DatabaseItem) => pickReservedKeysFromDocument(document),
removeReservedKeys: (document: DatabaseItem) => removeReservedKeysFromDocument(document),
detectActives: () => {
// TODO: introduce a new convention to detect data contents [data-content-id!]
const wrappers = document.querySelectorAll('[data-content-id]')
return Array.from(wrappers).map((wrapper) => {
const id = wrapper.getAttribute('data-content-id')!
const title = id.split(/[/:]/).pop() || id

const collection = getCollectionById(id, useContentCollections())
let collection
try {
collection = getCollectionById(id, useContentCollections())
}
catch {
return null
}

const source = getCollectionSourceById(id, collection.source)
const sources = (Array.isArray(collection.source) ? collection.source : [collection.source]) as ResolvedCollectionSource[]
const source = getCollectionSourceById(id, sources)

const fsPath = generateFsPathFromId(id, source!)
// Use fallback logic
const fsPath = getFsPathFromId(id, collection.name, source)

return {
fsPath,
fsPath: stripRootDir(fsPath),
title,
}
})
}).filter(Boolean) as Array<{ fsPath: string, title: string }>
},
},
generate: {
Expand Down Expand Up @@ -361,12 +430,21 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH
}
if (element) {
const id = element.getAttribute('data-content-id')!
const collection = getCollectionById(id, useContentCollections())
const source = getCollectionSourceById(id, collection.source)
const fsPath = generateFsPathFromId(id, source!)

// @ts-expect-error studio:document:edit is not defined in types
useNuxtApp().hooks.callHook('studio:document:edit', fsPath)
try {
const collection = getCollectionById(id, useContentCollections())
const sources = (Array.isArray(collection.source) ? collection.source : [collection.source]) as ResolvedCollectionSource[]
const source = getCollectionSourceById(id, sources)

// Use fallback logic
const fsPath = getFsPathFromId(id, collection.name, source)

// @ts-expect-error studio:document:edit is not defined in types
useNuxtApp().hooks.callHook('studio:document:edit', stripRootDir(fsPath))
}
catch (e) {
console.warn(`[Nuxt Studio] Cannot edit document ${id}: ${e}`)
}
}
})
// Initialize styles
Expand Down
Loading
Loading