@@ -89,6 +89,77 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => {
8989
9090const COMPUTER_LINK_PREFIX = 'computer://' ;
9191
92+ const CODE_FILE_EXTENSIONS = new Set ( [
93+ 'js' , 'jsx' , 'ts' , 'tsx' , 'mjs' , 'cjs' , 'mts' , 'cts' ,
94+ 'py' , 'pyw' , 'pyi' ,
95+ 'rs' , 'go' , 'java' , 'kt' , 'kts' , 'scala' , 'groovy' ,
96+ 'c' , 'cpp' , 'cc' , 'cxx' , 'h' , 'hpp' , 'hxx' , 'hh' ,
97+ 'cs' , 'rb' , 'php' , 'swift' ,
98+ 'vue' , 'svelte' ,
99+ 'html' , 'htm' , 'css' , 'scss' , 'less' , 'sass' ,
100+ 'json' , 'jsonc' , 'yaml' , 'yml' , 'toml' , 'xml' ,
101+ 'md' , 'mdx' , 'rst' , 'txt' ,
102+ 'sh' , 'bash' , 'zsh' , 'fish' , 'ps1' , 'bat' , 'cmd' ,
103+ 'sql' , 'graphql' , 'gql' , 'proto' ,
104+ 'lock' , 'env' , 'ini' , 'cfg' , 'conf' ,
105+ 'cj' , 'ets' ,
106+ 'editorconfig' , 'gitignore' ,
107+ 'log' ,
108+ ] ) ;
109+
110+ const DOWNLOADABLE_EXTENSIONS = new Set ( [
111+ 'pdf' , 'doc' , 'docx' , 'xls' , 'xlsx' , 'ppt' , 'pptx' ,
112+ 'odt' , 'ods' , 'odp' , 'rtf' , 'pages' , 'numbers' , 'key' ,
113+ 'png' , 'jpg' , 'jpeg' , 'gif' , 'bmp' , 'svg' , 'webp' , 'ico' , 'tiff' , 'tif' ,
114+ 'zip' , 'tar' , 'gz' , 'bz2' , '7z' , 'rar' , 'dmg' , 'iso' , 'xz' ,
115+ 'mp3' , 'wav' , 'ogg' , 'flac' , 'aac' , 'm4a' , 'wma' ,
116+ 'mp4' , 'avi' , 'mkv' , 'mov' , 'webm' , 'wmv' , 'flv' ,
117+ 'csv' , 'tsv' , 'sqlite' , 'db' , 'parquet' ,
118+ 'epub' , 'mobi' ,
119+ 'apk' , 'ipa' , 'exe' , 'msi' , 'deb' , 'rpm' ,
120+ 'ttf' , 'otf' , 'woff' , 'woff2' ,
121+ ] ) ;
122+
123+ /**
124+ * Detect local file links: absolute paths, file:// URLs, and relative paths
125+ * pointing to downloadable files. Returns the file path or null.
126+ *
127+ * - Absolute paths (`/Users/.../file.pdf`): use CODE_FILE_EXTENSIONS blacklist
128+ * - Relative paths (`report.pptx`, `./output.pdf`): use DOWNLOADABLE_EXTENSIONS whitelist
129+ */
130+ function isLocalFileLink ( href : string ) : string | null {
131+ if ( ! href || href === '/' ) return null ;
132+
133+ let filePath : string ;
134+ if ( href . startsWith ( 'file://' ) ) {
135+ filePath = href . slice ( 7 ) ;
136+ } else if ( href . includes ( '://' ) || href . startsWith ( '#' ) || href . startsWith ( '//' ) ) {
137+ return null ;
138+ } else {
139+ filePath = href ;
140+ }
141+
142+ if ( filePath . startsWith ( '/' ) ) {
143+ const segments = filePath . split ( '/' ) . filter ( Boolean ) ;
144+ if ( segments . length < 2 ) return null ;
145+ }
146+
147+ const fileName = filePath . split ( '/' ) . pop ( ) || '' ;
148+ const dotIdx = fileName . lastIndexOf ( '.' ) ;
149+ if ( dotIdx <= 0 ) return null ;
150+
151+ const ext = fileName . slice ( dotIdx + 1 ) . toLowerCase ( ) ;
152+ if ( ! ext ) return null ;
153+
154+ if ( filePath . startsWith ( '/' ) ) {
155+ if ( CODE_FILE_EXTENSIONS . has ( ext ) ) return null ;
156+ } else {
157+ if ( ! DOWNLOADABLE_EXTENSIONS . has ( ext ) ) return null ;
158+ }
159+
160+ return filePath ;
161+ }
162+
92163function formatFileSize ( bytes : number ) : string {
93164 if ( bytes >= 1024 * 1024 ) return `${ ( bytes / ( 1024 * 1024 ) ) . toFixed ( 1 ) } MB` ;
94165 if ( bytes >= 1024 ) return `${ Math . round ( bytes / 1024 ) } KB` ;
@@ -337,6 +408,20 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, onFileDownlo
337408 ) ;
338409 }
339410
411+ // Local file path (e.g. /Users/.../report.pdf) → FileCard, excluding code files
412+ if ( onGetFileInfo && onFileDownload ) {
413+ const localPath = typeof href === 'string' ? isLocalFileLink ( href ) : null ;
414+ if ( localPath ) {
415+ return (
416+ < FileCard
417+ path = { localPath }
418+ onGetFileInfo = { onGetFileInfo }
419+ onDownload = { onFileDownload }
420+ />
421+ ) ;
422+ }
423+ }
424+
340425 // Fallback: render as plain text for computer:// links without handler,
341426 // or as a regular link for http(s) links.
342427 if ( typeof href === 'string' && ( href . startsWith ( 'http://' ) || href . startsWith ( 'https://' ) ) ) {
@@ -373,13 +458,14 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, onFileDownlo
373458 remarkPlugins = { [ remarkGfm ] }
374459 components = { components }
375460 urlTransform = { ( url ) => {
376- // react-markdown v9 strips non-standard protocols by default.
377- // Preserve computer:// so our FileCard renderer receives the href intact.
378461 if ( url . startsWith ( 'computer://' ) ) return url ;
379- // Keep default-safe behaviour for everything else.
380- if ( / ^ ( h t t p s ? | m a i l t o | t e l ) : / i. test ( url ) || url . startsWith ( '#' ) || url . startsWith ( '/' ) ) {
462+ if ( / ^ ( h t t p s ? | m a i l t o | t e l | f i l e ) : / i. test ( url ) || url . startsWith ( '#' ) || url . startsWith ( '/' ) ) {
381463 return url ;
382464 }
465+ // Preserve relative paths without a protocol (e.g. "report.pptx",
466+ // "./output.pdf"). Content is from our own AI so javascript:/data:
467+ // injection is not a concern; those contain ':' and are blocked above.
468+ if ( ! url . includes ( ':' ) ) return url ;
383469 return '' ;
384470 } }
385471 >
0 commit comments