Skip to content
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
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;
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);
});
});