From d28c43e95a289f7d0beb24b5ddb90d70f3ad72a3 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Wed, 10 May 2023 09:09:36 -0400 Subject: [PATCH] fix: slug in media paths (#793) --- packages/core/src/__mocks__/backend.ts | 36 +++ packages/core/src/__mocks__/url-join.ts | 2 +- packages/core/src/actions/config.ts | 6 +- packages/core/src/backend.ts | 9 +- packages/core/src/lib/formatters.ts | 30 ++- .../src/lib/util/__tests__/media.util.spec.ts | 245 ++++++++++++++++++ packages/core/src/lib/util/media.util.ts | 15 +- .../core/src/lib/widgets/stringTemplate.ts | 4 + .../widgets/file/__test__/FileControl.spec.ts | 7 +- .../__tests__/BalloonToolbar.spec.tsx | 2 + .../__tests__/RelationControl.spec.ts | 32 ++- packages/core/test/data/config.mock.ts | 15 +- packages/core/test/data/widgets.mock.ts | 2 + 13 files changed, 358 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/__mocks__/backend.ts create mode 100644 packages/core/src/lib/util/__tests__/media.util.spec.ts diff --git a/packages/core/src/__mocks__/backend.ts b/packages/core/src/__mocks__/backend.ts new file mode 100644 index 00000000..eceb94d8 --- /dev/null +++ b/packages/core/src/__mocks__/backend.ts @@ -0,0 +1,36 @@ +import type { Entry } from '../interface'; + +const { + expandSearchEntries: actualExpandSearchEntries, + getEntryField: actualGetEntryField, + mergeExpandedEntries: actualMergeExpandedEntries, +} = jest.requireActual('@staticcms/core/backend'); + +const isGitBackend = jest.fn().mockReturnValue(true); + +export const resolveBackend = jest.fn().mockReturnValue({ + isGitBackend, +}); + +export const currentBackend = jest.fn(); + +export const expandSearchEntries = jest.fn().mockImplementation( + ( + entries: Entry[], + searchFields: string[], + ): (Entry & { + field: string; + })[] => { + return actualExpandSearchEntries(entries, searchFields); + }, +); + +export const getEntryField = jest.fn().mockImplementation((field: string, entry: Entry): string => { + return actualGetEntryField(field, entry); +}); + +export const mergeExpandedEntries = jest + .fn() + .mockImplementation((entries: (Entry & { field: string })[]): Entry[] => { + return actualMergeExpandedEntries(entries); + }); diff --git a/packages/core/src/__mocks__/url-join.ts b/packages/core/src/__mocks__/url-join.ts index 418f35bc..0880b129 100644 --- a/packages/core/src/__mocks__/url-join.ts +++ b/packages/core/src/__mocks__/url-join.ts @@ -1,3 +1,3 @@ -export default function cleanStack(parts: string[]) { +export default function urlJoin(...parts: string[]) { return parts.join('/'); } diff --git a/packages/core/src/actions/config.ts b/packages/core/src/actions/config.ts index 8d15a8dc..5fcecd7d 100644 --- a/packages/core/src/actions/config.ts +++ b/packages/core/src/actions/config.ts @@ -4,7 +4,7 @@ import trim from 'lodash/trim'; import trimStart from 'lodash/trimStart'; import yaml from 'yaml'; -import { resolveBackend } from '../backend'; +import { resolveBackend } from '@staticcms/core/backend'; import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants'; import validateConfig from '../constants/configSchema'; import { @@ -124,7 +124,9 @@ function throwOnMissingDefaultLocale(i18n?: I18nInfo) { } } -export function applyDefaults(originalConfig: Config) { +export function applyDefaults( + originalConfig: Config, +): Config { return produce(originalConfig, (config: Config) => { config.slug = config.slug || {}; config.collections = config.collections || []; diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 2c78d51e..a4ce3915 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -133,7 +133,12 @@ export function extractSearchFields(searchFields: string[]) { }, ''); } -export function expandSearchEntries(entries: Entry[], searchFields: string[]) { +export function expandSearchEntries( + entries: Entry[], + searchFields: string[], +): (Entry & { + field: string; +})[] { // expand the entries for the purpose of the search const expandedEntries = entries.reduce((acc, e) => { const expandedFields = searchFields.reduce((acc, f) => { @@ -152,7 +157,7 @@ export function expandSearchEntries(entries: Entry[], searchFields: string[]) { return expandedEntries; } -export function mergeExpandedEntries(entries: (Entry & { field: string })[]) { +export function mergeExpandedEntries(entries: (Entry & { field: string })[]): Entry[] { // merge the search results by slug and only keep data that matched the search const fields = entries.map(f => f.field); const arrayPaths: Record> = {}; diff --git a/packages/core/src/lib/formatters.ts b/packages/core/src/lib/formatters.ts index c2f6c2e9..dd948266 100644 --- a/packages/core/src/lib/formatters.ts +++ b/packages/core/src/lib/formatters.ts @@ -1,6 +1,4 @@ -import flow from 'lodash/flow'; import get from 'lodash/get'; -import partialRight from 'lodash/partialRight'; import { COMMIT_AUTHOR, COMMIT_DATE } from '../constants/commitProps'; import { sanitizeSlug } from './urlHelper'; @@ -14,7 +12,15 @@ import { parseDateFromEntry, } from './widgets/stringTemplate'; -import type { BaseField, Collection, Config, Entry, EntryData, Slug } from '../interface'; +import type { + BaseField, + Collection, + Config, + Entry, + EntryData, + Slug, + UnknownField, +} from '../interface'; const commitMessageTemplates = { create: 'Create {{collection}} “{{slug}}”', @@ -81,10 +87,14 @@ export function getProcessSegment(slugConfig?: Slug, ignoreValues?: string[]) { return (value: string) => ignoreValues && ignoreValues.includes(value) ? value - : flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value); + : sanitizeSlug(prepareSlug(String(value)), slugConfig); } -export function slugFormatter(collection: Collection, entryData: EntryData, slugConfig?: Slug) { +export function slugFormatter( + collection: Collection, + entryData: EntryData, + slugConfig?: Slug, +) { const slugTemplate = collection.slug || '{{slug}}'; const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); @@ -151,16 +161,10 @@ export function folderFormatter( ); const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; - const identifier = get(fields, keyToPathArray(selectIdentifier(collection))); + const slug = slugFormatter(collection, entry.data, slugConfig); const processSegment = getProcessSegment(slugConfig, [defaultFolder, fields?.dirname as string]); - const mediaFolder = compileStringTemplate( - folderTemplate, - date, - identifier, - fields, - processSegment, - ); + const mediaFolder = compileStringTemplate(folderTemplate, date, slug, fields, processSegment); return mediaFolder; } diff --git a/packages/core/src/lib/util/__tests__/media.util.spec.ts b/packages/core/src/lib/util/__tests__/media.util.spec.ts new file mode 100644 index 00000000..edd7d36c --- /dev/null +++ b/packages/core/src/lib/util/__tests__/media.util.spec.ts @@ -0,0 +1,245 @@ +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 type { FileOrImageField, FolderCollection, UnknownField } from '@staticcms/core/interface'; + +jest.mock('@staticcms/core/backend'); + +describe('media.util', () => { + describe('selectMediaFolder', () => { + 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 media_folder', () => { + const mockConfig = createMockConfig({ + collections: [mockBaseCollection], + media_folder: 'path/to/media/folder', + }); + + expect(selectMediaFolder(mockConfig, undefined, undefined, undefined)).toBe( + 'path/to/media/folder', + ); + }); + }); + + describe('entry', () => { + it('should default to top level config media_folder', () => { + 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(selectMediaFolder(mockConfig, mockCollection, mockBaseEntry, mockImageField)).toBe( + 'path/to/media/folder', + ); + }); + + describe('relative path', () => { + it('should use collection media_folder if available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: 'path/to/some/other/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(selectMediaFolder(mockConfig, mockCollection, mockBaseEntry, mockImageField)).toBe( + 'path/to/entry/path/to/some/other/media/folder', + ); + }); + + 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( + selectMediaFolder(mockConfig, mockCollection, mockBaseEntry, mockImageField), + ).toBe('path/to/entry/path/to/some/other/media/i-am-a-title'); + }); + + 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(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/entry/path/to/some/other/media/i-am-a-title-fish', + ); + }); + }); + }); + + describe('absolute path', () => { + it('should use collection media_folder if available', () => { + const mockConfig = createMockConfig({ + collections: [ + createMockCollection({ + folder: 'base/folder', + media_folder: '/path/to/some/other/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(selectMediaFolder(mockConfig, mockCollection, mockBaseEntry, mockImageField)).toBe( + 'path/to/some/other/media/folder', + ); + }); + + 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( + selectMediaFolder(mockConfig, mockCollection, mockBaseEntry, mockImageField), + ).toBe('path/to/some/other/media/i-am-a-title'); + }); + + 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(selectMediaFolder(mockConfig, mockCollection, mockEntry, mockImageField)).toBe( + 'path/to/some/other/media/i-am-a-title-fish', + ); + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index aa70166d..af986e95 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -1,5 +1,5 @@ import trim from 'lodash/trim'; -import { dirname, join } from 'path'; +import { dirname } from 'path'; import { basename, isAbsolutePath } from '.'; import { folderFormatter } from '../formatters'; @@ -243,12 +243,15 @@ export function selectMediaFolder( } else if (hasCustomFolder('media_folder', collection, entryMap?.slug, field)) { const folder = evaluateFolder('media_folder', config, collection!, entryMap, field); if (folder.startsWith('/')) { - mediaFolder = join(folder); + mediaFolder = folder.replace(/^[/]*/g, ''); } else { const entryPath = entryMap?.path; mediaFolder = entryPath - ? join(dirname(entryPath), folder) - : join(collection && 'folder' in collection ? collection.folder : '', DRAFT_MEDIA_FILES); + ? joinUrlPath(dirname(entryPath), folder) + : joinUrlPath( + collection && 'folder' in collection ? collection.folder : '', + DRAFT_MEDIA_FILES, + ); } } @@ -289,7 +292,7 @@ export function selectMediaFilePublicPath( return joinUrlPath(selectedPublicFolder, basename(mediaPath)); } - return join(selectedPublicFolder, basename(mediaPath)); + return joinUrlPath(selectedPublicFolder, basename(mediaPath)); } export function selectMediaFilePath( @@ -331,5 +334,5 @@ export function selectMediaFilePath( } } - return join(mediaFolder, basename(mediaPath)); + return joinUrlPath(mediaFolder, basename(mediaPath)); } diff --git a/packages/core/src/lib/widgets/stringTemplate.ts b/packages/core/src/lib/widgets/stringTemplate.ts index e45896f1..ea776611 100644 --- a/packages/core/src/lib/widgets/stringTemplate.ts +++ b/packages/core/src/lib/widgets/stringTemplate.ts @@ -193,6 +193,10 @@ export function compileStringTemplate( data: ObjectValue | undefined | null = {}, processor?: (value: string) => string, ) { + if (template === '') { + return ''; + } + let missingRequiredDate; // Turn off date processing (support for replacements like `{{year}}`), by passing in diff --git a/packages/core/src/widgets/file/__test__/FileControl.spec.ts b/packages/core/src/widgets/file/__test__/FileControl.spec.ts index afe6efad..c67b45a9 100644 --- a/packages/core/src/widgets/file/__test__/FileControl.spec.ts +++ b/packages/core/src/widgets/file/__test__/FileControl.spec.ts @@ -18,9 +18,9 @@ import { mockFileField } from '@staticcms/test/data/fields.mock'; import { createWidgetControlHarness } from '@staticcms/test/harnesses/widget.harness'; import withFileControl from '../withFileControl'; -import type { Config, FileOrImageField, MediaFile } from '@staticcms/core/interface'; +import type { Config, MediaFile } from '@staticcms/core/interface'; -const FileControl = withFileControl(); +jest.mock('@staticcms/core/backend'); jest.mock('@staticcms/core/lib/hooks/useMediaFiles', () => { const mockMediaFiles: MediaFile[] = [ @@ -54,10 +54,11 @@ jest.mock('@staticcms/core/actions/mediaLibrary', () => ({ jest.mock('@staticcms/core/lib/hooks/useMediaAsset', () => (url: string) => url); describe('File Control', () => { + const FileControl = withFileControl(); const collection = createMockCollection({}, mockFileField); const config = createMockConfig({ collections: [collection], - }) as unknown as Config; + }); const mockInsertMedia = insertMedia as jest.Mock; diff --git a/packages/core/src/widgets/markdown/plate/components/balloon-toolbar/__tests__/BalloonToolbar.spec.tsx b/packages/core/src/widgets/markdown/plate/components/balloon-toolbar/__tests__/BalloonToolbar.spec.tsx index 12adb1d1..0e43241e 100644 --- a/packages/core/src/widgets/markdown/plate/components/balloon-toolbar/__tests__/BalloonToolbar.spec.tsx +++ b/packages/core/src/widgets/markdown/plate/components/balloon-toolbar/__tests__/BalloonToolbar.spec.tsx @@ -32,6 +32,8 @@ import type { MdEditor } from '@staticcms/markdown/plate/plateTypes'; import type { TRange } from '@udecode/plate'; import type { FC } from 'react'; +jest.mock('@staticcms/core/backend'); + interface BalloonToolbarWrapperProps { useMdx?: boolean; } diff --git a/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts b/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts index 41fe12b5..9f276d75 100644 --- a/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts +++ b/packages/core/src/widgets/relation/__tests__/RelationControl.spec.ts @@ -27,6 +27,8 @@ import type { StringOrTextField, } from '@staticcms/core/interface'; +jest.mock('@staticcms/core/backend'); + const dateField: DateTimeField = { widget: 'datetime', name: 'date', @@ -72,20 +74,22 @@ const bodyField: StringOrTextField = { name: 'body', }; -const searchCollection = createMockCollection( - { - name: 'posts', - }, - dateField, - authorField, - coAuthorsField, - tagsField, - bodyField, -) as unknown as Collection; - const config = createMockConfig({ - collections: [searchCollection] as unknown as Collection[], -}) as unknown as Config; + collections: [ + createMockCollection( + { + name: 'posts', + }, + dateField, + authorField, + coAuthorsField, + tagsField, + bodyField, + ) as unknown as Collection, + ], +}); + +const searchCollection = config.collections[0]; describe(RelationControl.name, () => { const renderControl = createWidgetControlHarness(RelationControl, { @@ -649,7 +653,7 @@ describe(RelationControl.name, () => { }); describe('parse options', () => { - it('should default to valueField if displayFields is not set', async () => { + fit('should default to valueField if displayFields is not set', async () => { const field: RelationField = { label: 'Relation', name: 'relation', diff --git a/packages/core/test/data/config.mock.ts b/packages/core/test/data/config.mock.ts index 1ca0969c..17e3f818 100644 --- a/packages/core/test/data/config.mock.ts +++ b/packages/core/test/data/config.mock.ts @@ -1,11 +1,14 @@ /* eslint-disable import/prefer-default-export */ +import { applyDefaults } from '@staticcms/core/actions/config'; + import type { BaseField, Config } from '@staticcms/core'; export const createMockConfig = ( options: Omit>, 'collections'> & Pick, 'collections'>, -): Config => ({ - backend: { - name: 'test-repo', - }, - ...options, -}); +): Config => + applyDefaults({ + backend: { + name: 'test-repo', + }, + ...options, + }); diff --git a/packages/core/test/data/widgets.mock.ts b/packages/core/test/data/widgets.mock.ts index 1ed192f0..ce73f6b6 100644 --- a/packages/core/test/data/widgets.mock.ts +++ b/packages/core/test/data/widgets.mock.ts @@ -10,6 +10,8 @@ import type { WidgetControlProps, } from '@staticcms/core'; +jest.mock('@staticcms/core/backend'); + export const createMockWidgetControlProps = < T extends ValueOrNestedValue, F extends BaseField = UnknownField,