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
76 changes: 75 additions & 1 deletion spec/util/urlUtils-spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const { constructSheetUrl, getDocumentOrSheetId, getSheetName } = require('../../src/util/urlUtils')
const {
constructSheetUrl,
getDocumentOrSheetId,
getSheetName,
convertRelativeUrlsToAbsolute,
} = require('../../src/util/urlUtils')
const queryParams = require('../../src/util/queryParamProcessor')

jest.mock('../../src/util/queryParamProcessor')
Expand Down Expand Up @@ -74,4 +79,73 @@ describe('Url Utils', () => {

expect(sheetName).toEqual('sheetName')
})

describe('convertRelativeUrlsToAbsolute', () => {
beforeEach(() => {
delete window.location
window.location = Object.create(window)
window.location.origin = 'https://radar.thoughtworks.com'
window.location.protocol = 'https:'
})

it('should return empty string for empty input', () => {
expect(convertRelativeUrlsToAbsolute('')).toEqual('')
})

it('should return null for null input', () => {
expect(convertRelativeUrlsToAbsolute(null)).toEqual(null)
})

it('should return undefined for undefined input', () => {
expect(convertRelativeUrlsToAbsolute(undefined)).toEqual(undefined)
})

it('should not modify absolute URLs', () => {
const html = '<a href="https://example.com/page">Link</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(html)
})
Comment on lines +103 to +106

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Consider adding a test case for http:// URLs (without the 's') to ensure the regex properly handles both HTTP and HTTPS protocols. While the regex pattern https? correctly handles both, an explicit test would make this behavior clearer and prevent regressions.

Copilot uses AI. Check for mistakes.

it('should not modify mailto links', () => {
const html = '<a href="mailto:test@example.com">Email</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(html)
})

it('should not modify tel links', () => {
const html = '<a href="tel:+1234567890">Call</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(html)
})

it('should convert root-relative URLs to absolute', () => {
const html = '<a href="/radar/Techniques/continuous-delivery">CD</a>'
const expected = '<a href="https://radar.thoughtworks.com/radar/Techniques/continuous-delivery">CD</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(expected)
})

it('should convert relative URLs to absolute', () => {
const html = '<a href="page/subpage">Link</a>'
const expected = '<a href="https://radar.thoughtworks.com/page/subpage">Link</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(expected)
})

it('should convert protocol-relative URLs', () => {
const html = '<a href="//example.com/page">Link</a>'
const expected = '<a href="https://example.com/page">Link</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(expected)
})

it('should handle multiple links in the same HTML', () => {
const html =
'<p>Check out <a href="/radar/Techniques/cd">CD</a> and <a href="https://example.com">Example</a></p>'
const expected =
'<p>Check out <a href="https://radar.thoughtworks.com/radar/Techniques/cd">CD</a> and <a href="https://example.com">Example</a></p>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(expected)
})

it('should handle single quotes in href', () => {
const html = "<a href='/radar/page'>Link</a>"
// Note: the regex normalizes quotes to double quotes
const expected = '<a href="https://radar.thoughtworks.com/radar/page">Link</a>'
expect(convertRelativeUrlsToAbsolute(html)).toEqual(expected)
})
})

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Consider adding test cases for URLs with query parameters and fragments to ensure they are handled correctly. For example:

  • <a href="/path?query=value">Link</a>
  • <a href="/path#section">Link</a>
  • <a href="/path?query=value&other=param#section">Link</a>

These are common URL patterns that should be verified to work correctly with the regex.

Copilot uses AI. Check for mistakes.
})
3 changes: 2 additions & 1 deletion src/graphing/components/quadrantTables.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const d3 = require('d3')
const { graphConfig, getScale, uiConfig } = require('../config')
const { stickQuadrantOnScroll } = require('./quadrants')
const { removeAllSpaces } = require('../../util/stringUtil')
const { convertRelativeUrlsToAbsolute } = require('../../util/urlUtils')

function fadeOutAllBlips() {
d3.selectAll('g > a.blip-link').attr('opacity', 0.3)
Expand Down Expand Up @@ -67,7 +68,7 @@ function renderBlipDescription(blip, ring, quadrant, tip, groupBlipTooltipText)
.append('div')
.classed('blip-list__item-container__description', true)
.attr('id', `blip-description-${blip.id()}`)
.html(blip.description())
.html(convertRelativeUrlsToAbsolute(blip.description()))
}
const blipGraphItem = d3.select(`g a#blip-link-${removeAllSpaces(blip.id())}`)
const mouseOver = function (e) {
Expand Down
3 changes: 2 additions & 1 deletion src/graphing/radar.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config = require('../config')
const featureToggles = config().featureToggles
const { plotRadarBlips } = require('./blips')
const { graphConfig, getGraphSize } = require('./config')
const { convertRelativeUrlsToAbsolute } = require('../util/urlUtils')

const { renderBanner } = require('./components/banner')
const { renderQuadrantSubnav } = require('./components/quadrantSubnav')
Expand Down Expand Up @@ -364,7 +365,7 @@ const Radar = function (size, radar) {
.attr('id', 'blip-description-' + blip.id())
.attr('class', 'blip-item-description')
if (blip.description()) {
blipItemDescription.append('p').html(blip.description())
blipItemDescription.append('p').html(convertRelativeUrlsToAbsolute(blip.description()))
}

var mouseOver = function () {
Expand Down
31 changes: 31 additions & 0 deletions src/util/urlUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,39 @@ function getSheetName() {
return queryParams.sheetName
}

/**
* Converts relative URLs in HTML content to absolute URLs.
* This is useful for PDF printing where relative URLs like "/radar/..."
* need to show the full URL.
* @param {string} html - HTML content potentially containing relative URLs
* @returns {string} HTML with relative URLs converted to absolute
*/
function convertRelativeUrlsToAbsolute(html) {
if (!html) return html

const baseUrl = window.location.origin

// Convert relative href attributes to absolute URLs
return html.replace(
/href=["'](?!https?:\/\/|mailto:|tel:)([^"']+)["']/gi,

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

As a defense-in-depth measure, consider adding other URL schemes to the negative lookahead pattern: (?!https?:\/\/|mailto:|tel:|javascript:|data:). While the sanitize-html library already filters dangerous schemes like javascript: and data:, explicitly excluding them in the URL conversion function provides an additional layer of protection and makes the security intent more explicit in the code.

Suggested change
/href=["'](?!https?:\/\/|mailto:|tel:)([^"']+)["']/gi,
/href=["'](?!https?:\/\/|mailto:|tel:|javascript:|data:)([^"']+)["']/gi,

Copilot uses AI. Check for mistakes.
(match, relativeUrl) => {
// Handle protocol-relative URLs (//example.com)
if (relativeUrl.startsWith('//')) {
return `href="${window.location.protocol}${relativeUrl}"`
}
// Handle root-relative URLs (/path)
if (relativeUrl.startsWith('/')) {
return `href="${baseUrl}${relativeUrl}"`
}
// Handle relative URLs (path or ./path)
return `href="${baseUrl}/${relativeUrl.replace(/^\.\//, '')}"`
}
)
}

module.exports = {
constructSheetUrl,
getDocumentOrSheetId,
getSheetName,
convertRelativeUrlsToAbsolute,
}
Loading