diff --git a/packages/core/dev-test/backends/gitlab/config.yml b/packages/core/dev-test/backends/gitlab/config.yml index cf6a72c5..70328f84 100644 --- a/packages/core/dev-test/backends/gitlab/config.yml +++ b/packages/core/dev-test/backends/gitlab/config.yml @@ -71,6 +71,7 @@ collections: label: FAQ folder: _faqs create: true + media_folder: '/_faqs_images/{{slug}}' fields: - label: Question name: title @@ -78,6 +79,9 @@ collections: - label: Answer name: body widget: markdown + - label: Image + name: image + widget: image - name: posts label: Posts label_singular: Post @@ -475,3 +479,26 @@ collections: - label: File name: file widget: file + - name: pages + label: Nested Pages + label_singular: 'Page' + folder: _nested_pages + create: true + media_folder: '/_nested_page_pictures/{{slug}}' + # adding a nested object will show the collection folder structure + nested: + depth: 100 # max depth to show in the collection tree + summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field + # adding a path object allows editing the path of entries + # moving an existing entry will move the entire sub tree of the entry to the new location + path: { label: 'Path', index_file: 'index' } + fields: + - label: Title + name: title + widget: string + - label: Image + name: image + widget: image + - label: Body + name: body + widget: markdown diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index a4ce3915..14c91b26 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -833,7 +833,7 @@ export class Backend { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); }); @@ -29,7 +29,7 @@ describe('gitea API', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should fetch url with authorization header', async () => { diff --git a/packages/core/src/backends/github/__tests__/API.spec.ts b/packages/core/src/backends/github/__tests__/API.spec.ts index 982df0a9..6e751051 100644 --- a/packages/core/src/backends/github/__tests__/API.spec.ts +++ b/packages/core/src/backends/github/__tests__/API.spec.ts @@ -6,7 +6,7 @@ import type { Options } from '../API'; describe('github API', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); }); @@ -65,7 +65,7 @@ describe('github API', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should fetch url with authorization header', async () => { diff --git a/packages/core/src/backends/gitlab/__tests__/API.spec.ts b/packages/core/src/backends/gitlab/__tests__/API.spec.ts index 85ab3d34..f179f260 100644 --- a/packages/core/src/backends/gitlab/__tests__/API.spec.ts +++ b/packages/core/src/backends/gitlab/__tests__/API.spec.ts @@ -5,7 +5,7 @@ global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch insi describe('GitLab API', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); afterAll(() => { diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx index 73b2bf97..990e978b 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx @@ -63,7 +63,7 @@ const EditorControlPane = ({ const config = useAppSelector(selectConfig); const defaultNestedPath = useMemo( - () => getNestedSlug(collection, entry, slug, config), + () => getNestedSlug(collection, entry, slug, config?.slug), [collection, config, entry, slug], ); diff --git a/packages/core/src/lib/formatters.ts b/packages/core/src/lib/formatters.ts index dd948266..fc71aec9 100644 --- a/packages/core/src/lib/formatters.ts +++ b/packages/core/src/lib/formatters.ts @@ -5,6 +5,7 @@ import { sanitizeSlug } from './urlHelper'; import { selectIdentifier, selectInferredField } from './util/collection.util'; import { selectField } from './util/field.util'; import { set } from './util/object.util'; +import { isEmpty } from './util/string.util'; import { addFileTemplateFields, compileStringTemplate, @@ -94,16 +95,21 @@ export function slugFormatter( collection: Collection, entryData: EntryData, slugConfig?: Slug, -) { +): string { const slugTemplate = collection.slug || '{{slug}}'; - const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); - if (!identifier) { + const identifierField = selectIdentifier(collection); + if (!identifierField) { throw new Error( 'Collection must have a field name that is a valid entry identifier, or must have `identifier_field` set', ); } + const identifier = get(entryData, keyToPathArray(identifierField)); + if (isEmpty(identifier)) { + return ''; + } + const processSegment = getProcessSegment(slugConfig); const date = new Date(); const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment); @@ -122,10 +128,12 @@ export function summaryFormatter( summaryTemplate: string, entry: Entry, collection: Collection, + slugConfig?: Slug, ) { + const slug = slugFormatter(collection, entry.data, slugConfig); + let entryData = entry.data; const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; - const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); entryData = addFileTemplateFields(entry.path, entryData, 'folder' in collection ? collection.folder : '') ?? @@ -137,7 +145,7 @@ export function summaryFormatter( if (entry.updatedOn && !selectField(collection, COMMIT_DATE)) { entryData = set(entryData, COMMIT_DATE, entry.updatedOn); } - const summary = compileStringTemplate(summaryTemplate, date, identifier, entryData); + const summary = compileStringTemplate(summaryTemplate, date, slug, entryData); return summary; } @@ -160,8 +168,10 @@ export function folderFormatter( 'folder' in collection ? collection.folder : '', ); - const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; const slug = slugFormatter(collection, entry.data, slugConfig); + + const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; + const processSegment = getProcessSegment(slugConfig, [defaultFolder, fields?.dirname as string]); const mediaFolder = compileStringTemplate(folderTemplate, date, slug, fields, processSegment); diff --git a/packages/core/src/lib/urlHelper.ts b/packages/core/src/lib/urlHelper.ts index 2ebfdf07..7958741f 100644 --- a/packages/core/src/lib/urlHelper.ts +++ b/packages/core/src/lib/urlHelper.ts @@ -1,11 +1,12 @@ -import url from 'url'; -import urlJoin from 'url-join'; import diacritics from 'diacritics'; -import sanitizeFilename from 'sanitize-filename'; -import isString from 'lodash/isString'; import escapeRegExp from 'lodash/escapeRegExp'; import flow from 'lodash/flow'; +import isString from 'lodash/isString'; import partialRight from 'lodash/partialRight'; +import trim from 'lodash/trim'; +import sanitizeFilename from 'sanitize-filename'; +import url from 'url'; +import urlJoin from 'url-join'; import type { Slug } from '../interface'; @@ -124,5 +125,5 @@ export function sanitizeSlug(str: string, options?: Slug) { } export function joinUrlPath(base: string, ...path: string[]) { - return urlJoin(base, ...path); + return urlJoin(trim(base, '/'), ...path.map(p => trim(p, '/'))); } diff --git a/packages/core/src/lib/util/__tests__/media.util.spec.ts b/packages/core/src/lib/util/__tests__/media.util.spec.ts index edd7d36c..22eb7f50 100644 --- a/packages/core/src/lib/util/__tests__/media.util.spec.ts +++ b/packages/core/src/lib/util/__tests__/media.util.spec.ts @@ -2,7 +2,7 @@ import { createMockCollection } from '@staticcms/test/data/collections.mock'; import { createMockConfig } from '@staticcms/test/data/config.mock'; import { createMockEntry } from '@staticcms/test/data/entry.mock'; import { mockImageField as mockBaseImageField } from '@staticcms/test/data/fields.mock'; -import { selectMediaFolder } from '../media.util'; +import { selectMediaFilePublicPath, selectMediaFolder } from '../media.util'; import type { FileOrImageField, FolderCollection, UnknownField } from '@staticcms/core/interface'; @@ -145,6 +145,80 @@ describe('media.util', () => { 'path/to/entry/path/to/some/other/media/i-am-a-title-fish', ); }); + + it('should substitute slug from top level media_folder (always considered an absolute path)', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/media/folder/i-am-a-title-fish', + ); + }); + + it('should not throw error when evaluating slug for new entry', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/some/other/media/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: {}, + newRecord: true, + }); + + expect(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/entry/path/to/some/other/media', + ); + }); }); }); @@ -238,6 +312,730 @@ describe('media.util', () => { 'path/to/some/other/media/i-am-a-title-fish', ); }); + + it('should substitute slug from top level media_folder', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: '/path/to/media/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/media/folder/i-am-a-title-fish', + ); + }); + + it('should not throw error when evaluating slug for new entry', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/some/other/media/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: {}, + newRecord: true, + }); + + expect(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/some/other/media', + ); + }); + }); + }); + }); + }); + + describe('selectMediaFilePublicPath', () => { + const mockBaseCollection = createMockCollection({ + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }); + + const mockBaseEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { + title: 'I am a title', + }, + }); + + describe('top level', () => { + it('should default to top level config public_folder', () => { + const mockConfig = createMockConfig({ + collections: [mockBaseCollection], + media_folder: 'path/to/media/folder', + public_folder: 'path/to/public/folder', + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + undefined, + 'image.png', + undefined, + undefined, + undefined, + ), + ).toBe('path/to/public/folder/image.png'); + }); + + it('should use media_folder as an absolute path if public_folder is not provided', () => { + const mockConfig = createMockConfig({ + collections: [mockBaseCollection], + media_folder: 'path/to/media/folder', + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + undefined, + 'image.png', + undefined, + undefined, + undefined, + ), + ).toBe('/path/to/media/folder/image.png'); + }); + }); + + describe('entry', () => { + it('should default to top level config public_folder', () => { + const mockConfig = createMockConfig({ + collections: [mockBaseCollection], + media_folder: 'path/to/media/folder', + public_folder: 'path/to/public/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('path/to/public/folder/image.png'); + }); + + it('should default to top level config media_folder as an absolute path', () => { + const mockConfig = createMockConfig({ + collections: [mockBaseCollection], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('/path/to/media/folder/image.png'); + }); + + describe('relative path', () => { + it('should use collection public_folder if available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/collection/media/folder', + public_folder: 'path/to/collection/public/folder', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('path/to/collection/public/folder/image.png'); + }); + + it('should use collection media_folder if no public_folder is available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/collection/media/folder', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('path/to/collection/media/folder/image.png'); + }); + + describe('template variable', () => { + it('should substitute field value', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/some/other/media/{{fields.title}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('path/to/some/other/media/i-am-a-title/image.png'); + }); + + it('should substitute slug', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/some/other/media/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('path/to/some/other/media/i-am-a-title-fish/image.png'); + }); + + it('should substitute slug from top level media_folder if no public_folders', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('/path/to/media/folder/i-am-a-title-fish/image.png'); + }); + + it('should substitute slug from top level public_folder', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder/{{slug}}', + public_folder: 'path/to/public/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('path/to/public/folder/i-am-a-title-fish/image.png'); + }); + + it('should not throw error when evaluating slug for new entry', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/collection/media/folder/{{slug}}', + public_folder: 'path/to/collection/public/folder/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: {}, + newRecord: true, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('path/to/collection/public/folder/image.png'); + }); + }); + }); + + describe('absolute path', () => { + it('should use collection public_folder if available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/collection/media/folder', + public_folder: '/path/to/collection/public/folder', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('/path/to/collection/public/folder/image.png'); + }); + + it('should use collection media_folder if no public_folder is available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/collection/media/folder', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('/path/to/collection/media/folder/image.png'); + }); + + describe('template variable', () => { + it('should substitute field value', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/some/other/media/{{fields.title}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockBaseEntry, + mockImageField, + ), + ).toBe('/path/to/some/other/media/i-am-a-title/image.png'); + }); + + it('should substitute slug', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/some/other/media/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('/path/to/some/other/media/i-am-a-title-fish/image.png'); + }); + + it('should substitute slug from top level media_folder if no public_folders', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: '/path/to/media/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('/path/to/media/folder/i-am-a-title-fish/image.png'); + }); + + it('should substitute slug from top level public_folder', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: '/path/to/media/folder/{{slug}}', + public_folder: '/path/to/public/folder/{{slug}}', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: { title: 'i am a title', name: 'fish' }, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('/path/to/public/folder/i-am-a-title-fish/image.png'); + }); + + it('should not throw error when evaluating slug for new entry', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/collection/media/folder/{{slug}}', + public_folder: '/path/to/collection/public/folder/{{slug}}', + slug: '{{fields.title}}-{{fields.name}}', + fields: [ + { + name: 'title', + widget: 'string', + }, + { + name: 'name', + widget: 'string', + }, + mockBaseImageField, + ], + }), + ], + media_folder: 'path/to/media/folder', + }); + + const mockCollection = mockConfig.collections[0]; + const mockImageField = (mockConfig.collections[0] as FolderCollection) + .fields[3] as FileOrImageField; + + const mockEntry = createMockEntry({ + path: 'path/to/entry/index.md', + data: {}, + newRecord: true, + }); + + expect( + selectMediaFilePublicPath( + mockConfig, + mockCollection, + 'image.png', + mockEntry, + mockImageField, + ), + ).toBe('/path/to/collection/public/folder/image.png'); + }); }); }); }); diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index af986e95..0e5155a4 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -236,7 +236,14 @@ export function selectMediaFolder( field: MediaField | undefined, currentFolder?: string, ) { - let mediaFolder = config['media_folder'] ?? ''; + let mediaFolder = folderFormatter( + config.media_folder ?? '', + entryMap, + collection as Collection, + config.media_folder ?? '', + 'media_folder', + config.slug, + ); if (currentFolder) { mediaFolder = currentFolder; @@ -270,7 +277,15 @@ export function selectMediaFilePublicPath( return mediaPath; } - let publicFolder = config['public_folder']!; + let publicFolder = folderFormatter( + config.public_folder ?? '', + entryMap, + collection as Collection, + config.public_folder ?? '', + 'public_folder', + config.slug, + ); + let selectedPublicFolder = publicFolder; const customPublicFolder = hasCustomFolder('public_folder', collection, entryMap?.slug, field); @@ -288,11 +303,12 @@ export function selectMediaFilePublicPath( selectedPublicFolder = trim(currentFolder, '/').replace(trim(mediaFolder!, '/'), publicFolder); } - if (isAbsolutePath(selectedPublicFolder)) { - return joinUrlPath(selectedPublicFolder, basename(mediaPath)); + const finalPublicPath = joinUrlPath(selectedPublicFolder, basename(mediaPath)); + if (selectedPublicFolder.startsWith('/')) { + return `/${finalPublicPath}`; } - return joinUrlPath(selectedPublicFolder, basename(mediaPath)); + return finalPublicPath; } export function selectMediaFilePath( diff --git a/packages/core/src/lib/util/nested.util.ts b/packages/core/src/lib/util/nested.util.ts index 276937e3..fe69dd83 100644 --- a/packages/core/src/lib/util/nested.util.ts +++ b/packages/core/src/lib/util/nested.util.ts @@ -2,11 +2,11 @@ import trim from 'lodash/trim'; import { basename, dirname, extname, join } from 'path'; import { sanitizeSlug } from '../urlHelper'; +import { stringTemplate } from '../widgets'; import { selectEntryCollectionTitle, selectFolderEntryExtension } from './collection.util'; import { isEmpty, isNotEmpty } from './string.util'; -import { stringTemplate } from '../widgets'; -import type { BaseField, Collection, Config, Entry } from '@staticcms/core/interface'; +import type { BaseField, Collection, Entry, Slug } from '@staticcms/core/interface'; const { addFileTemplateFields } = stringTemplate; @@ -28,7 +28,7 @@ export function selectCustomPath( entry: Entry, collection: Collection, rootSlug: string | undefined, - config: Config | undefined, + slugConfig: Slug | undefined, ): string | undefined { if (!('nested' in collection) || !collection.nested?.path) { return undefined; @@ -37,7 +37,7 @@ export function selectCustomPath( const indexFile = collection.nested.path.index_file; const extension = selectFolderEntryExtension(collection); - const slug = entry.meta?.path ?? getNestedSlug(collection, entry, rootSlug, config); + const slug = entry.meta?.path ?? getNestedSlug(collection, entry, rootSlug, slugConfig); const customPath = join(collection.folder, slug, `${indexFile}.${extension}`); return customPath; @@ -72,7 +72,7 @@ export function getNestedSlug( collection: Collection, entry: Entry, slug: string | undefined, - config: Config | undefined, + slugConfig: Slug | undefined, ) { if ('nested' in collection && collection.nested?.path) { if (isNotEmpty(entry.slug)) { @@ -85,7 +85,7 @@ export function getNestedSlug( return `${customPathFromSlug(collection, slug)}/${sanitizeSlug( summarySlug.toLowerCase(), - config?.slug, + slugConfig, )}`; } } diff --git a/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts b/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts index 9f276d75..a3afdb4c 100644 --- a/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts +++ b/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts @@ -223,7 +223,7 @@ describe(RelationControl.name, () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); interface RenderRelationControlOptions { @@ -653,7 +653,7 @@ describe(RelationControl.name, () => { }); describe('parse options', () => { - fit('should default to valueField if displayFields is not set', async () => { + it('should default to valueField if displayFields is not set', async () => { const field: RelationField = { label: 'Relation', name: 'relation',