11/** URL spans: bare http(s) scanning plus splitting styled runs on hits. */
2+ import {
3+ detectLinks ,
4+ type SimpleHighlight ,
5+ type TextChunk ,
6+ } from "@opentui/core" ;
7+ import { stringWidth } from "./view/height.js" ;
8+ import { isOpenableUrl } from "./link-open.js" ;
9+
210export interface LinkSpan {
311 readonly text : string ;
412 readonly fg : string ;
@@ -12,7 +20,6 @@ export interface LinkHit {
1220 readonly end : number ;
1321}
1422
15- const URL_PATTERN = / h t t p s ? : \/ \/ [ ^ \s < > " ' ` \] ] + / gi;
1623const TRAILING_PUNCTUATION = new Set ( [
1724 "." ,
1825 "," ,
@@ -27,14 +34,34 @@ const TRAILING_PUNCTUATION = new Set([
2734 ">" ,
2835] ) ;
2936
30- /** http(s) runs inside plain text, without trailing prose punctuation. */
37+ /**
38+ * http(s) runs inside plain text. Bare detection is OpenTUI `detectLinks`
39+ * so a painted fragment and a Code chunk agree on the same target.
40+ */
3141export function findLinks ( text : string ) : LinkHit [ ] {
42+ return linkHits ( text , [ ] ) ;
43+ }
44+
45+ /** Linked ranges `detectLinks` reports for one string. */
46+ export function linkHits (
47+ text : string ,
48+ highlights : readonly SimpleHighlight [ ] ,
49+ ) : LinkHit [ ] {
50+ if ( text . length === 0 ) return [ ] ;
51+ const chunk : TextChunk = { __isChunk : true , text } ;
52+ const linked = detectLinks ( [ chunk ] , {
53+ content : text ,
54+ highlights : [ ...highlights ] ,
55+ sourceRanges : [ { start : 0 , end : text . length } ] ,
56+ } ) ;
3257 const hits : LinkHit [ ] = [ ] ;
33- URL_PATTERN . lastIndex = 0 ;
34- for ( const match of text . matchAll ( URL_PATTERN ) ) {
35- const end = trimUrlEnd ( text , match . index , match . index + match [ 0 ] . length ) ;
36- if ( end > match . index )
37- hits . push ( { url : text . slice ( match . index , end ) , start : match . index , end } ) ;
58+ let offset = 0 ;
59+ for ( const part of linked ) {
60+ const url = part . link ?. url ;
61+ if ( url !== undefined && part . text . length > 0 ) {
62+ hits . push ( { url, start : offset , end : offset + part . text . length } ) ;
63+ }
64+ offset += part . text . length ;
3865 }
3966 return hits ;
4067}
@@ -124,3 +151,165 @@ export function sliceSpans(
124151 } ) ;
125152 return spans ;
126153}
154+
155+ /**
156+ * Rejoin URL fragments split across continuation lines so every fragment
157+ * highlights and opens the full URL.
158+ *
159+ * `wrapWidth` is the painted width the row was wrapped at. A chain starts
160+ * on a full line ending in a URL run and continues through full lines; a
161+ * short line ends it unless no text follows (then a natural break, not a
162+ * wrap). A chain is accepted when its fragments reassemble to one of
163+ * `sourceUrls`, the row's real links: wrap can orphan a short fragment
164+ * with text after it, indistinguishable from a natural break by geometry.
165+ * Without known source URLs the joined candidate must still scan as
166+ * exactly one clean http(s) URL, so a break cannot fuse two unrelated
167+ * runs. The scan is `detectLinks` via `findLinks`.
168+ */
169+ export function splitWrappedLinkSpans (
170+ lines : readonly { text : string ; fg : string } [ ] ,
171+ wrapWidth : number ,
172+ sourceUrls : readonly string [ ] = [ ] ,
173+ ) : LinkSpan [ ] [ ] {
174+ const hits = lines . map ( ( line ) =>
175+ findLinks ( line . text ) . map ( ( hit ) : SliceHit => ( { ...hit , text : hit . url } ) ) ,
176+ ) ;
177+ let index = 0 ;
178+ while ( index < lines . length ) {
179+ const line = lines [ index ] ;
180+ const seed = line === undefined ? null : wrapSeed ( line . text , wrapWidth ) ;
181+ if ( line === undefined || seed === null ) {
182+ index += 1 ;
183+ continue ;
184+ }
185+ const chain = followWrapChain (
186+ lines ,
187+ index + 1 ,
188+ seed ,
189+ ( hits [ index ] ?? [ ] ) . some ( ( hit ) => hit . end >= seed . end ) ,
190+ wrapWidth ,
191+ sourceUrls ,
192+ ) ;
193+ if ( chain === null ) {
194+ index += 1 ;
195+ continue ;
196+ }
197+ hits [ index ] = ( hits [ index ] ?? [ ] ) . filter ( ( hit ) => hit . start < seed . start ) ;
198+ hits [ index ] ?. push ( {
199+ url : chain . full ,
200+ start : seed . start ,
201+ end : seed . end ,
202+ text : seed . text ,
203+ } ) ;
204+ for ( const run of chain . runs ) {
205+ hits [ run . line ] = ( hits [ run . line ] ?? [ ] ) . filter (
206+ ( hit ) => hit . end <= run . start || hit . start >= run . end ,
207+ ) ;
208+ hits [ run . line ] ?. push ( {
209+ url : chain . full ,
210+ start : run . start ,
211+ end : run . end ,
212+ text : lines [ run . line ] ?. text . slice ( run . start , run . end ) ?? "" ,
213+ } ) ;
214+ }
215+ index = chain . endLine + 1 ;
216+ }
217+ return lines . map ( ( line , i ) =>
218+ sliceSpans (
219+ line ,
220+ [ ...( hits [ i ] ?? [ ] ) ] . sort ( ( a , b ) => a . start - b . start ) ,
221+ ) ,
222+ ) ;
223+ }
224+
225+ /** A full line's trailing URL run seeds a wrapped chain, if URL-shaped. */
226+ function wrapSeed (
227+ text : string ,
228+ wrapWidth : number ,
229+ ) : {
230+ readonly start : number ;
231+ readonly end : number ;
232+ readonly text : string ;
233+ } | null {
234+ if ( stringWidth ( text ) !== wrapWidth ) return null ;
235+ const run = text . match ( / [ ^ \s ] + $ / ) ?. [ 0 ] ?? "" ;
236+ // The :// marks the run as URL-shaped even when a hard split inside the
237+ // scheme or host leaves no detectable hit; the joined candidate still
238+ // has to scan as one clean URL before anything merges. Prose punctuation
239+ // at the wrap edge is not part of the seed.
240+ if ( ! run . includes ( "://" ) ) return null ;
241+ const start = text . length - run . length ;
242+ const end = trimUrlEnd ( text , start , text . length ) ;
243+ if ( end <= start ) return null ;
244+ return { start, end, text : text . slice ( start , end ) } ;
245+ }
246+
247+ /** Fragments a chain picks up past its seed line, through its final line. */
248+ interface WrapChain {
249+ readonly full : string ;
250+ readonly runs : readonly {
251+ readonly line : number ;
252+ readonly start : number ;
253+ readonly end : number ;
254+ } [ ] ;
255+ readonly endLine : number ;
256+ }
257+
258+ /**
259+ * Walk continuation lines past their indent, fusing leading runs onto the
260+ * seed under the module's chain rules. A hitless seed continues only
261+ * through a full first line — a short line behind a bare scheme reads as
262+ * prose.
263+ */
264+ function followWrapChain (
265+ lines : readonly { text : string ; fg : string } [ ] ,
266+ from : number ,
267+ seed : { readonly start : number ; readonly end : number ; readonly text : string } ,
268+ seedAnchored : boolean ,
269+ wrapWidth : number ,
270+ sourceUrls : readonly string [ ] ,
271+ ) : WrapChain | null {
272+ let full = seed . text ;
273+ const runs : { line : number ; start : number ; end : number } [ ] = [ ] ;
274+ let line = from ;
275+ for ( ; ; ) {
276+ const text = lines [ line ] ?. text ;
277+ if ( text === undefined ) break ;
278+ const start = text . match ( / ^ [ \s ▍ ] * / ) ?. [ 0 ] . length ?? 0 ;
279+ const raw = text . slice ( start ) . match ( / ^ [ ^ \s ] + / ) ?. [ 0 ] ?? "" ;
280+ if ( raw . length === 0 ) {
281+ if ( runs . length === 0 || ! isWrapEndLine ( text ) ) return null ;
282+ break ;
283+ }
284+ const end = trimUrlEnd ( text , start , start + raw . length ) ;
285+ if ( end <= start ) return null ;
286+ full += text . slice ( start , end ) ;
287+ runs . push ( { line, start, end } ) ;
288+ if ( sourceUrls . includes ( full ) ) return { full, runs, endLine : line } ;
289+ if ( runs . length === 1 && ! seedAnchored && stringWidth ( text ) !== wrapWidth )
290+ return null ;
291+ if ( end !== text . length ) break ;
292+ if ( stringWidth ( text ) === wrapWidth ) {
293+ line += 1 ;
294+ continue ;
295+ }
296+ if ( ! isWrapEndLine ( lines [ line + 1 ] ?. text ) ) return null ;
297+ break ;
298+ }
299+ if ( runs . length === 0 ) return null ;
300+ if ( sourceUrls . length > 0 ) return null ;
301+ if ( ! isOpenableUrl ( full ) ) return null ;
302+ const check = findLinks ( full ) ;
303+ if ( check . length !== 1 || check [ 0 ] ?. url !== full ) return null ;
304+ return { full, runs, endLine : runs [ runs . length - 1 ] ?. line ?? from } ;
305+ }
306+
307+ /**
308+ * A line nothing textual follows on: the end of the text, or a user-bubble
309+ * pad row (a bare `▍`). A blank source line is a natural break, not one.
310+ * Paint trims trailing space, so the pad compares exactly.
311+ */
312+ function isWrapEndLine ( text : string | undefined ) : boolean {
313+ if ( text === undefined ) return true ;
314+ return text . trimEnd ( ) === "▍" ;
315+ }
0 commit comments