Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow special characters to pass through slugification #6220

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ publish_mode: editorial_workflow
media_folder: assets/uploads

collections: # A list of collections the CMS should be able to edit
- name: 'nested'
label: 'Nested Pages'
label_singular: 'Nested Page'
folder: '_nested'
preview_path: '{{slug}}'
nested: {depth: 100}
meta:
path: { widget: string, label: 'Path', index_file: 'index' }
create: true
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Body', name: 'body', widget: 'markdown' }
- name: 'slug-field'
label: 'Slug Pages'
label_singular: 'Slug Page'
folder: '_slugs'
slug: '{{fields.slug}}'
preview_path: '{{fields.slug}}'
preview_preserve_slash: true
create: true
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Slug', name: 'slug', widget: 'string' }
- { label: 'Body', name: 'body', widget: 'markdown' }
- name: 'posts' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'Posts' # Used in the UI
label_singular: 'Post' # Used in the UI, ie: "New Post"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ describe('sanitizeSlug', () => {
'test_test',
);
});

it('preserves slashes if when requested', () => {
const input = '/this-is-a/nested/page';

expect(sanitizeSlug(input, slugConfig, false)).toEqual('this-is-a-nested-page');
expect(sanitizeSlug(input, slugConfig, true)).toEqual('this-is-a/nested/page');
});
});

describe('sanitizeChar', () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/netlify-cms-core/src/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,19 @@ export function prepareSlug(slug: string) {
);
}

export function getProcessSegment(slugConfig?: CmsSlug, ignoreValues?: string[]) {
export function getProcessSegment(
slugConfig?: CmsSlug,
ignoreValues?: string[],
preserveSlash?: boolean,
) {
return (value: string) =>
ignoreValues && ignoreValues.includes(value)
? value
: flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value);
: flow([
value => String(value),
prepareSlug,
partialRight(sanitizeSlug, slugConfig, preserveSlash),
])(value);
}

export function slugFormatter(
Expand Down Expand Up @@ -185,10 +193,11 @@ export function previewUrlFormatter(
fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder'));
const dateFieldName = getDateField() || selectInferedField(collection, 'date');
const date = parseDateFromEntry(entry as unknown as Map<string, unknown>, dateFieldName);
const preserveSlash = collection.has('nested') || !!collection.get('preview_preserve_slash');

// Prepare and sanitize slug variables only, leave the rest of the
// `preview_path` template as is.
const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')]);
const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')], preserveSlash);
let compiledPath;

try {
Expand Down
37 changes: 27 additions & 10 deletions packages/netlify-cms-core/src/lib/urlHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import url from 'url';
import urlJoin from 'url-join';
import diacritics from 'diacritics';
import sanitizeFilename from 'sanitize-filename';
import { isString, escapeRegExp, flow, partialRight } from 'lodash';
import { isString, escapeRegExp, flow, partialRight, identity } from 'lodash';

import type { CmsSlug } from '../types/redux';

Expand Down Expand Up @@ -48,7 +48,14 @@ function validIRIChar(char: string) {
return uriChars.test(char) || ucsChars.test(char);
}

export function getCharReplacer(encoding: string, replacement: string) {
export function getCharReplacer(
encoding: string,
options: {
replacement: NonNullable<CmsSlug['sanitize_replacement']>;
preserveSlash?: boolean;
},
) {
const { replacement, preserveSlash } = options;
let validChar: (char: string) => boolean;

if (encoding === 'unicode') {
Expand All @@ -64,14 +71,24 @@ export function getCharReplacer(encoding: string, replacement: string) {
throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
}

return (char: string) => (validChar(char) ? char : replacement);
return (char: string, i = 0, arr: string[] = [char]) => {
if (preserveSlash && char === '/' && i !== 0 && i !== arr.length - 1) {
return char;
}

return validChar(char) ? char : replacement;
};
}
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
export function sanitizeURI(
str: string,
options?: { replacement: CmsSlug['sanitize_replacement']; encoding: CmsSlug['encoding'] },
options?: {
replacement: CmsSlug['sanitize_replacement'];
encoding: CmsSlug['encoding'];
preserveSlash?: boolean;
},
) {
const { replacement = '', encoding = 'unicode' } = options || {};
const { replacement = '', encoding = 'unicode', preserveSlash } = options || {};

if (!isString(str)) {
throw new Error('The input slug must be a string.');
Expand All @@ -82,15 +99,15 @@ export function sanitizeURI(

// `Array.from` must be used instead of `String.split` because
// `split` converts things like emojis into UTF-16 surrogate pairs.
return Array.from(str).map(getCharReplacer(encoding, replacement)).join('');
return Array.from(str).map(getCharReplacer(encoding, { replacement, preserveSlash })).join('');
}

export function sanitizeChar(char: string, options?: CmsSlug) {
const { encoding = 'unicode', sanitize_replacement: replacement = '' } = options || {};
return getCharReplacer(encoding, replacement)(char);
return getCharReplacer(encoding, { replacement })(char);
}

export function sanitizeSlug(str: string, options?: CmsSlug) {
export function sanitizeSlug(str: string, options?: CmsSlug, preserveSlash?: boolean) {
if (!isString(str)) {
throw new Error('The input slug must be a string.');
}
Expand All @@ -103,8 +120,8 @@ export function sanitizeSlug(str: string, options?: CmsSlug) {

const sanitizedSlug = flow([
...(stripDiacritics ? [diacritics.remove] : []),
partialRight(sanitizeURI, { replacement, encoding }),
partialRight(sanitizeFilename, { replacement }),
partialRight(sanitizeURI, { replacement, encoding, preserveSlash }),
preserveSlash ? identity : partialRight(sanitizeFilename, { replacement }),
])(str);

// Remove any doubled or leading/trailing replacement characters (that were added in the sanitizers).
Expand Down
1 change: 1 addition & 0 deletions packages/netlify-cms-core/src/types/redux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ type CollectionObject = {
public_folder?: string;
preview_path?: string;
preview_path_date_field?: string;
preview_preserve_slash?: boolean;
summary?: string;
filter?: FilterRule;
type: 'file_based_collection' | 'folder_based_collection';
Expand Down
6 changes: 3 additions & 3 deletions website/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10252,9 +10252,9 @@ pretty-format@^25.5.0:
react-is "^16.12.0"

prismjs@^1.21.0:
version "1.27.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057"
integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==
version "1.26.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.26.0.tgz#16881b594828bb6b45296083a8cbab46b0accd47"
integrity sha512-HUoH9C5Z3jKkl3UunCyiD5jwk0+Hz0fIgQ2nbwU2Oo/ceuTAQAg+pPVnfdt2TJWRVLcxKh9iuoYDUSc8clb5UQ==

process-nextick-args@~2.0.0:
version "2.0.1"
Expand Down
Loading