From b254bcc8dc91b1254f9c4b9961ccdbceff02c583 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Fri, 7 Nov 2025 17:56:07 +0100 Subject: [PATCH 1/2] wip: refactor to handle edge case for collection sources --- src/app/src/composables/useContext.ts | 3 +- src/app/src/composables/useTree.ts | 3 +- src/app/src/shared.ts | 1 + src/app/src/utils/collection.ts | 18 +++ src/app/src/utils/tree.ts | 5 - src/app/test/mocks/collection.ts | 72 +++++++++ src/app/test/unit/utils/collection.test.ts | 81 ++++++++++ src/app/test/unit/utils/tree.test.ts | 3 +- src/module/src/runtime/host.ts | 4 +- src/module/test/utils/collection.test.ts | 168 ++++++++++++++++++++- 10 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 src/app/src/utils/collection.ts create mode 100644 src/app/test/mocks/collection.ts create mode 100644 src/app/test/unit/utils/collection.test.ts diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 329e91a5..d55f1bbd 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -20,7 +20,8 @@ import type { useTree } from './useTree' import type { useGit } from './useGit' import type { useDraftMedias } from './useDraftMedias' import { useRoute, useRouter } from 'vue-router' -import { findDescendantsFileItemsFromFsPath, generateIdFromFsPath } from '../utils/tree' +import { findDescendantsFileItemsFromFsPath } from '../utils/tree' +import { generateIdFromFsPath } from '../utils/collection' import { joinURL } from 'ufo' import { upperFirst } from 'scule' import { generateStemFromFsPath } from '../utils/media' diff --git a/src/app/src/composables/useTree.ts b/src/app/src/composables/useTree.ts index fdf493a9..ec7baaeb 100644 --- a/src/app/src/composables/useTree.ts +++ b/src/app/src/composables/useTree.ts @@ -2,7 +2,8 @@ import { StudioFeature, TreeStatus, type StudioHost, type TreeItem, DraftStatus import { ref, computed } from 'vue' import type { useDraftDocuments } from './useDraftDocuments' import type { useDraftMedias } from './useDraftMedias' -import { buildTree, findItemFromFsPath, findItemFromRoute, findParentFromFsPath, generateIdFromFsPath } from '../utils/tree' +import { buildTree, findItemFromFsPath, findItemFromRoute, findParentFromFsPath } from '../utils/tree' +import { generateIdFromFsPath } from '../utils/collection' import type { RouteLocationNormalized } from 'vue-router' import { useHooks } from './useHooks' import { useStudioState } from './useStudioState' diff --git a/src/app/src/shared.ts b/src/app/src/shared.ts index e25b309b..24228a71 100644 --- a/src/app/src/shared.ts +++ b/src/app/src/shared.ts @@ -1 +1,2 @@ export { generateContentFromDocument, generateDocumentFromContent, removeReservedKeysFromDocument } from './utils/content' +export { generateIdFromFsPath, parseSourceBase } from './utils/collection' diff --git a/src/app/src/utils/collection.ts b/src/app/src/utils/collection.ts new file mode 100644 index 00000000..2974184b --- /dev/null +++ b/src/app/src/utils/collection.ts @@ -0,0 +1,18 @@ +import type { CollectionSource, CollectionInfo } from '@nuxt/content' +import { join } from 'pathe' + +export function parseSourceBase(source: CollectionSource) { + const [fixPart, ...rest] = source.include.includes('*') ? source.include.split('*') : ['', source.include] + return { + fixed: fixPart || '', + dynamic: '*' + rest.join('*'), + } +} + +export function generateIdFromFsPath(path: string, collectionInfo: CollectionInfo) { + const { fixed } = parseSourceBase(collectionInfo.source[0]!) + + const pathWithoutFixed = path.substring(fixed.length) + + return join(collectionInfo.name, collectionInfo.source[0]?.prefix || '', pathWithoutFixed) +} diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index d2ba7fca..92ac9076 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -11,7 +11,6 @@ import type { BaseItem } from '../types/item' import { isEqual } from './database' import { studioFlags } from '../composables/useStudio' import { getFileExtension, parseName } from './file' -import { joinURL } from 'ufo' export const COLOR_STATUS_MAP: { [key in TreeStatus]?: string } = { [TreeStatus.Created]: 'green', @@ -173,10 +172,6 @@ TreeItem[] { return tree } -export function generateIdFromFsPath(fsPath: string, collectionName: string): string { - return joinURL(collectionName, fsPath) -} - export function getTreeStatus(modified?: BaseItem, original?: BaseItem): TreeStatus { if (studioFlags.dev) { return TreeStatus.Opened diff --git a/src/app/test/mocks/collection.ts b/src/app/test/mocks/collection.ts new file mode 100644 index 00000000..b9b5345e --- /dev/null +++ b/src/app/test/mocks/collection.ts @@ -0,0 +1,72 @@ +import type { CollectionInfo } from '@nuxt/content' + +export const collections: Record = { + landing: { + name: 'landing', + pascalName: 'Landing', + tableName: '_content_landing', + source: [ + { + _resolved: true, + prefix: '/', + cwd: '/Users/larbish/Documents/nuxt/modules/studio/playground/content', + include: 'index.md', + }, + ], + type: 'page', + fields: { + id: 'string', + title: 'string', + body: 'json', + description: 'string', + extension: 'string', + meta: 'json', + navigation: 'json', + path: 'string', + seo: 'json', + stem: 'string', + }, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/docs', + definitions: {}, + }, + tableDefinition: '', + }, + docs: { + name: 'docs', + pascalName: 'Docs', + tableName: '_content_docs', + source: [ + { + _resolved: true, + prefix: '/', + cwd: '/Users/larbish/Documents/nuxt/modules/studio/playground/content', + include: '**', + exclude: [ + 'index.md', + ], + }, + ], + type: 'page', + fields: { + id: 'string', + title: 'string', + body: 'json', + description: 'string', + extension: 'string', + links: 'json', + meta: 'json', + navigation: 'json', + path: 'string', + seo: 'json', + stem: 'string', + }, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/__SCHEMA__', + definitions: {}, + }, + tableDefinition: 'CREATE TABLE IF NOT EXISTS', + }, +} diff --git a/src/app/test/unit/utils/collection.test.ts b/src/app/test/unit/utils/collection.test.ts new file mode 100644 index 00000000..6b0beb0f --- /dev/null +++ b/src/app/test/unit/utils/collection.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { generateIdFromFsPath } from '../../../src/utils/collection' +import type { CollectionInfo } from '@nuxt/content' +import { collections } from '../../mocks/collection' + +describe('generateIdFromFsPath', () => { + it('should generate id for single file with no prefix', () => { + const path = 'index.md' + const result = generateIdFromFsPath(path, collections.landing!) + expect(result).toBe('landing/index.md') + }) + + it('should generate id for nested file with global pattern', () => { + const path = '1.getting-started/2.introduction.md' + const result = generateIdFromFsPath(path, collections.docs!) + expect(result).toBe('docs/1.getting-started/2.introduction.md') + }) + + it('should handle deeply nested paths', () => { + const path = '2.essentials/1.nested/3.components.md' + const result = generateIdFromFsPath(path, collections.docs!) + expect(result).toBe('docs/2.essentials/1.nested/3.components.md') + }) + + it('should handle collection with custom prefix', () => { + const customCollection: CollectionInfo = { + name: 'docs_en', + pascalName: 'DocsEn', + tableName: '_content_docs_en', + source: [ + { + _resolved: true, + prefix: '/en', + cwd: '', + include: 'en/**/*', + exclude: ['en/index.md'], + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/docs_en', + definitions: {}, + }, + tableDefinition: '', + } + + const path = 'en/1.getting-started/2.introduction.md' + const result = generateIdFromFsPath(path, customCollection) + expect(result).toBe('docs_en/en/1.getting-started/2.introduction.md') + }) + + it('should handle empty prefix correctly', () => { + const customCollection: CollectionInfo = { + name: 'pages', + pascalName: 'Pages', + tableName: '_content_pages', + source: [ + { + _resolved: true, + prefix: '', + cwd: '', + include: 'content/**/*.md', + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/pages', + definitions: {}, + }, + tableDefinition: '', + } + + const path = 'content/about.md' + const result = generateIdFromFsPath(path, customCollection) + expect(result).toBe('pages/about.md') + }) +}) diff --git a/src/app/test/unit/utils/tree.test.ts b/src/app/test/unit/utils/tree.test.ts index 3a20a5c1..149544f7 100644 --- a/src/app/test/unit/utils/tree.test.ts +++ b/src/app/test/unit/utils/tree.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildTree, findParentFromFsPath, findItemFromRoute, findItemFromFsPath, findDescendantsFileItemsFromFsPath, getTreeStatus, generateIdFromFsPath } from '../../../src/utils/tree' +import { buildTree, findParentFromFsPath, findItemFromRoute, findItemFromFsPath, findDescendantsFileItemsFromFsPath, getTreeStatus } from '../../../src/utils/tree' import { tree } from '../../../test/mocks/tree' import type { TreeItem } from '../../../src/types/tree' import { dbItemsList, languagePrefixedDbItemsList, nestedDbItemsList } from '../../../test/mocks/database' @@ -9,6 +9,7 @@ import { DraftStatus, TreeStatus, TreeRootId } from '../../../src/types' import type { RouteLocationNormalized } from 'vue-router' import type { DatabaseItem } from '../../../src/types/database' import { joinURL, withLeadingSlash } from 'ufo' +import { generateIdFromFsPath } from '../../../src/utils/collection' describe('buildTree of documents with one level of depth', () => { // Result based on dbItemsList mock diff --git a/src/module/src/runtime/host.ts b/src/module/src/runtime/host.ts index 826a3dd0..19d38a39 100644 --- a/src/module/src/runtime/host.ts +++ b/src/module/src/runtime/host.ts @@ -2,11 +2,11 @@ import { ref } from 'vue' import { ensure } from './utils/ensure' import type { CollectionItemBase, CollectionSource, DatabaseAdapter } from '@nuxt/content' import type { ContentDatabaseAdapter } from '../types/content' -import { getCollectionByFilePath, generateIdFromFsPath, createCollectionDocument, generateRecordDeletion, generateRecordInsert, getCollectionInfo, normalizeDocument } from './utils/collection' +import { getCollectionByFilePath, createCollectionDocument, generateRecordDeletion, generateRecordInsert, getCollectionInfo, normalizeDocument } from './utils/collection' import { kebabCase } from 'scule' import type { StudioHost, StudioUser, DatabaseItem, MediaItem, Repository } from 'nuxt-studio/app' import type { RouteLocationNormalized, Router } from 'vue-router' -import { generateDocumentFromContent } from 'nuxt-studio/app/utils' +import { generateDocumentFromContent, generateIdFromFsPath } from 'nuxt-studio/app/utils' // @ts-expect-error queryCollection is not defined in .nuxt/imports.d.ts import { clearError, getAppManifest, queryCollection, queryCollectionItemSurroundings, queryCollectionNavigation, queryCollectionSearchSections } from '#imports' import { collections } from '#content/preview' diff --git a/src/module/test/utils/collection.test.ts b/src/module/test/utils/collection.test.ts index f7635605..81117d20 100644 --- a/src/module/test/utils/collection.test.ts +++ b/src/module/test/utils/collection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { getCollectionByFilePath, generateFsPathFromId } from '../../src/runtime/utils/collection' +import { getCollectionByFilePath, generateFsPathFromId, getCollectionSource } from '../../src/runtime/utils/collection' import type { CollectionInfo, ResolvedCollectionSource } from '@nuxt/content' import { collections } from '../mocks/collection' @@ -78,4 +78,170 @@ describe('generateFsPathFromId', () => { const result = generateFsPathFromId(id, source) expect(result).toBe('en/1.getting-started/2.introduction.md') }) + + it('Custom pattern with root prefix and fixed part', () => { + const id = 'pages/about.md' + const source: ResolvedCollectionSource = { + prefix: '/', + include: 'pages/**/*', + cwd: '', + _resolved: true, + } + + const result = generateFsPathFromId(id, source) + expect(result).toBe('pages/about.md') + }) +}) + +describe('getCollectionSource', () => { + it('should return matching source for root docs collection', () => { + const id = 'docs/1.getting-started/2.introduction.md' + const source = getCollectionSource(id, collections.docs!) + + expect(source).toEqual(collections.docs!.source[0]) + }) + + it('should return matching source for root index file in landing collection', () => { + const id = 'landing/index.md' + const source = getCollectionSource(id, collections.landing!) + + expect(source).toEqual(collections.landing!.source[0]) + }) + + it('should handle root dot files correctly', () => { + const id = 'docs/.navigation.yml' + const source = getCollectionSource(id, collections.docs!) + + expect(source).toEqual(collections.docs!.source[0]) + }) + + it('should return undefined when path matches exclude pattern', () => { + const id = 'landing/index.md' + const source = getCollectionSource(id, collections.docs!) + + expect(source).toBeUndefined() + }) + + it('should return correct source when collection has prefix with dynamic include pattern', () => { + const collectionWithPrefix: CollectionInfo = { + name: 'blog', + pascalName: 'Blog', + tableName: '_content_blog', + source: [ + { + _resolved: true, + prefix: '/blog', + include: 'blog/**/*.md', + cwd: '', + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/blog', + definitions: {}, + }, + tableDefinition: '', + } + + const id = 'blog/blog/my-post.md' + const source = getCollectionSource(id, collectionWithPrefix) + expect(source).toEqual(collectionWithPrefix.source[0]) + }) + + it('should return correct source when collection has multiple sources', () => { + const multiSourceCollection: CollectionInfo = { + name: 'content', + pascalName: 'Content', + tableName: '_content', + source: [ + { + _resolved: true, + prefix: '/blog', + cwd: '', + include: 'blog/**/*.md', + }, + { + _resolved: true, + prefix: '/docs', + cwd: '', + include: 'docs/**/*.md', + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/content', + definitions: {}, + }, + tableDefinition: '', + } + + const blogId = 'content/blog/my-post.md' + const blogResult = getCollectionSource(blogId, multiSourceCollection) + expect(blogResult).toEqual(multiSourceCollection.source[0]) + + const docsId = 'content/docs/guide.md' + const docsResult = getCollectionSource(docsId, multiSourceCollection) + expect(docsResult).toEqual(multiSourceCollection.source[1]) + }) + + it('should return correct source when collection has root prefix with custom dynamic include pattern', () => { + const rootPrefixCollection: CollectionInfo = { + name: 'pages', + pascalName: 'Pages', + tableName: '_content_pages', + source: [ + { + _resolved: true, + prefix: '/', + include: 'pages/**/*.md', + cwd: '', + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/__SCHEMA__', + definitions: { }, + }, + tableDefinition: '', + } + + // {collection.name}/{source.prefix}/{name} + const id = 'pages/about.md' + const source = getCollectionSource(id, rootPrefixCollection) + expect(source).toEqual(rootPrefixCollection.source[0]) + }) + + it('should return correct source when collection has custom prefix with custom dynamic include pattern', () => { + const customPrefixCollection: CollectionInfo = { + name: 'edge_case', + pascalName: 'EdgeCase', + tableName: '_content_edge_case', + source: [ + { + _resolved: true, + prefix: '/prefix', + include: 'path/**/*.md', + cwd: '', + }, + ], + type: 'page', + fields: {}, + schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/edge_case', + definitions: {}, + }, + tableDefinition: '', + } + + const id = 'edge_case/prefix/file.md' + const source = getCollectionSource(id, customPrefixCollection) + expect(source).toEqual(customPrefixCollection.source[0]) + }) }) From 61bba9e316e4a412255cabb73d311b270009bbed Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Fri, 7 Nov 2025 17:57:23 +0100 Subject: [PATCH 2/2] collection utils --- src/module/src/runtime/utils/collection.ts | 51 ++++++++++++---------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/module/src/runtime/utils/collection.ts b/src/module/src/runtime/utils/collection.ts index 3f4b6ec9..0d76d412 100644 --- a/src/module/src/runtime/utils/collection.ts +++ b/src/module/src/runtime/utils/collection.ts @@ -1,10 +1,11 @@ -import type { CollectionInfo, CollectionSource, Draft07, CollectionItemBase, PageCollectionItemBase, ResolvedCollectionSource, Draft07DefinitionProperty } from '@nuxt/content' +import type { CollectionInfo, Draft07, CollectionItemBase, PageCollectionItemBase, ResolvedCollectionSource, Draft07DefinitionProperty } from '@nuxt/content' import { hash } from 'ohash' import { pathMetaTransform } from './path-meta' import { minimatch } from 'minimatch' import { join, dirname, parse } from 'pathe' import type { DatabaseItem } from 'nuxt-studio/app' -import { withoutLeadingSlash } from 'ufo' +import { parseSourceBase } from 'nuxt-studio/app/utils' +import { withLeadingSlash, withoutLeadingSlash } from 'ufo' export const getCollectionByFilePath = (path: string, collections: Record): CollectionInfo | undefined => { let matchedSource: ResolvedCollectionSource | undefined @@ -34,15 +35,6 @@ export function generateStemFromFsPath(path: string) { return withoutLeadingSlash(join(dirname(path), parse(path).name)) } -// TODO handle several sources case -export function generateIdFromFsPath(path: string, collectionInfo: CollectionInfo) { - const { fixed } = parseSourceBase(collectionInfo.source[0]!) - - const pathWithoutFixed = path.substring(fixed.length) - - return join(collectionInfo.name, collectionInfo.source[0]?.prefix || '', pathWithoutFixed) -} - export function getOrderedSchemaKeys(schema: Draft07) { const shape = Object.values(schema.definitions)[0]?.properties || {} const keys = new Set([ @@ -67,7 +59,24 @@ export function getCollectionSource(id: string, collection: CollectionInfo) { const path = rest.join('/') const matchedSource = collection.source.find((source) => { - const include = minimatch(path, source.include, { dot: true }) + // Id is built like this: {collection.name}/{source.prefix}/{name} + // In some cases the prefix is different from the fsPath (source.include fixed part) + // In this case we need to remove the prefix from the path and add the fixed part of the source.include to get the fsPath + + let fsPath = path + + // First we need to ensure the path is starting with the source prefix in order to be sure the collection is the correct one + if (source.prefix && withLeadingSlash(path).startsWith(source.prefix)) { + const [fixPart] = source.include.includes('*') ? source.include.split('*') : ['', source.include] + const fixed = (fixPart || '').replace(/^\//, '') + + const prefix = (source.prefix || '').replace(/^\//, '') + const pathWithoutPrefix = path.replace(prefix || '', '') + + fsPath = join(fixed, pathWithoutPrefix) + } + + const include = minimatch(fsPath, source.include, { dot: true }) const exclude = source.exclude?.some(exclude => minimatch(path, exclude)) return include && !exclude @@ -81,9 +90,15 @@ export function generateFsPathFromId(id: string, source: CollectionInfo['source' const path = rest.join('/') const { fixed } = parseSourceBase(source) + const normalizedFixed = fixed.replace(/\/$/, '') + + // If path already starts with the fixed part, return as is + if (normalizedFixed && path.startsWith(normalizedFixed)) { + return path + } - const pathWithoutFixed = path.substring(fixed.length) - return join(fixed, pathWithoutFixed) + // Otherwise, join fixed part with path + return join(fixed, path) } export function getCollectionInfo(id: string, collections: Record) { @@ -99,14 +114,6 @@ export function getCollectionInfo(id: string, collections: Record