Skip to content

Auto-detect and linkify URLs in plain-string TokenizedText items#7384

Open
ryancbahan wants to merge 1 commit intomainfrom
hotfix-link-linebreak
Open

Auto-detect and linkify URLs in plain-string TokenizedText items#7384
ryancbahan wants to merge 1 commit intomainfrom
hotfix-link-linebreak

Conversation

@ryancbahan
Copy link
Copy Markdown
Contributor

@ryancbahan ryancbahan commented Apr 23, 2026

WHY are these changes introduced?

Long URLs embedded as plain strings in CLI error messages wrap across multiple lines inside the fixed-width error banner (~53 chars on an 80-col terminal). The wrap splits the URL with the border's characters, producing output that is neither clickable nor copy-pasteable as a single URL.

WHAT is this pull request doing?

Three small changes in packages/cli-kit:

  1. TokenizedText.tsx — New renderStringWithLinks helper auto-detects http(s)://… URLs in plain-string tokens, strips common trailing sentence punctuation, and routes each URL through the existing <Link> component. This is the chokepoint through which all banner/alert bodies render, so server-returned error strings benefit automatically.
  2. FatalError.tsx — The error.message fallback (used when a FatalError is constructed with a plain OutputMessage instead of a TokenItem) now flows through TokenizedText instead of a bare <Text>, picking up the same URL treatment.
  3. Link.tsx — Two fixes to the non-OSC-8 fallback path, both limited to behavior inside a LinksContext (i.e. inside a Banner):
    • When a link has no label, render just [N] as the in-body anchor instead of url [N] (prevents the URL from being visible in-body where it would wrap).
    • Always use the footnote mechanism when a LinksContext is present, regardless of whether a label was provided (previously the method short-circuited for label-less links and emitted the raw URL inline).

Net effect

Before:

│ See specification requirements: https://shopify.dev/docs/apps/build/sales- │
│ channels/channel-config-extension#specification-properties                 │

After:

│ See specification requirements: [1]                                        │
╰────────────────────────────────────────────────────────────────────────────╯
[1] https://shopify.dev/docs/apps/build/sales-channels/channel-config-extension#specification-properties

On OSC 8 terminals (iTerm2, modern Terminal.app, VS Code, Warp, Kitty, WezTerm, Ghostty) the [1] anchor is a clickable hyperlink to the full URL. On terminals without OSC 8 support, the URL appears outside the bordered box in the footnote block, where it can still wrap at terminal width but is not interleaved with characters, so copy-paste recovers the full URL.

How to test your changes?

Unit coverage added

  • TokenizedText.test.tsx: 5 tests — no-URL passthrough, URL preserved without OSC 8, URL wrapped in OSC 8 when supported, multiple URLs, trailing punctuation stripped.
  • Alert.test.tsx and FatalError.test.tsx: inline snapshot integration tests that render a long-URL message inside a real Banner / FatalError and lock in the [1] in-body + URL-in-footnote layout.

Visual QA in a real terminal

  1. pnpm --filter @shopify/cli-kit build
  2. Create a small script that calls renderError / renderFatalError with a plain-string body containing a long URL, then run it with node.
  3. Verify the URL is no longer interleaved with , the [1] anchor is clickable (OSC 8 terminals), and copy-pasting the footnote URL yields a single clean URL.
  4. Force the non-OSC-8 path with FORCE_HYPERLINK=0 and confirm the [N] + footnote layout still renders cleanly.

Post-release steps

None.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows) — change is pure text rendering; OSC 8 detection is delegated to the existing supports-hyperlinks dependency.
  • I've considered possible documentation changes — none needed; the fix is transparent to callers.
  • I've considered analytics changes to measure impact — not applicable.
  • The change is user-facing — added a patch changeset (bug fix).

@ryancbahan ryancbahan changed the title preserve link strings Auto-detect and linkify URLs in plain-string TokenizedText items Apr 23, 2026
Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions
Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/private/node/ui/components/TokenizedText.d.ts
@@ -40,9 +40,5 @@ export declare function appendToTokenItem(token: TokenItem, suffix: string): Tok
 interface TokenizedTextProps {
     item: TokenItem;
 }
-/**
- *  renders a text string with tokens that can be either strings,
- * links, and commands.
- */
 declare const TokenizedText: FunctionComponent<TokenizedTextProps>;
 export { TokenizedText };
\ No newline at end of file

@ryancbahan ryancbahan marked this pull request as ready for review April 23, 2026 21:36
@ryancbahan ryancbahan requested a review from a team as a code owner April 23, 2026 21:36
Copy link
Copy Markdown
Contributor

@isaacroldan isaacroldan left a comment

Choose a reason for hiding this comment

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


Review assisted by pair-review

* links, and commands.
*/
const URL_REGEX = /https?:\/\/\S+/g
const URL_TRAILING_PUNCTUATION = /[.,;:!?)\]}>'"]+$/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🐛 Bug: URL_TRAILING_PUNCTUATION = /[.,;:!?)\]}>'"]+$/ unconditionally strips ), ], }, and > from the end of a matched URL, but these characters are legal URL suffixes. Real-world examples that would break: Wikipedia disambiguation links like https://en.wikipedia.org/wiki/Ruby_(programming_language), MDN Function() pages, Microsoft Learn URLs using parens in paths, and query strings ending in JSON-array-style values. Because this helper is now the chokepoint for every plain string passed through TokenizedText, any such URL in a server-returned error message will have its closing bracket stripped, and the <Link> component will emit an OSC 8 escape pointing at a broken target (with the trailing ) rendered outside the link). None of the new tests exercise URLs containing (), so this silent corruption is uncovered.

Suggestion: Narrow the character class to unambiguous sentence punctuation, or balance-count parens before stripping. Minimal fix:

Suggested change
const URL_TRAILING_PUNCTUATION = /[.,;:!?)\]}>'"]+$/
const URL_TRAILING_PUNCTUATION = /[.,;:!?'"]+$/

And add a test for https://en.wikipedia.org/wiki/Foo_(bar) to lock the behavior in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1 on this

const TokenizedText: FunctionComponent<TokenizedTextProps> = ({item}) => {
if (typeof item === 'string') {
return <Text>{item}</Text>
return renderStringWithLinks(item)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Design: The PR is framed as a Banner/alert fix, but renderStringWithLinks runs on every plain-string token passing through TokenizedText. That includes Alert, FatalError, List, TabularData, DangerousConfirmationPrompt, Prompts/PromptLayout, Prompts/InfoMessage, and TextPrompt. In non-Banner contexts there's no LinksContext, so the Link component falls back to raw-URL output for non-OSC-8 terminals (preserved) but emits OSC 8 escapes on OSC-8-capable terminals (behavior change). A concrete example: the placeholder URL in packages/app/src/cli/services/dev/urls.ts:86 (Valid format: "https://my-tunnel-url:port") will now be rendered as a clickable escape to a non-existent host. Reviewers of downstream packages won't catch this from the PR description alone.

Suggestion: Either (a) scope the auto-detection to contexts that own a LinksContext by threading an explicit flag through (e.g. an autoLinkify prop or opt-in from Banner/Alert/FatalError), or (b) keep the current broad behavior but update the PR description/changeset and add snapshot coverage for non-Banner callers (TabularData, List, prompts) so the behavior change is visible at review time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tested this and does indeed break other alerts:

Captura de pantalla 2026-04-24 a las 10.50.29.png

Copy link
Copy Markdown
Contributor

@isaacroldan isaacroldan Apr 24, 2026

Choose a reason for hiding this comment

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

You can trigger this one by running:

pnpm shopify app dev --tunnel-url https://wrong

expect(lastFrame()).toContain(`]8;;${url}${url}]8;;`)
})

test('detects multiple URLs in the same string', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Might be worth adding edge test cases where there's back to back URLs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants