Skip to content
Merged
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
14 changes: 13 additions & 1 deletion src/components/CallToActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<template>
<DestinationDataProvider v-if="action" :destination="action" v-slot="{ url, title }">
<ButtonLink
:url="url"
:url="normalizeUrl(url)"
:isDark="isDark"
>
{{ title }}
Expand All @@ -22,13 +22,21 @@

import ButtonLink from 'docc-render/components/ButtonLink.vue';
import DestinationDataProvider from 'docc-render/components/DestinationDataProvider.vue';
import { isAbsoluteUrl } from 'docc-render/utils/url-helper';
import { normalizePath, normalizeRelativePath } from 'docc-render/utils/assets';

export default {
name: 'CallToActionButton',
components: {
DestinationDataProvider,
ButtonLink,
},
methods: {
normalizeUrl(url) {
if (!this.linksToAsset || isAbsoluteUrl(url)) return url;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need to check for special flags or absolute URLs as a special case here? I think the logic should pretty much match up with the logic in components for image and video assets, which always "normalize" the URLs. I think the normalizePath function should already be a noop for absolute URLs, and I don't think there's any special logic for assets vs documents with regards to a custom base path.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the normalizePath function to work, the path needs to be relative. This means that we need to normalize the relative path first, to make sure that the normalizePath function reads it as a relative path.

An example is the simpleRelativePath const that I wrote in the tests, which value is foo/bar:
foo/bar needs to be normalize as a relative path to become /foo/bar instead.
Otherwise normalizePath will think that foo/bar is an absolute URL and it won't prefix the baseUrl.

The reason why I'm exiting early in the case of being a real absolute URL (by using the isAbsoluteUrl function) is that, if we pass an absolute URL to normalizeRelativePath, it will turn it into a relative path like /website.com/samplecode and then normalizePath will prefix that path as baseUrl/website.com/samplecode. We want to prevent this scenario.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative to this is to use the isAbsoluteUrl function within the normalizeRelativePath, so we will never accidentally turn an absolute URL into a relative path.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why I'm exiting early in the case of being a real absolute URL (by using the isAbsoluteUrl function) is that, if we pass an absolute URL to normalizeRelativePath, it will turn it into a relative path like /website.com/samplecode and then normalizePath will prefix that path as baseUrl/website.com/samplecode. We want to prevent this scenario.

I don't think that's an issue for the existing normalizePath function or we would have hit similar issues with external image/video assets where we already call this function on all URLs.

Here's an example unit test showing the expected behavior for this function when called with an absolute URL:

it('does not change, if passed a url', () => {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test is showing the expected behaviour for absolute URLs (like website.com/samplecode), but by normalising the relative path it adds a slash in the URL (like /website.com/samplecode) and we want to prevent this.

This works for images and videos, because we expect the relative path to always be correct. So we don't normalise the relative path.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test is showing the expected behaviour for absolute URLs (like website.com/samplecode), but by normalising the relative path it adds a slash in the URL (like /website.com/samplecode) and we want to prevent this.

An absolute URL should have a scheme like https://website.com/samplecode.

This works for images and videos, because we expect the relative path to always be correct. So we don't normalise the relative path.

We do already normalize relative paths for images/videos in that function by prepending the base URL (example). Absolute URLs with a scheme would be unaffected by this normalizePath function as shown in the above linked unit test.

I think the confusion is that we also have a different normalizeRelativePath function (seen below in those tests) which normalizes relative paths to all be root relative (some/path => /some/path).

We can utilize both here as you've done, although it's slightly unfortunate if we're getting different styles of relative paths for sample code when compared to images/videos in the JSON.

return normalizePath(normalizeRelativePath(url));
},
},
props: {
action: {
type: Object,
Expand All @@ -38,6 +46,10 @@ export default {
type: Boolean,
default: false,
},
linksToAsset: {
type: Boolean,
default: false,
},
},
};
</script>
6 changes: 5 additions & 1 deletion src/components/DocumentationTopic.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@
:content="abstract"
/>
<div v-if="sampleCodeDownload">
<DownloadButton class="sample-download" :action="sampleCodeDownload.action" />
<DownloadButton
class="sample-download"
:action="sampleCodeDownload.action"
linksToAsset
/>
</div>
<Availability
v-if="shouldShowAvailability"
Expand Down
21 changes: 21 additions & 0 deletions src/utils/url-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,24 @@ export function getAbsoluteUrl(path, domainPath = window.location.href) {
export function resolveAbsoluteUrl(path, domainPath) {
return getAbsoluteUrl(path, domainPath).href;
}

/**
* Check if a URL is absolute (has a protocol scheme).
*
* @param {string} url - The URL to check.
* @return {boolean} True if the URL is absolute, false if relative.
*
* @example
* isAbsoluteUrl('https://example.com/path') // true
* isAbsoluteUrl('/relative/path') // false
* isAbsoluteUrl('relative/path') // false
*/
export function isAbsoluteUrl(url) {
try {
// eslint-disable-next-line no-new
new URL(url);
return true;
} catch (e) {
return false;
}
}
81 changes: 63 additions & 18 deletions tests/unit/components/CallToActionButton.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { shallowMount } from '@vue/test-utils';
import { pathJoin } from 'docc-render/utils/assets';
import CallToActionButton from 'docc-render/components/CallToActionButton.vue';

const { ButtonLink, DestinationDataProvider } = CallToActionButton.components;
Expand All @@ -22,40 +23,84 @@ describe('CallToActionButton', () => {
type: 'reference',
},
isDark: true,
linksToAsset: true,
};

const simpleRelativePath = 'foo/bar';
const rootRelativePath = '/foo/bar';
const absolutePath = 'http://example.com/foo/bar';

let wrapper;

const provide = {
const createProvide = references => ({
store: {
state: {
references: {
[propsData.action.identifier]: {
title: 'Foo Bar',
url: '/foo/bar',
},
},
},
state: { references },
},
};
});

const createReferences = ({ url }) => ({
[propsData.action.identifier]: {
title: 'Foo Bar',
url,
},
});

beforeEach(() => {
wrapper = shallowMount(CallToActionButton, {
const createWrapper = ({ provide } = {}) => (
shallowMount(CallToActionButton, {
propsData,
stubs: { DestinationDataProvider },
provide,
});
});
provide: provide || createProvide(createReferences({ url: rootRelativePath })),
})
);

const baseUrl = '/base-prefix';

it('renders a `ButtonLink`', () => {
it('renders a `ButtonLink` with root-relative path', () => {
wrapper = createWrapper();
const btn = wrapper.findComponent(ButtonLink);
expect(btn.exists()).toBe(true);
expect(btn.props('url'))
.toBe(provide.store.state.references[propsData.action.identifier].url);
expect(btn.props('url')).toBe(rootRelativePath);
expect(btn.props('isDark')).toBe(propsData.isDark);
expect(btn.text()).toBe(propsData.action.overridingTitle);
});

it('prefixes `ButtonLink` URL if baseUrl is provided', () => {
window.baseUrl = baseUrl;
wrapper = createWrapper();

const btn = wrapper.findComponent(ButtonLink);
expect(btn.props('url')).toBe(pathJoin([baseUrl, rootRelativePath]));
});

it('prefixes `ButtonLink` URL if baseUrl is provided and path is a simple-relative path', () => {
window.baseUrl = baseUrl;
wrapper = createWrapper({
provide: createProvide(createReferences({ url: simpleRelativePath })),
});

const btn = wrapper.findComponent(ButtonLink);
expect(btn.props('url')).toBe(pathJoin([baseUrl, simpleRelativePath]));
});

it('does not prefixes `ButtonLink` URL if path does not link to asset', async () => {
window.baseUrl = baseUrl;
wrapper = createWrapper();
await wrapper.setProps({
linksToAsset: false,
});

const btn = wrapper.findComponent(ButtonLink);
expect(btn.props('url')).toBe(rootRelativePath);
});

it('does not prefix `ButtonLink` URL if baseUrl is provided but URL is absolute', () => {
window.baseUrl = baseUrl;
wrapper = createWrapper({ provide: createProvide(createReferences({ url: absolutePath })) });
expect(wrapper.findComponent(ButtonLink).props('url')).toBe(absolutePath);
});

it('renders a `DestinationDataProvider`', () => {
wrapper = createWrapper();
const provider = wrapper.findComponent(DestinationDataProvider);
expect(provider.exists()).toBe(true);
expect(provider.props('destination')).toBe(propsData.action);
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/utils/url-helper.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import TechnologiesQueryParams from 'docc-render/constants/TechnologiesQueryPara
let areEquivalentLocations;
let buildUrl;
let resolveAbsoluteUrl;
let isAbsoluteUrl;

const normalizePathMock = jest.fn().mockImplementation(n => n);

Expand All @@ -29,6 +30,7 @@ function importDeps() {
areEquivalentLocations,
buildUrl,
resolveAbsoluteUrl,
isAbsoluteUrl,
// eslint-disable-next-line global-require
} = require('@/utils/url-helper'));
}
Expand Down Expand Up @@ -185,3 +187,35 @@ describe('resolveAbsoluteUrl', () => {
.toBe('https://swift.org/foo/bar');
});
});

describe('isAbsoluteUrl', () => {
beforeEach(() => {
importDeps();
jest.clearAllMocks();
});

it('returns true for absolute URLs', () => {
expect(isAbsoluteUrl('https://example.com')).toBe(true);
expect(isAbsoluteUrl('https://example.com/path')).toBe(true);
});

it('returns true for other protocol schemes', () => {
expect(isAbsoluteUrl('mailto:[email protected]')).toBe(true);
expect(isAbsoluteUrl('tel:+1234567890')).toBe(true);
});

it('returns false for relative paths starting with /', () => {
expect(isAbsoluteUrl('/relative/path')).toBe(false);
expect(isAbsoluteUrl('/')).toBe(false);
});

it('returns false for relative paths not starting with /', () => {
expect(isAbsoluteUrl('relative/path')).toBe(false);
expect(isAbsoluteUrl('./current/path')).toBe(false);
});

it('returns false for empty or invalid URLs', () => {
expect(isAbsoluteUrl('')).toBe(false);
expect(isAbsoluteUrl('not-a-valid-url')).toBe(false);
});
});