fix: slug in media paths (#793)
This commit is contained in:
parent
f96bb026d9
commit
d28c43e95a
36
packages/core/src/__mocks__/backend.ts
Normal file
36
packages/core/src/__mocks__/backend.ts
Normal file
@ -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);
|
||||
});
|
@ -1,3 +1,3 @@
|
||||
export default function cleanStack(parts: string[]) {
|
||||
export default function urlJoin(...parts: string[]) {
|
||||
return parts.join('/');
|
||||
}
|
||||
|
@ -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<EF extends BaseField = UnknownField>(
|
||||
originalConfig: Config<EF>,
|
||||
): Config<EF> {
|
||||
return produce(originalConfig, (config: Config) => {
|
||||
config.slug = config.slug || {};
|
||||
config.collections = config.collections || [];
|
||||
|
@ -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<string, Set<string>> = {};
|
||||
|
@ -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<EF extends BaseField = UnknownField>(
|
||||
collection: Collection<EF>,
|
||||
entryData: EntryData,
|
||||
slugConfig?: Slug,
|
||||
) {
|
||||
const slugTemplate = collection.slug || '{{slug}}';
|
||||
|
||||
const identifier = get(entryData, keyToPathArray(selectIdentifier(collection)));
|
||||
@ -151,16 +161,10 @@ export function folderFormatter<EF extends BaseField>(
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
245
packages/core/src/lib/util/__tests__/media.util.spec.ts
Normal file
245
packages/core/src/lib/util/__tests__/media.util.spec.ts
Normal file
@ -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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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<UnknownField>({
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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<EF extends BaseField>(
|
||||
} 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<EF extends BaseField>(
|
||||
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));
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<FileOrImageField>;
|
||||
});
|
||||
|
||||
const mockInsertMedia = insertMedia as jest.Mock;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<RelationField>;
|
||||
collections: [
|
||||
createMockCollection(
|
||||
{
|
||||
name: 'posts',
|
||||
},
|
||||
dateField,
|
||||
authorField,
|
||||
coAuthorsField,
|
||||
tagsField,
|
||||
bodyField,
|
||||
) as unknown as Collection<RelationField>,
|
||||
],
|
||||
});
|
||||
|
||||
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',
|
||||
|
@ -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 = <EF extends BaseField>(
|
||||
options: Omit<Partial<Config<EF>>, 'collections'> & Pick<Config<EF>, 'collections'>,
|
||||
): Config<EF> => ({
|
||||
backend: {
|
||||
name: 'test-repo',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
): Config<EF> =>
|
||||
applyDefaults({
|
||||
backend: {
|
||||
name: 'test-repo',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user