@@ -38,6 +38,7 @@ export type CellRenderKind =
3838 | { kind : 'boolean' ; checked : boolean }
3939 | { kind : 'json' ; text : string }
4040 | { kind : 'date' ; text : string }
41+ | { kind : 'url' ; text : string ; href : string ; domain : string }
4142 | { kind : 'text' ; text : string }
4243 // Universal fallback
4344 | { kind : 'empty' }
@@ -113,6 +114,12 @@ export function resolveCellRender({
113114 if ( isNull ) return { kind : 'empty' }
114115 if ( column . type === 'json' ) return { kind : 'json' , text : JSON . stringify ( value ) }
115116 if ( column . type === 'date' ) return { kind : 'date' , text : String ( value ) }
117+ if ( column . type === 'string' ) {
118+ const text = stringifyValue ( value )
119+ const urlInfo = extractUrlInfo ( text )
120+ if ( urlInfo ) return { kind : 'url' , text, href : urlInfo . href , domain : urlInfo . domain }
121+ return { kind : 'text' , text }
122+ }
116123 return { kind : 'text' , text : stringifyValue ( value ) }
117124}
118125
@@ -122,6 +129,78 @@ function stringifyValue(value: unknown): string {
122129 return JSON . stringify ( value )
123130}
124131
132+ /** Matches bare hostnames: `microsoft.com`, `www.linkedin.com`, `sub.domain.co.uk` */
133+ const BARE_DOMAIN_RE = / ^ ( [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 - ] { 0 , 61 } [ a - z A - Z 0 - 9 ] ) ? \. ) + [ a - z A - Z ] { 2 , } $ /
134+
135+ /**
136+ * File extensions that are also valid TLDs but should never be treated as URLs.
137+ * Without this, strings like `config.json` or `readme.md` would resolve as domains.
138+ */
139+ const FILE_EXTENSION_TLDS = new Set ( [
140+ 'txt' ,
141+ 'json' ,
142+ 'yaml' ,
143+ 'yml' ,
144+ 'xml' ,
145+ 'html' ,
146+ 'htm' ,
147+ 'css' ,
148+ 'js' ,
149+ 'ts' ,
150+ 'tsx' ,
151+ 'jsx' ,
152+ 'md' ,
153+ 'mdx' ,
154+ 'csv' ,
155+ 'pdf' ,
156+ 'doc' ,
157+ 'docx' ,
158+ 'xls' ,
159+ 'xlsx' ,
160+ 'ppt' ,
161+ 'pptx' ,
162+ 'zip' ,
163+ 'gz' ,
164+ 'tar' ,
165+ 'rar' ,
166+ 'png' ,
167+ 'jpg' ,
168+ 'jpeg' ,
169+ 'gif' ,
170+ 'svg' ,
171+ 'webp' ,
172+ 'mp4' ,
173+ 'mp3' ,
174+ 'wav' ,
175+ 'mov' ,
176+ 'py' ,
177+ 'rb' ,
178+ 'go' ,
179+ 'rs' ,
180+ 'sh' ,
181+ 'bat' ,
182+ 'log' ,
183+ 'env' ,
184+ ] )
185+
186+ function extractUrlInfo ( text : string ) : { href : string ; domain : string } | null {
187+ if ( ! text ) return null
188+ if ( / ^ h t t p s ? : \/ \/ / i. test ( text ) ) {
189+ try {
190+ const url = new URL ( text )
191+ return { href : text , domain : url . hostname }
192+ } catch {
193+ return null
194+ }
195+ }
196+ if ( BARE_DOMAIN_RE . test ( text ) ) {
197+ const tld = text . split ( '.' ) . pop ( ) ?. toLowerCase ( ) ?? ''
198+ if ( FILE_EXTENSION_TLDS . has ( tld ) ) return null
199+ return { href : `https://${ text } ` , domain : text }
200+ }
201+ return null
202+ }
203+
125204interface CellRenderProps {
126205 kind : CellRenderKind
127206 /** When true the static content sits underneath the InlineEditor overlay
@@ -237,6 +316,31 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
237316 </ span >
238317 )
239318
319+ case 'url' :
320+ return (
321+ < span className = { cn ( 'flex min-w-0 items-center gap-1.5' , isEditing && 'invisible' ) } >
322+ < img
323+ src = { `https://www.google.com/s2/favicons?domain=${ kind . domain } &sz=16` }
324+ alt = ''
325+ width = { 12 }
326+ height = { 12 }
327+ className = 'shrink-0 rounded-[2px]'
328+ onError = { ( e ) => {
329+ e . currentTarget . style . display = 'none'
330+ } }
331+ />
332+ < a
333+ href = { kind . href }
334+ target = '_blank'
335+ rel = 'noopener noreferrer'
336+ className = 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70'
337+ onClick = { ( e ) => e . stopPropagation ( ) }
338+ >
339+ { kind . text }
340+ </ a >
341+ </ span >
342+ )
343+
240344 case 'text' :
241345 return (
242346 < span
0 commit comments