diff --git a/packages/cli-kit/src/private/node/ui/components/Alert.test.tsx b/packages/cli-kit/src/private/node/ui/components/Alert.test.tsx
index 4c44ae594ee..8922a03325b 100644
--- a/packages/cli-kit/src/private/node/ui/components/Alert.test.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/Alert.test.tsx
@@ -126,6 +126,33 @@ describe('Alert', async () => {
`)
})
+ test('auto-detects URLs in a plain-string body and renders them as footnotes', async () => {
+ const options = {
+ body: 'See specification requirements: https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties',
+ }
+
+ const {lastFrame} = render()
+ const frame = unstyled(lastFrame()!)
+
+ expect(frame).toMatchInlineSnapshot(`
+ "╭─ error ──────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ See specification requirements: [1] │
+ │ │
+ ╰──────────────────────────────────────────────────────────────────────────────╯
+ [1]
+ https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties
+ "
+ `)
+
+ const footnoteLines = frame.split('\n').filter((line) => line.includes('[1]'))
+ footnoteLines.forEach((line) => {
+ if (line.startsWith('[1]')) {
+ expect(line).not.toContain('│')
+ }
+ })
+ })
+
test('has the headline in bold', async () => {
const options = {
headline: 'Title.',
diff --git a/packages/cli-kit/src/private/node/ui/components/FatalError.test.tsx b/packages/cli-kit/src/private/node/ui/components/FatalError.test.tsx
index 51721c0a06d..9d5745766c5 100644
--- a/packages/cli-kit/src/private/node/ui/components/FatalError.test.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/FatalError.test.tsx
@@ -23,6 +23,43 @@ describe('FatalError', async () => {
`)
})
+ test('auto-detects URLs in a plain-string error message and renders them as footnotes', async () => {
+ const error = new AbortError(
+ 'See specification requirements: https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties',
+ )
+
+ const {lastFrame} = render()
+ const frame = unstyled(lastFrame()!)
+
+ expect(frame).toMatchInlineSnapshot(`
+ "╭─ error ──────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ See specification requirements: [1] │
+ │ │
+ ╰──────────────────────────────────────────────────────────────────────────────╯
+ [1]
+ https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties
+ "
+ `)
+ })
+
+ test('does not linkify user-supplied invalid URLs or placeholder URLs in error/tryMessage', async () => {
+ const error = new AbortError(`Invalid tunnel URL: https://asda`, `Valid format: "https://my-tunnel-url:port"`)
+
+ const {lastFrame} = render()
+
+ expect(unstyled(lastFrame()!)).toMatchInlineSnapshot(`
+ "╭─ error ──────────────────────────────────────────────────────────────────────╮
+ │ │
+ │ Invalid tunnel URL: https://asda │
+ │ │
+ │ Valid format: "https://my-tunnel-url:port" │
+ │ │
+ ╰──────────────────────────────────────────────────────────────────────────────╯
+ "
+ `)
+ })
+
test('renders correctly with a formatted message', async () => {
const error = new AbortError([
'There has been an error creating your deployment:',
diff --git a/packages/cli-kit/src/private/node/ui/components/FatalError.tsx b/packages/cli-kit/src/private/node/ui/components/FatalError.tsx
index f8876fab6ea..7dbde0f2295 100644
--- a/packages/cli-kit/src/private/node/ui/components/FatalError.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/FatalError.tsx
@@ -48,7 +48,11 @@ const FatalError: FunctionComponent = ({error}) => {
) : null}
- {error.formattedMessage ? : {error.message}}
+ {error.formattedMessage ? (
+
+ ) : (
+
+ )}
{error.tryMessage ? : null}
diff --git a/packages/cli-kit/src/private/node/ui/components/Link.test.tsx b/packages/cli-kit/src/private/node/ui/components/Link.test.tsx
index 00fb8a8b7c0..d4ceb81f3a5 100644
--- a/packages/cli-kit/src/private/node/ui/components/Link.test.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/Link.test.tsx
@@ -1,11 +1,34 @@
import {Link} from './Link.js'
+import {LinksContext, Link as LinkEntry} from '../contexts/LinksContext.js'
import {render} from '../../testing/ui.js'
import {describe, expect, test, vi} from 'vitest'
-import React from 'react'
+import React, {FunctionComponent, useRef} from 'react'
import supportsHyperlinks from 'supports-hyperlinks'
vi.mock('supports-hyperlinks')
+const WithLinksContext: FunctionComponent<{
+ children: React.ReactNode
+ addLinkSpy?: (label: string | undefined, url: string) => void
+}> = ({children, addLinkSpy}) => {
+ const links = useRef>({})
+ return (
+ {
+ addLinkSpy?.(label, url)
+ const newId = (Object.keys(links.current).length + 1).toString()
+ links.current = {...links.current, [newId]: {label, url}}
+ return newId
+ },
+ }}
+ >
+ {children}
+
+ )
+}
+
describe('Link', async () => {
test("renders correctly with a fallback for terminals that don't support hyperlinks", async () => {
// Given
@@ -100,6 +123,58 @@ describe('Link', async () => {
expect(lastFrame()).toMatchInlineSnapshot('"https://example.com"')
})
+ describe('inside a LinksContext (Banner)', async () => {
+ test('renders just `[N]` (not `url [N]`) for label-less links when the terminal does not support hyperlinks', async () => {
+ supportHyperLinks(false)
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe('[1]')
+ })
+
+ test('uses the footnote mechanism for labeled links when the terminal does not support hyperlinks', async () => {
+ supportHyperLinks(false)
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe('Example [1]')
+ })
+
+ test('registers label-less links in context so the footnote mechanism is always used', async () => {
+ supportHyperLinks(false)
+ const addLinkSpy = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ expect(addLinkSpy).toHaveBeenCalledWith(undefined, 'https://example.com')
+ })
+
+ test('registers labeled links in context', async () => {
+ supportHyperLinks(false)
+ const addLinkSpy = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ expect(addLinkSpy).toHaveBeenCalledWith('Example', 'https://example.com')
+ })
+ })
+
function supportHyperLinks(isSupported: boolean) {
vi.mocked(supportsHyperlinks).stdout = isSupported
}
diff --git a/packages/cli-kit/src/private/node/ui/components/Link.tsx b/packages/cli-kit/src/private/node/ui/components/Link.tsx
index 0690dcbd133..0eb634b77fd 100644
--- a/packages/cli-kit/src/private/node/ui/components/Link.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/Link.tsx
@@ -12,16 +12,15 @@ interface LinkProps {
function link(label: string | undefined, url: string, linksContext: LinksContextValue | null) {
if (!supportsHyperlinks.stdout) {
- if (url === (label ?? url)) {
- return url
- }
-
if (linksContext === null) {
+ if (url === (label ?? url)) {
+ return url
+ }
return label ? `${label} ${chalk.dim(`( ${url} )`)}` : url
}
const linkId = linksContext.addLink(label, url)
- return `${label ?? url} [${linkId}]`
+ return label ? `${label} [${linkId}]` : `[${linkId}]`
}
return ansiEscapes.link(label ?? url, url)
diff --git a/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx b/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx
index 16fd76c7002..89ff9183365 100644
--- a/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx
@@ -1,9 +1,31 @@
import {tokenItemToString, TokenizedText} from './TokenizedText.js'
+import {LinksContext, Link} from '../contexts/LinksContext.js'
import {unstyled} from '../../../../public/node/output.js'
import {render} from '../../testing/ui.js'
-import {describe, expect, test} from 'vitest'
+import {describe, expect, test, vi} from 'vitest'
+import supportsHyperlinks from 'supports-hyperlinks'
-import React from 'react'
+import React, {FunctionComponent, useRef} from 'react'
+
+vi.mock('supports-hyperlinks')
+
+const WithLinksContext: FunctionComponent<{children: React.ReactNode}> = ({children}) => {
+ const links = useRef>({})
+ return (
+ {
+ const newId = (Object.keys(links.current).length + 1).toString()
+ links.current = {...links.current, [newId]: {label, url}}
+ return newId
+ },
+ }}
+ >
+ {children}
+
+ )
+}
describe('TokenizedText', async () => {
test('renders arrays of items separated by spaces', async () => {
@@ -57,6 +79,142 @@ describe('TokenizedText', async () => {
`)
})
+ describe('URL auto-detection in plain strings', async () => {
+ test('renders strings without URLs unchanged', async () => {
+ vi.mocked(supportsHyperlinks).stdout = false
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe('no link here, just text')
+ })
+
+ test('replaces a URL with a `[N]` footnote anchor in the body when the terminal does not support hyperlinks', async () => {
+ vi.mocked(supportsHyperlinks).stdout = false
+ const url = 'https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe('See specification requirements: [1]')
+ expect(lastFrame()).not.toContain(url)
+ })
+
+ test('wraps detected URLs in OSC 8 escapes when the terminal supports hyperlinks', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const url = 'https://example.com/docs'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
+ })
+
+ test('detects multiple URLs in the same string', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const first = 'https://example.com/a'
+ const second = 'https://example.com/b'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toContain(`]8;;${first}${first}]8;;`)
+ expect(lastFrame()).toContain(`]8;;${second}${second}]8;;`)
+ })
+
+ test('detects back-to-back URLs separated only by whitespace', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const first = 'https://example.com/a'
+ const second = 'https://example.com/b'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toContain(`]8;;${first}${first}]8;;`)
+ expect(lastFrame()).toContain(`]8;;${second}${second}]8;;`)
+ })
+
+ test('preserves balanced parentheses inside URLs (e.g. Wikipedia disambiguation links)', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const url = 'https://en.wikipedia.org/wiki/Ruby_(programming_language)'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
+ })
+
+ test('does not auto-linkify URLs outside a LinksContext', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const url = 'https://example.com/docs'
+
+ const {lastFrame} = render()
+
+ expect(lastFrame()).toBe(`visit ${url} now`)
+ expect(lastFrame()).not.toContain(']8;;')
+ })
+
+ test('does not linkify https://-prefixed strings whose hostname has no dot (e.g. user typos like https://asda)', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const candidate = 'https://asda'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe(`Invalid tunnel URL: ${candidate}`)
+ expect(lastFrame()).not.toContain(']8;;')
+ })
+
+ test('does not linkify placeholder URLs with non-numeric ports (e.g. https://my-tunnel-url:port)', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const candidate = 'https://my-tunnel-url:port'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toBe(`Valid format: "${candidate}"`)
+ expect(lastFrame()).not.toContain(']8;;')
+ })
+
+ test('strips trailing sentence punctuation from detected URLs', async () => {
+ vi.mocked(supportsHyperlinks).stdout = true
+ const url = 'https://example.com/docs'
+
+ const {lastFrame} = render(
+
+
+ ,
+ )
+
+ expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
+ expect(lastFrame()).toContain('. Thanks')
+ })
+ })
+
describe('tokenItemToString', async () => {
test("doesn't add a space before char", async () => {
expect(tokenItemToString(['Run', {char: '!'}])).toBe('Run!')
diff --git a/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx b/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx
index 2c9905e01fc..70f82c7c774 100644
--- a/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx
+++ b/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx
@@ -4,7 +4,8 @@ import {List} from './List.js'
import {UserInput} from './UserInput.js'
import {FilePath} from './FilePath.js'
import {Subdued} from './Subdued.js'
-import React, {FunctionComponent} from 'react'
+import {LinksContext} from '../contexts/LinksContext.js'
+import React, {FunctionComponent, useContext} from 'react'
import {Box, Text} from 'ink'
export interface LinkToken {
@@ -152,9 +153,59 @@ interface TokenizedTextProps {
* `TokenizedText` renders a text string with tokens that can be either strings,
* links, and commands.
*/
+const URL_REGEX = /https?:\/\/\S+/g
+const URL_TRAILING_PUNCTUATION = /[.,;:!?'"]+$/
+
+function isLikelyRealUrl(candidate: string): boolean {
+ try {
+ const parsed = new URL(candidate)
+ return parsed.hostname.includes('.')
+ } catch (error) {
+ if (error instanceof TypeError) {
+ return false
+ }
+ throw error
+ }
+}
+
+function renderStringWithLinks(str: string): JSX.Element {
+ const matches = Array.from(str.matchAll(URL_REGEX))
+ if (matches.length === 0) {
+ return {str}
+ }
+
+ const parts: JSX.Element[] = []
+ let cursor = 0
+ matches.forEach((match, index) => {
+ let url = match[0]
+ const trailing = url.match(URL_TRAILING_PUNCTUATION)
+ if (trailing) {
+ url = url.slice(0, url.length - trailing[0].length)
+ }
+ if (!isLikelyRealUrl(url)) {
+ return
+ }
+ const start = match.index
+ const end = start + url.length
+ if (start > cursor) {
+ parts.push({str.slice(cursor, start)})
+ }
+ parts.push()
+ cursor = end
+ })
+ if (parts.length === 0) {
+ return {str}
+ }
+ if (cursor < str.length) {
+ parts.push({str.slice(cursor)})
+ }
+ return {parts}
+}
+
const TokenizedText: FunctionComponent = ({item}) => {
+ const linksContext = useContext(LinksContext)
if (typeof item === 'string') {
- return {item}
+ return linksContext === null ? {item} : renderStringWithLinks(item)
} else if ('command' in item) {
return
} else if ('link' in item) {