11import ansis from 'ansis' ;
22import type { TableCellAlignment } from 'build-md' ;
33import stringWidth from 'string-width' ;
4+ import wrapAnsi from 'wrap-ansi' ;
45import type {
56 Table ,
67 TableAlignment ,
@@ -12,6 +13,7 @@ import { TERMINAL_WIDTH } from '../constants.js';
1213type AsciiTableOptions = {
1314 borderless ?: boolean ;
1415 padding ?: number ;
16+ maxWidth ?: number ;
1517} ;
1618
1719type NormalizedOptions = Required < AsciiTableOptions > ;
@@ -23,12 +25,14 @@ type NormalizedTable = {
2325
2426type TableCell = { text : string ; alignment : TableAlignment } ;
2527
28+ type ColumnStats = { maxWidth : number ; maxWord : string } ;
29+
2630const DEFAULT_PADDING = 1 ;
2731const DEFAULT_ALIGNMENT = 'left' satisfies TableAlignment ;
28- const MAX_WIDTH = TERMINAL_WIDTH ; // TODO: use process.stdout.columns?
2932const DEFAULT_OPTIONS : NormalizedOptions = {
3033 borderless : false ,
3134 padding : DEFAULT_PADDING ,
35+ maxWidth : TERMINAL_WIDTH , // TODO: use process.stdout.columns?
3236} ;
3337
3438const BORDERS = {
@@ -67,18 +71,23 @@ function formatTable(
6771 table : NormalizedTable ,
6872 options : NormalizedOptions ,
6973) : string {
70- // TODO: enforce MAX_WIDTH
71- const columnWidths = getColumnWidths ( table ) ;
74+ const columnWidths = getColumnWidths ( table , options ) ;
7275
7376 return [
7477 formatBorderRow ( 'top' , columnWidths , options ) ,
7578 ...( table . columns
7679 ? [
77- formatContentRow ( table . columns , columnWidths , options ) ,
80+ ...wrapRow ( table . columns , columnWidths ) . map ( row =>
81+ formatContentRow ( row , columnWidths , options ) ,
82+ ) ,
7883 formatBorderRow ( 'middle' , columnWidths , options ) ,
7984 ]
8085 : [ ] ) ,
81- ...table . rows . map ( cells => formatContentRow ( cells , columnWidths , options ) ) ,
86+ ...table . rows . flatMap ( row =>
87+ wrapRow ( row , columnWidths ) . map ( cells =>
88+ formatContentRow ( cells , columnWidths , options ) ,
89+ ) ,
90+ ) ,
8291 formatBorderRow ( 'bottom' , columnWidths , options ) ,
8392 ]
8493 . filter ( Boolean )
@@ -126,6 +135,62 @@ function formatContentRow(
126135 return `${ ansis . dim ( BORDERS . single . vertical ) } ${ spaces } ${ inner } ${ spaces } ${ ansis . dim ( BORDERS . single . vertical ) } ` ;
127136}
128137
138+ function wrapRow ( cells : TableCell [ ] , columnWidths : number [ ] ) : TableCell [ ] [ ] {
139+ const emptyCell : TableCell = { text : '' , alignment : DEFAULT_ALIGNMENT } ;
140+
141+ return cells . reduce < TableCell [ ] [ ] > ( ( acc , cell , colIndex ) => {
142+ const wrapped : string = wrapText ( cell . text , columnWidths [ colIndex ] ) ;
143+ const lines = wrapped . split ( '\n' ) . filter ( Boolean ) ;
144+
145+ const rowCount = Math . max ( acc . length , lines . length ) ;
146+
147+ return Array . from ( { length : rowCount } ) . map ( ( _ , rowIndex ) => {
148+ const prevCols =
149+ acc [ rowIndex ] ?? Array . from ( { length : colIndex } ) . map ( ( ) => emptyCell ) ;
150+ const currCol = { ...cell , text : lines [ rowIndex ] ?? '' } ;
151+ return [ ...prevCols , currCol ] ;
152+ } ) ;
153+ } , [ ] ) ;
154+ }
155+
156+ function wrapText ( text : string , width : number | undefined ) : string {
157+ if ( ! width || stringWidth ( text ) <= width ) {
158+ return text ;
159+ }
160+ const words = extractWords ( text ) ;
161+ const longWords = words . filter ( word => word . length > width ) ;
162+ const replacements = longWords . map ( original => {
163+ const parts = original . includes ( '-' )
164+ ? original . split ( '-' )
165+ : partitionString ( original , width - 1 ) ;
166+ const replacement = parts . join ( '-\n' ) ;
167+ return { original, replacement } ;
168+ } ) ;
169+ const textWithSplitLongWords = replacements . reduce (
170+ ( acc , { original, replacement } ) => acc . replace ( original , replacement ) ,
171+ text ,
172+ ) ;
173+ return wrapAnsi ( textWithSplitLongWords , width ) ;
174+ }
175+
176+ function extractWords ( text : string ) : string [ ] {
177+ return ansis
178+ . strip ( text )
179+ . split ( ' ' )
180+ . map ( word => word . trim ( ) ) ;
181+ }
182+
183+ function partitionString ( text : string , maxChars : number ) : string [ ] {
184+ const groups = [ ...text ] . reduce < Record < number , string [ ] > > (
185+ ( acc , char , index ) => {
186+ const key = Math . floor ( index / maxChars ) ;
187+ return { ...acc , [ key ] : [ ...( acc [ key ] ?? [ ] ) , char ] } ;
188+ } ,
189+ { } ,
190+ ) ;
191+ return Object . values ( groups ) . map ( chars => chars . join ( '' ) ) ;
192+ }
193+
129194function alignText (
130195 text : string ,
131196 alignment : TableAlignment ,
@@ -147,19 +212,92 @@ function alignText(
147212 }
148213}
149214
150- function getColumnWidths ( table : NormalizedTable ) : number [ ] {
215+ function getColumnWidths (
216+ table : NormalizedTable ,
217+ options : NormalizedOptions ,
218+ ) : number [ ] {
219+ const columnTexts = getColumnTexts ( table ) ;
220+ const columnStats = aggregateColumnsStats ( columnTexts ) ;
221+ return adjustColumnWidthsToMax ( columnStats , options ) ;
222+ }
223+
224+ function getColumnTexts ( table : NormalizedTable ) : string [ ] [ ] {
151225 const columnCount = table . columns ?. length ?? table . rows [ 0 ] ?. length ?? 0 ;
152226 return Array . from ( { length : columnCount } ) . map ( ( _ , index ) => {
153227 const cells : TableCell [ ] = [
154228 table . columns ?. [ index ] ,
155229 ...table . rows . map ( row => row [ index ] ) ,
156230 ] . filter ( cell => cell != null ) ;
157- const texts = cells . map ( cell => cell . text ) ;
231+ return cells . map ( cell => cell . text ) ;
232+ } ) ;
233+ }
234+
235+ function aggregateColumnsStats ( columnTexts : string [ ] [ ] ) : ColumnStats [ ] {
236+ return columnTexts . map ( texts => {
158237 const widths = texts . map ( text => stringWidth ( text ) ) ;
159- return Math . max ( ...widths ) ;
238+ const longestWords = texts
239+ . flatMap ( extractWords )
240+ . toSorted ( ( a , b ) => b . length - a . length ) ;
241+ return {
242+ maxWidth : Math . max ( ...widths ) ,
243+ maxWord : longestWords [ 0 ] ?? '' ,
244+ } ;
160245 } ) ;
161246}
162247
248+ function adjustColumnWidthsToMax (
249+ columnStats : ColumnStats [ ] ,
250+ options : NormalizedOptions ,
251+ ) : number [ ] {
252+ const tableWidth = getTableWidth ( columnStats , options ) ;
253+ if ( tableWidth <= options . maxWidth ) {
254+ return columnStats . map ( ( { maxWidth } ) => maxWidth ) ;
255+ }
256+ const overflow = tableWidth - options . maxWidth ;
257+
258+ return truncateColumns ( columnStats , overflow ) ;
259+ }
260+
261+ function truncateColumns (
262+ columnStats : ColumnStats [ ] ,
263+ overflow : number ,
264+ ) : number [ ] {
265+ const sortedColumns = columnStats
266+ . map ( ( stats , index ) => ( { ...stats , index } ) )
267+ . toSorted (
268+ ( a , b ) => b . maxWidth - a . maxWidth || b . maxWord . length - a . maxWord . length ,
269+ ) ;
270+
271+ let remaining = overflow ;
272+ const newWidths = new Map < number , number > ( ) ;
273+ for ( const { index, maxWidth, maxWord } of sortedColumns ) {
274+ const newWidth = Math . max (
275+ maxWidth - remaining ,
276+ Math . ceil ( maxWidth / 2 ) ,
277+ Math . ceil ( maxWord . length / 2 ) + 1 ,
278+ ) ;
279+ newWidths . set ( index , newWidth ) ;
280+ remaining -= maxWidth - newWidth ;
281+ if ( remaining <= 0 ) {
282+ break ;
283+ }
284+ }
285+ return columnStats . map (
286+ ( { maxWidth } , index ) => newWidths . get ( index ) ?? maxWidth ,
287+ ) ;
288+ }
289+
290+ function getTableWidth (
291+ columnStats : ColumnStats [ ] ,
292+ options : NormalizedOptions ,
293+ ) : number {
294+ const contents = columnStats . reduce ( ( acc , { maxWidth } ) => acc + maxWidth , 0 ) ;
295+ const paddings =
296+ options . padding * columnStats . length * 2 - ( options . borderless ? 2 : 0 ) ;
297+ const borders = options . borderless ? 0 : columnStats . length + 1 ;
298+ return contents + paddings + borders ;
299+ }
300+
163301function normalizeTable ( table : Table ) : NormalizedTable {
164302 const rows = normalizeTableRows ( table . rows , table . columns ) ;
165303 const columns = normalizeTableColumns ( table . columns ) ;
0 commit comments