fix: slug in media paths (#793)

This commit is contained in:
Daniel Lautzenheiser 2023-05-10 09:09:36 -04:00 committed by GitHub
parent f96bb026d9
commit d28c43e95a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 358 additions and 47 deletions

View 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);
});

View File

@ -1,3 +1,3 @@
export default function cleanStack(parts: string[]) {
export default function urlJoin(...parts: string[]) {
return parts.join('/');
}

View File

@ -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 || [];

View File

@ -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>> = {};

View File

@ -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;
}

View 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',
);
});
});
});
});
});
});

View File

@ -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));
}

View File

@ -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

View File

@ -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;

View File

@ -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;
}

View File

@ -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',

View File

@ -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,
});

View File

@ -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,