11import { truncateStatusText } from "@/chat/runtime/status-format" ;
2+ import {
3+ isMarkdownTableSeparator ,
4+ parseMarkdownTableRow ,
5+ renderMarkdownTableCodeBlock ,
6+ } from "@/chat/slack/markdown-table" ;
27
38const PROTECTED_SLACK_SEGMENT_PATTERN =
49 / ( ` ` ` [ \s \S ] * ?` ` ` | ~ ~ ~ [ \s \S ] * ?~ ~ ~ | ` [ ^ ` \n ] + ` ) / g;
@@ -9,7 +14,8 @@ function normalizeSlackMarkdownSegment(text: string): string {
914 normalized = normalizeCommonMarkEmphasis ( normalized ) ;
1015 normalized = normalizeMarkdownLinks ( normalized ) ;
1116 normalized = normalizeWrappedRawUrls ( normalized ) ;
12- return normalizeMarkdownTables ( normalized ) ;
17+ normalized = normalizeMarkdownTables ( normalized ) ;
18+ return escapeSlackControlChars ( normalized ) ;
1319}
1420
1521function normalizeUnprotectedSlackMarkdown ( text : string ) : string {
@@ -382,72 +388,6 @@ function normalizeWrappedRawUrls(text: string): string {
382388 return `${ out } ${ text . slice ( lastIndex ) } ` ;
383389}
384390
385- function parseMarkdownTableRow ( line : string ) : string [ ] | null {
386- if ( ! line . includes ( "|" ) ) {
387- return null ;
388- }
389-
390- const normalized = line . trim ( ) . replace ( / ^ \| / , "" ) . replace ( / \| $ / , "" ) ;
391- const cells : string [ ] = [ ] ;
392- let current = "" ;
393- let insideSlackLink = false ;
394-
395- for ( const char of normalized ) {
396- if ( char === "<" ) {
397- insideSlackLink = true ;
398- current += char ;
399- continue ;
400- }
401- if ( char === ">" ) {
402- insideSlackLink = false ;
403- current += char ;
404- continue ;
405- }
406- if ( char === "|" && ! insideSlackLink ) {
407- cells . push ( current . trim ( ) ) ;
408- current = "" ;
409- continue ;
410- }
411- current += char ;
412- }
413-
414- cells . push ( current . trim ( ) ) ;
415- return cells . length >= 2 ? cells : null ;
416- }
417-
418- function isMarkdownTableSeparator ( line : string ) : boolean {
419- const cells = parseMarkdownTableRow ( line ) ;
420- return (
421- cells !== null &&
422- cells . length >= 2 &&
423- cells . every ( ( cell ) => / ^ : ? - { 3 , } : ? $ / . test ( cell ) )
424- ) ;
425- }
426-
427- function renderMarkdownTableCodeBlock ( rows : string [ ] [ ] ) : string {
428- const columnCount = Math . max ( ...rows . map ( ( row ) => row . length ) ) ;
429- const normalizedRows = rows . map ( ( row ) =>
430- Array . from ( { length : columnCount } , ( _unused , index ) => row [ index ] ?? "" ) ,
431- ) ;
432- const widths = Array . from ( { length : columnCount } , ( _unused , index ) =>
433- Math . max ( 3 , ...normalizedRows . map ( ( row ) => ( row [ index ] ?? "" ) . length ) ) ,
434- ) ;
435-
436- const formatRow = ( row : string [ ] ) =>
437- row
438- . map ( ( cell , index ) => cell . padEnd ( widths [ index ] ?? 3 ) )
439- . join ( " | " )
440- . trimEnd ( ) ;
441-
442- const header = formatRow ( normalizedRows [ 0 ] ?? [ ] ) ;
443- const separator = widths
444- . map ( ( width ) => "-" . repeat ( Math . max ( 3 , width ) ) )
445- . join ( " | " ) ;
446- const body = normalizedRows . slice ( 1 ) . map ( formatRow ) ;
447-
448- return [ "```" , header , separator , ...body , "```" ] . join ( "\n" ) ;
449- }
450-
451391function normalizeMarkdownTables ( text : string ) : string {
452392 const lines = text . split ( "\n" ) ;
453393 const out : string [ ] = [ ] ;
@@ -477,6 +417,103 @@ function normalizeMarkdownTables(text: string): string {
477417 return out . join ( "\n" ) ;
478418}
479419
420+ function isPreservedSlackEntity ( text : string , index : number ) : number {
421+ if ( text . startsWith ( "&" , index ) ) {
422+ return 5 ;
423+ }
424+ if ( text . startsWith ( "<" , index ) || text . startsWith ( ">" , index ) ) {
425+ return 4 ;
426+ }
427+ return 0 ;
428+ }
429+
430+ function isSlackBlockQuoteMarker ( text : string , index : number ) : boolean {
431+ if ( text [ index ] !== ">" ) {
432+ return false ;
433+ }
434+
435+ let cursor = index - 1 ;
436+ while ( cursor >= 0 && text [ cursor ] !== "\n" ) {
437+ if ( ! / \s / . test ( text [ cursor ] ?? "" ) ) {
438+ return false ;
439+ }
440+ cursor -= 1 ;
441+ }
442+ return true ;
443+ }
444+
445+ function isPreservedSlackToken ( token : string ) : boolean {
446+ return (
447+ / ^ < h t t p s ? : \/ \/ [ ^ > \s ] + (?: \| [ ^ > ] + ) ? > $ / . test ( token ) ||
448+ / ^ < m a i l t o : [ ^ > \s ] + (?: \| [ ^ > ] + ) ? > $ / . test ( token ) ||
449+ / ^ < @ [ ^ > | ] + (?: \| [ ^ > ] + ) ? > $ / . test ( token ) ||
450+ / ^ < # [ ^ > | ] + (?: \| [ ^ > ] + ) ? > $ / . test ( token ) ||
451+ / ^ < ! (? ! - ) [ ^ > ] + > $ / . test ( token )
452+ ) ;
453+ }
454+
455+ function escapeSlackControlCharsSegment ( text : string ) : string {
456+ let out = "" ;
457+
458+ for ( let index = 0 ; index < text . length ; index += 1 ) {
459+ const char = text [ index ] ;
460+ if ( char === "&" ) {
461+ const entityLength = isPreservedSlackEntity ( text , index ) ;
462+ if ( entityLength > 0 ) {
463+ out += text . slice ( index , index + entityLength ) ;
464+ index += entityLength - 1 ;
465+ continue ;
466+ }
467+ out += "&" ;
468+ continue ;
469+ }
470+
471+ if ( char === "<" ) {
472+ const closeIndex = text . indexOf ( ">" , index + 1 ) ;
473+ if ( closeIndex !== - 1 ) {
474+ const token = text . slice ( index , closeIndex + 1 ) ;
475+ if ( isPreservedSlackToken ( token ) ) {
476+ out += token ;
477+ index = closeIndex ;
478+ continue ;
479+ }
480+ }
481+
482+ out += "<" ;
483+ continue ;
484+ }
485+
486+ if ( char === ">" ) {
487+ out += isSlackBlockQuoteMarker ( text , index ) ? ">" : ">" ;
488+ continue ;
489+ }
490+
491+ out += char ;
492+ }
493+
494+ return out ;
495+ }
496+
497+ function escapeSlackControlChars ( text : string ) : string {
498+ let out = "" ;
499+ let lastIndex = 0 ;
500+
501+ for ( const match of text . matchAll ( PROTECTED_SLACK_SEGMENT_PATTERN ) ) {
502+ const index = match . index ?? 0 ;
503+ if ( index > lastIndex ) {
504+ out += escapeSlackControlCharsSegment ( text . slice ( lastIndex , index ) ) ;
505+ }
506+ out += match [ 0 ] ;
507+ lastIndex = index + match [ 0 ] . length ;
508+ }
509+
510+ if ( lastIndex < text . length ) {
511+ out += escapeSlackControlCharsSegment ( text . slice ( lastIndex ) ) ;
512+ }
513+
514+ return out ;
515+ }
516+
480517/** Insert blank lines between content blocks so Slack renders them with visual separation. */
481518function ensureBlockSpacing ( text : string ) : string {
482519 const codeBlockPattern = / ^ ` ` ` / ;
0 commit comments