mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
🐛 Fixed navigations links for Ghost sites hosted on a subdirectory (#21071)
ref https://linear.app/tryghost/issue/ENG-1570 - for a Ghost site hosted on a subdirectory, e.g. `/blog/`, adding a navigation link to `/blog/page/` was being re-written as `/page/` in Admin settings - fixed the underlying `formatUrl` utility function and added unit tests
This commit is contained in:
parent
9093ffbf98
commit
577362aabf
4 changed files with 172 additions and 92 deletions
|
@ -1,97 +1,7 @@
|
|||
import React, {useEffect, useState} from 'react';
|
||||
import isEmail from 'validator/es/lib/isEmail';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
import TextField, {TextFieldProps} from './TextField';
|
||||
|
||||
export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => {
|
||||
if (nullable && !value) {
|
||||
return {save: null, display: ''};
|
||||
}
|
||||
|
||||
let url = value.trim();
|
||||
|
||||
if (!url) {
|
||||
if (baseUrl) {
|
||||
return {save: '/', display: baseUrl};
|
||||
}
|
||||
return {save: '', display: ''};
|
||||
}
|
||||
|
||||
// if we have an email address, add the mailto:
|
||||
if (isEmail(url)) {
|
||||
return {save: `mailto:${url}`, display: `mailto:${url}`};
|
||||
}
|
||||
|
||||
const isAnchorLink = url.match(/^#/);
|
||||
|
||||
if (isAnchorLink) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
// Absolute URL with no base URL
|
||||
if (!url.startsWith('http')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc
|
||||
if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url, baseUrl);
|
||||
} catch (e) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return {save: parsedUrl.toString(), display: parsedUrl.toString()};
|
||||
}
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
|
||||
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
|
||||
|
||||
// if our path is only missing a trailing / mark it as relative
|
||||
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
|
||||
isRelativeToBasePath = true;
|
||||
}
|
||||
|
||||
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
|
||||
|
||||
// if relative to baseUrl, remove the base url before sending to action
|
||||
if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) {
|
||||
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
|
||||
url = url.replace(/^\/\//, '');
|
||||
url = url.replace(parsedBaseUrl.host, '');
|
||||
url = url.replace(parsedBaseUrl.pathname, '');
|
||||
|
||||
// handle case where url path is same as baseUrl path but missing trailing slash
|
||||
if (parsedUrl.pathname.slice(-1) !== '/') {
|
||||
url = url.replace(parsedBaseUrl.pathname.slice(0, -1), '');
|
||||
}
|
||||
|
||||
if (!url.match(/^\//)) {
|
||||
url = `/${url}`;
|
||||
}
|
||||
|
||||
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
|
||||
url = `${url}/`;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.match(/^(\/\/|#)/)) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
// we update with the relative URL but then transform it back to absolute
|
||||
// for the input value. This avoids problems where the underlying relative
|
||||
// value hasn't changed even though the input value has
|
||||
return {save: url, display: new URL(url, baseUrl).toString()};
|
||||
};
|
||||
import {formatUrl} from '../../utils/formatUrl';
|
||||
|
||||
export interface URLTextFieldProps extends Omit<TextFieldProps, 'value' | 'onChange'> {
|
||||
baseUrl?: string;
|
||||
|
|
|
@ -50,7 +50,7 @@ export {default as Toggle} from './global/form/Toggle';
|
|||
export type {ToggleProps} from './global/form/Toggle';
|
||||
export {default as ToggleGroup} from './global/form/ToggleGroup';
|
||||
export type {ToggleGroupProps} from './global/form/ToggleGroup';
|
||||
export {default as URLTextField, formatUrl} from './global/form/URLTextField';
|
||||
export {default as URLTextField} from './global/form/URLTextField';
|
||||
export type {URLTextFieldProps} from './global/form/URLTextField';
|
||||
|
||||
export {default as ConfirmationModal, ConfirmationModalContent} from './global/modal/ConfirmationModal';
|
||||
|
@ -166,6 +166,7 @@ export {default as useSortableIndexedList} from './hooks/useSortableIndexedList'
|
|||
|
||||
export {debounce} from './utils/debounce';
|
||||
export {confirmIfDirty} from './utils/modals';
|
||||
export {formatUrl} from './utils/formatUrl';
|
||||
|
||||
export {default as DesignSystemApp} from './DesignSystemApp';
|
||||
export type {DesignSystemAppProps} from './DesignSystemApp';
|
||||
|
|
100
apps/admin-x-design-system/src/utils/formatUrl.ts
Normal file
100
apps/admin-x-design-system/src/utils/formatUrl.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import isEmail from 'validator/es/lib/isEmail';
|
||||
|
||||
export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => {
|
||||
if (nullable && !value) {
|
||||
return {save: null, display: ''};
|
||||
}
|
||||
|
||||
let url = value.trim();
|
||||
|
||||
if (!url) {
|
||||
if (baseUrl) {
|
||||
return {save: '/', display: baseUrl};
|
||||
}
|
||||
return {save: '', display: ''};
|
||||
}
|
||||
|
||||
// if we have an email address, add the mailto:
|
||||
if (isEmail(url)) {
|
||||
return {save: `mailto:${url}`, display: `mailto:${url}`};
|
||||
}
|
||||
|
||||
const isAnchorLink = url.match(/^#/);
|
||||
if (isAnchorLink) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
const isProtocolRelative = url.match(/^(\/\/)/);
|
||||
if (isProtocolRelative) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
// Absolute URL with no base URL
|
||||
if (!url.startsWith('http')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc
|
||||
if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url, baseUrl);
|
||||
} catch (e) {
|
||||
return {save: url, display: url};
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return {save: parsedUrl.toString(), display: parsedUrl.toString()};
|
||||
}
|
||||
const parsedBaseUrl = new URL(baseUrl);
|
||||
|
||||
let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0;
|
||||
|
||||
// if our path is only missing a trailing / mark it as relative
|
||||
if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) {
|
||||
isRelativeToBasePath = true;
|
||||
}
|
||||
|
||||
const isOnSameHost = parsedUrl.host === parsedBaseUrl.host;
|
||||
|
||||
// if relative to baseUrl, remove the base url before sending to action
|
||||
if (isOnSameHost && isRelativeToBasePath) {
|
||||
url = url.replace(/^[a-zA-Z0-9-]+:/, '');
|
||||
url = url.replace(/^\/\//, '');
|
||||
url = url.replace(parsedBaseUrl.host, '');
|
||||
url = url.replace(parsedBaseUrl.pathname, '');
|
||||
|
||||
if (!url.match(/^\//)) {
|
||||
url = `/${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.match(/\/$/) && !url.match(/[.#?]/)) {
|
||||
url = `${url}/`;
|
||||
}
|
||||
|
||||
// we update with the relative URL but then transform it back to absolute
|
||||
// for the input value. This avoids problems where the underlying relative
|
||||
// value hasn't changed even though the input value has
|
||||
return {save: url, display: displayFromBase(url, baseUrl)};
|
||||
};
|
||||
|
||||
const displayFromBase = (url: string, baseUrl: string) => {
|
||||
// Ensure base url has a trailing slash
|
||||
if (!baseUrl.endsWith('/')) {
|
||||
baseUrl += '/';
|
||||
}
|
||||
|
||||
// Remove leading slash from url
|
||||
if (url.startsWith('/')) {
|
||||
url = url.substring(1);
|
||||
}
|
||||
|
||||
return new URL(url, baseUrl).toString();
|
||||
};
|
69
apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts
Normal file
69
apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import * as assert from 'assert/strict';
|
||||
import {formatUrl} from '../../../src/utils/formatUrl';
|
||||
|
||||
describe('formatUrl', function () {
|
||||
it('displays empty string if the input is empty and nullable is true', function () {
|
||||
const formattedUrl = formatUrl('', undefined, true);
|
||||
assert.deepEqual(formattedUrl, {save: null, display: ''});
|
||||
});
|
||||
|
||||
it('displays empty string value if the input has only whitespace', function () {
|
||||
const formattedUrl = formatUrl('');
|
||||
assert.deepEqual(formattedUrl, {save: '', display: ''});
|
||||
});
|
||||
|
||||
it('displays base value if the input has only whitespace and base url is available', function () {
|
||||
const formattedUrl = formatUrl('', 'http://example.com');
|
||||
assert.deepEqual(formattedUrl, {save: '/', display: 'http://example.com'});
|
||||
});
|
||||
|
||||
it('displays a mailto address for an email address', function () {
|
||||
const formattedUrl = formatUrl('test@example.com');
|
||||
assert.deepEqual(formattedUrl, {save: 'mailto:test@example.com', display: 'mailto:test@example.com'});
|
||||
});
|
||||
|
||||
it('displays an anchor link without formatting', function () {
|
||||
const formattedUrl = formatUrl('#section');
|
||||
assert.deepEqual(formattedUrl, {save: '#section', display: '#section'});
|
||||
});
|
||||
|
||||
it('displays a protocol-relative link without formatting', function () {
|
||||
const formattedUrl = formatUrl('//example.com');
|
||||
assert.deepEqual(formattedUrl, {save: '//example.com', display: '//example.com'});
|
||||
});
|
||||
|
||||
it('adds https:// automatically', function () {
|
||||
const formattedUrl = formatUrl('example.com');
|
||||
assert.deepEqual(formattedUrl, {save: 'https://example.com/', display: 'https://example.com/'});
|
||||
});
|
||||
|
||||
it('saves a relative URL if the input is a pathname', function () {
|
||||
const formattedUrl = formatUrl('/path', 'http://example.com');
|
||||
assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'});
|
||||
});
|
||||
|
||||
it('saves a relative URL if the input is a pathname, even if the base url has an non-empty pathname', function () {
|
||||
const formattedUrl = formatUrl('/path', 'http://example.com/blog');
|
||||
assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'});
|
||||
});
|
||||
|
||||
it('saves a relative URL if the input includes the base url', function () {
|
||||
const formattedUrl = formatUrl('http://example.com/path', 'http://example.com');
|
||||
assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/path/'});
|
||||
});
|
||||
|
||||
it('saves a relative URL if the input includes the base url, even if the base url has an non-empty pathname', function () {
|
||||
const formattedUrl = formatUrl('http://example.com/blog/path', 'http://example.com/blog');
|
||||
assert.deepEqual(formattedUrl, {save: '/path/', display: 'http://example.com/blog/path/'});
|
||||
});
|
||||
|
||||
it('saves an absolute URL if the input has a different pathname to the base url', function () {
|
||||
const formattedUrl = formatUrl('http://example.com/path', 'http://example.com/blog');
|
||||
assert.deepEqual(formattedUrl, {save: 'http://example.com/path', display: 'http://example.com/path'});
|
||||
});
|
||||
|
||||
it('saves an absolte URL if the input has a different hostname to the base url', function () {
|
||||
const formattedUrl = formatUrl('http://another.com/path', 'http://example.com');
|
||||
assert.deepEqual(formattedUrl, {save: 'http://another.com/path', display: 'http://another.com/path'});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue