0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-04 02:01:58 -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:
Sag 2024-09-23 18:37:05 +02:00 committed by GitHub
parent 9093ffbf98
commit 577362aabf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 172 additions and 92 deletions

View file

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

View file

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

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

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