forked from TryGhost/Ghost
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🐛 Fixed navigations links for Ghost sites hosted on a subdirectory (T…
…ryGhost#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
- Loading branch information
Showing
4 changed files
with
172 additions
and
92 deletions.
There are no files selected for viewing
92 changes: 1 addition & 91 deletions
92
apps/admin-x-design-system/src/global/form/URLTextField.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 changes: 69 additions & 0 deletions
69
apps/admin-x-design-system/test/unit/utils/formatUrl.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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('[email protected]'); | ||
assert.deepEqual(formattedUrl, {save: 'mailto:[email protected]', display: 'mailto:[email protected]'}); | ||
}); | ||
|
||
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'}); | ||
}); | ||
}); |