diff --git a/playground/docus/nuxt.config.ts b/playground/docus/nuxt.config.ts index 4d326975..54150b24 100644 --- a/playground/docus/nuxt.config.ts +++ b/playground/docus/nuxt.config.ts @@ -20,7 +20,7 @@ export default defineNuxtConfig({ owner: 'nuxt-content', repo: 'studio', branch: 'main', - rootDir: 'playground/docus', + rootDir: 'playground/docus/content', private: false, }, }, diff --git a/playground/minimal/nuxt.config.ts b/playground/minimal/nuxt.config.ts index ceace726..2b20c4a8 100644 --- a/playground/minimal/nuxt.config.ts +++ b/playground/minimal/nuxt.config.ts @@ -16,7 +16,7 @@ export default defineNuxtConfig({ owner: 'nuxt-content', repo: 'studio', branch: 'main', - rootDir: 'playground/minimal', + rootDir: 'playground/minimal/content', private: false, }, }, diff --git a/src/app/src/composables/useDraftBase.ts b/src/app/src/composables/useDraftBase.ts index d429d279..295f628f 100644 --- a/src/app/src/composables/useDraftBase.ts +++ b/src/app/src/composables/useDraftBase.ts @@ -19,7 +19,7 @@ export function useDraftBase( const list = ref[]>([]) const current = ref | 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 @@ -31,13 +31,26 @@ export function useDraftBase( return list.value.find(item => item.fsPath === fsPath) as DraftItem } + // Helper to fetch file with fallback strategy + async function fetchRemoteFile(fsPath: string): Promise { + const path = joinURL(remotePathPrefix, fsPath) + let remoteFile = await gitProvider.api.fetchFile(path, { cached: true }) + + if (!remoteFile && type === 'document' && !path.startsWith('content/')) { + 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> { 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 = { fsPath, @@ -83,8 +96,7 @@ export function useDraftBase( 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, @@ -97,8 +109,7 @@ export function useDraftBase( } } 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, diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index 39068a31..0c3461ab 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -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' @@ -112,14 +111,18 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, gitPr async function listAsRawFiles(): Promise { 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', diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts index 7d3971c1..08096d23 100644 --- a/src/app/src/composables/useDraftMedias.ts +++ b/src/app/src/composables/useDraftMedias.ts @@ -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 diff --git a/src/module/src/runtime/host.ts b/src/module/src/runtime/host.ts index 07274cad..296502ac 100644 --- a/src/module/src/runtime/host.ts +++ b/src/module/src/runtime/host.ts @@ -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' @@ -43,13 +44,6 @@ function getHostStyles(): Record> & { 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', - // }, } } @@ -89,20 +83,41 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH } function useContentCollections(): Record { - 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, @@ -189,12 +204,26 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH document: { db: { get: async (fsPath: string): Promise => { - 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) { @@ -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!) @@ -247,12 +280,26 @@ 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) @@ -260,14 +307,29 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH 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: { @@ -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: { @@ -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 diff --git a/src/module/src/runtime/utils/collection.ts b/src/module/src/runtime/utils/collection.ts index 8924caed..9331cdb8 100644 --- a/src/module/src/runtime/utils/collection.ts +++ b/src/module/src/runtime/utils/collection.ts @@ -15,25 +15,34 @@ export function generateStemFromFsPath(path: string) { export function generateIdFromFsPath(path: string, collectionInfo: CollectionInfo) { const { fixed } = parseSourceBase(collectionInfo.source[0]!) - const pathWithoutFixed = path.substring(fixed.length) + const pathWithoutFixed = (fixed && fixed !== '/') ? path.substring(fixed.length) : path return join(collectionInfo.name, collectionInfo.source[0]?.prefix || '', pathWithoutFixed) } export function generateFsPathFromId(id: string, source: ResolvedCollectionSource) { const [_, ...rest] = id.split(/[/:]/) - const path = rest.join('/') + const fullPathInId = rest.join('/') const { fixed } = parseSourceBase(source) const normalizedFixed = withoutTrailingSlash(fixed) + const prefix = source.prefix ? withoutTrailingSlash(source.prefix) : '' - // If path already starts with the fixed part, return as is - if (normalizedFixed && path.startsWith(normalizedFixed)) { - return path + // 1. Remove prefix from the path if it exists in the ID + let relativePath = fullPathInId + if (prefix && fullPathInId.startsWith(withoutLeadingSlash(prefix))) { + relativePath = fullPathInId.substring(withoutLeadingSlash(prefix).length) } - // Otherwise, join fixed part with path - return join(fixed, path) + relativePath = withoutLeadingSlash(relativePath) + + // 2. If the fixed part (source folder) is already in the path, return it as is + if (normalizedFixed && relativePath.startsWith(withoutLeadingSlash(normalizedFixed))) { + return relativePath + } + + // 3. Otherwise, reconstruct the full path by adding the fixed part + return join(fixed || '', relativePath) } /** diff --git a/src/module/src/runtime/utils/source.ts b/src/module/src/runtime/utils/source.ts index 7298ec2c..bf86d8fb 100644 --- a/src/module/src/runtime/utils/source.ts +++ b/src/module/src/runtime/utils/source.ts @@ -21,28 +21,30 @@ export function getCollectionSourceById(id: string, sources: ResolvedCollectionS const prefixAndPath = rest.join('/') const matchedSource = sources.find((source) => { - const prefix = source.prefix - if (!prefix) { - return false - } + const prefix = source.prefix || '' - if (!withLeadingSlash(prefixAndPath).startsWith(prefix)) { + if (prefix && !withLeadingSlash(prefixAndPath).startsWith(withLeadingSlash(prefix))) { return false } let fsPath const [fixPart] = source.include.includes('*') ? source.include.split('*') : ['', source.include] const fixed = withoutTrailingSlash(fixPart || '/') - if (withoutLeadingSlash(fixed) === withoutLeadingSlash(prefix)) { - fsPath = prefixAndPath + + // 1. Remove prefix from path + let path = prefix ? prefixAndPath.replace(prefix, '') : prefixAndPath + path = withoutLeadingSlash(path) + + if (fixed && path.startsWith(withoutLeadingSlash(fixed))) { + fsPath = path } else { - const path = prefixAndPath.replace(prefix, '') fsPath = join(fixed, path) } - const include = minimatch(fsPath, source.include, { dot: true }) - const exclude = source.exclude?.some(exclude => minimatch(fsPath, exclude)) + const relativeFsPath = withoutLeadingSlash(fsPath) + const include = minimatch(relativeFsPath, source.include, { dot: true }) + const exclude = source.exclude?.some(exclude => minimatch(relativeFsPath, exclude)) return include && !exclude })