Skip to content

Commit 4711908

Browse files
authored
Normalize Sample Code URLs (#979)
We want to normalize Sample Code URLs, by adding the baseUrl to them if the path is relative and the baseUrl is provided. If the path is absolute or the baseUrl is not provided, it does not prefixes the URL. Because the component CallToActionButton.vue is not only being used for Sample Code links, we created a new prop called linksToAsset that will normalize the relative URLs, in the cases that they are assets, like Sample Code. Fixes: #165347857
1 parent 92c5828 commit 4711908

File tree

5 files changed

+136
-20
lines changed

5 files changed

+136
-20
lines changed

src/components/CallToActionButton.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<template>
1212
<DestinationDataProvider v-if="action" :destination="action" v-slot="{ url, title }">
1313
<ButtonLink
14-
:url="url"
14+
:url="normalizeUrl(url)"
1515
:isDark="isDark"
1616
>
1717
{{ title }}
@@ -22,13 +22,21 @@
2222
2323
import ButtonLink from 'docc-render/components/ButtonLink.vue';
2424
import DestinationDataProvider from 'docc-render/components/DestinationDataProvider.vue';
25+
import { isAbsoluteUrl } from 'docc-render/utils/url-helper';
26+
import { normalizePath, normalizeRelativePath } from 'docc-render/utils/assets';
2527
2628
export default {
2729
name: 'CallToActionButton',
2830
components: {
2931
DestinationDataProvider,
3032
ButtonLink,
3133
},
34+
methods: {
35+
normalizeUrl(url) {
36+
if (!this.linksToAsset || isAbsoluteUrl(url)) return url;
37+
return normalizePath(normalizeRelativePath(url));
38+
},
39+
},
3240
props: {
3341
action: {
3442
type: Object,
@@ -38,6 +46,10 @@ export default {
3846
type: Boolean,
3947
default: false,
4048
},
49+
linksToAsset: {
50+
type: Boolean,
51+
default: false,
52+
},
4153
},
4254
};
4355
</script>

src/components/DocumentationTopic.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@
6262
:content="abstract"
6363
/>
6464
<div v-if="sampleCodeDownload">
65-
<DownloadButton class="sample-download" :action="sampleCodeDownload.action" />
65+
<DownloadButton
66+
class="sample-download"
67+
:action="sampleCodeDownload.action"
68+
linksToAsset
69+
/>
6670
</div>
6771
<Availability
6872
v-if="shouldShowAvailability"

src/utils/url-helper.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,24 @@ export function getAbsoluteUrl(path, domainPath = window.location.href) {
108108
export function resolveAbsoluteUrl(path, domainPath) {
109109
return getAbsoluteUrl(path, domainPath).href;
110110
}
111+
112+
/**
113+
* Check if a URL is absolute (has a protocol scheme).
114+
*
115+
* @param {string} url - The URL to check.
116+
* @return {boolean} True if the URL is absolute, false if relative.
117+
*
118+
* @example
119+
* isAbsoluteUrl('https://example.com/path') // true
120+
* isAbsoluteUrl('/relative/path') // false
121+
* isAbsoluteUrl('relative/path') // false
122+
*/
123+
export function isAbsoluteUrl(url) {
124+
try {
125+
// eslint-disable-next-line no-new
126+
new URL(url);
127+
return true;
128+
} catch (e) {
129+
return false;
130+
}
131+
}

tests/unit/components/CallToActionButton.spec.js

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

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

1415
const { ButtonLink, DestinationDataProvider } = CallToActionButton.components;
@@ -22,40 +23,84 @@ describe('CallToActionButton', () => {
2223
type: 'reference',
2324
},
2425
isDark: true,
26+
linksToAsset: true,
2527
};
28+
29+
const simpleRelativePath = 'foo/bar';
30+
const rootRelativePath = '/foo/bar';
31+
const absolutePath = 'http://example.com/foo/bar';
32+
2633
let wrapper;
2734

28-
const provide = {
35+
const createProvide = references => ({
2936
store: {
30-
state: {
31-
references: {
32-
[propsData.action.identifier]: {
33-
title: 'Foo Bar',
34-
url: '/foo/bar',
35-
},
36-
},
37-
},
37+
state: { references },
3838
},
39-
};
39+
});
40+
41+
const createReferences = ({ url }) => ({
42+
[propsData.action.identifier]: {
43+
title: 'Foo Bar',
44+
url,
45+
},
46+
});
4047

41-
beforeEach(() => {
42-
wrapper = shallowMount(CallToActionButton, {
48+
const createWrapper = ({ provide } = {}) => (
49+
shallowMount(CallToActionButton, {
4350
propsData,
4451
stubs: { DestinationDataProvider },
45-
provide,
46-
});
47-
});
52+
provide: provide || createProvide(createReferences({ url: rootRelativePath })),
53+
})
54+
);
55+
56+
const baseUrl = '/base-prefix';
4857

49-
it('renders a `ButtonLink`', () => {
58+
it('renders a `ButtonLink` with root-relative path', () => {
59+
wrapper = createWrapper();
5060
const btn = wrapper.findComponent(ButtonLink);
5161
expect(btn.exists()).toBe(true);
52-
expect(btn.props('url'))
53-
.toBe(provide.store.state.references[propsData.action.identifier].url);
62+
expect(btn.props('url')).toBe(rootRelativePath);
5463
expect(btn.props('isDark')).toBe(propsData.isDark);
5564
expect(btn.text()).toBe(propsData.action.overridingTitle);
5665
});
5766

67+
it('prefixes `ButtonLink` URL if baseUrl is provided', () => {
68+
window.baseUrl = baseUrl;
69+
wrapper = createWrapper();
70+
71+
const btn = wrapper.findComponent(ButtonLink);
72+
expect(btn.props('url')).toBe(pathJoin([baseUrl, rootRelativePath]));
73+
});
74+
75+
it('prefixes `ButtonLink` URL if baseUrl is provided and path is a simple-relative path', () => {
76+
window.baseUrl = baseUrl;
77+
wrapper = createWrapper({
78+
provide: createProvide(createReferences({ url: simpleRelativePath })),
79+
});
80+
81+
const btn = wrapper.findComponent(ButtonLink);
82+
expect(btn.props('url')).toBe(pathJoin([baseUrl, simpleRelativePath]));
83+
});
84+
85+
it('does not prefixes `ButtonLink` URL if path does not link to asset', async () => {
86+
window.baseUrl = baseUrl;
87+
wrapper = createWrapper();
88+
await wrapper.setProps({
89+
linksToAsset: false,
90+
});
91+
92+
const btn = wrapper.findComponent(ButtonLink);
93+
expect(btn.props('url')).toBe(rootRelativePath);
94+
});
95+
96+
it('does not prefix `ButtonLink` URL if baseUrl is provided but URL is absolute', () => {
97+
window.baseUrl = baseUrl;
98+
wrapper = createWrapper({ provide: createProvide(createReferences({ url: absolutePath })) });
99+
expect(wrapper.findComponent(ButtonLink).props('url')).toBe(absolutePath);
100+
});
101+
58102
it('renders a `DestinationDataProvider`', () => {
103+
wrapper = createWrapper();
59104
const provider = wrapper.findComponent(DestinationDataProvider);
60105
expect(provider.exists()).toBe(true);
61106
expect(provider.props('destination')).toBe(propsData.action);

tests/unit/utils/url-helper.spec.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import TechnologiesQueryParams from 'docc-render/constants/TechnologiesQueryPara
1313
let areEquivalentLocations;
1414
let buildUrl;
1515
let resolveAbsoluteUrl;
16+
let isAbsoluteUrl;
1617

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

@@ -29,6 +30,7 @@ function importDeps() {
2930
areEquivalentLocations,
3031
buildUrl,
3132
resolveAbsoluteUrl,
33+
isAbsoluteUrl,
3234
// eslint-disable-next-line global-require
3335
} = require('@/utils/url-helper'));
3436
}
@@ -185,3 +187,35 @@ describe('resolveAbsoluteUrl', () => {
185187
.toBe('https://swift.org/foo/bar');
186188
});
187189
});
190+
191+
describe('isAbsoluteUrl', () => {
192+
beforeEach(() => {
193+
importDeps();
194+
jest.clearAllMocks();
195+
});
196+
197+
it('returns true for absolute URLs', () => {
198+
expect(isAbsoluteUrl('https://example.com')).toBe(true);
199+
expect(isAbsoluteUrl('https://example.com/path')).toBe(true);
200+
});
201+
202+
it('returns true for other protocol schemes', () => {
203+
expect(isAbsoluteUrl('mailto:[email protected]')).toBe(true);
204+
expect(isAbsoluteUrl('tel:+1234567890')).toBe(true);
205+
});
206+
207+
it('returns false for relative paths starting with /', () => {
208+
expect(isAbsoluteUrl('/relative/path')).toBe(false);
209+
expect(isAbsoluteUrl('/')).toBe(false);
210+
});
211+
212+
it('returns false for relative paths not starting with /', () => {
213+
expect(isAbsoluteUrl('relative/path')).toBe(false);
214+
expect(isAbsoluteUrl('./current/path')).toBe(false);
215+
});
216+
217+
it('returns false for empty or invalid URLs', () => {
218+
expect(isAbsoluteUrl('')).toBe(false);
219+
expect(isAbsoluteUrl('not-a-valid-url')).toBe(false);
220+
});
221+
});

0 commit comments

Comments
 (0)