UNPKG

tablemark

Version:

Generate markdown tables from a list of objects or JSON data.

395 lines (388 loc) 15.7 kB
import * as changeCase from "change-case"; import getAnsiRegex from "ansi-regex"; import stringWidth from "string-width"; import wordwrap from "wordwrapjs"; import wrapAnsi from "wrap-ansi"; //#region src/constants.ts const alignmentOptions = { left: "left", center: "center", right: "right" }; const headerCaseOptions = Object.fromEntries([...Object.keys(changeCase).filter((key) => key.endsWith("Case")).map((key) => [key, key]), ["preserve", "preserve"]]); const unknownKeyStrategies = { ignore: "ignore", throw: "throw" }; const overflowStrategies = { wrap: "wrap", truncateStart: "truncateStart", truncateEnd: "truncateEnd" }; const lineBreakStrategies = { preserve: "preserve", truncate: "truncate", strip: "strip" }; const textHandlingStrategies = { auto: "auto", basic: "basic", advanced: "advanced" }; const columnsMinimumWidth = 3; const ansiCodeRegex = /\u001B/g; const lineEndingRegex = /\r?\n/g; const truncationCharacter = "…"; //#endregion //#region src/transformAnsiString.ts const ansiRegex$1 = getAnsiRegex(); const splitAnsi = (string) => { const parts = string.match(ansiRegex$1); if (!parts) return [string]; const result = []; let offset = 0; let pointer = 0; for (const part of parts) { offset = string.indexOf(part, offset); if (offset === -1) throw new Error("Could not split string"); if (pointer !== offset) result.push(string.slice(pointer, offset)); if (pointer === offset && result.length > 0) result[result.length - 1] += part; else { if (offset === 0) result.push(""); result.push(part); } pointer = offset + part.length; } result.push(string.slice(pointer)); return result; }; const transformAnsiString = (string, transform) => splitAnsi(string).map((part, index) => { if (index % 2 !== 0) return part; return transform(part); }).join(""); //#endregion //#region src/utilities.ts const pipeRegex = /\|/g; const ansiRegex = getAnsiRegex({ onlyFirst: true }); /** * Check if the given `string` contains special characters or character * sequences that require special handling, such as multi-byte emojis, ANSI * styles, or CJK characters. */ const hasSpecialCharacters = (string) => ansiRegex.test(string) || string.length !== stringWidth(string); const basicStringWrap = (string, width) => wordwrap.lines(string, { width, break: true }); const advancedStringWrap = (string, width) => wrapAnsi(string, width, { hard: true }).split("\n"); /** * Wrap the given `string` to the specified `width` using the "advanced" * strategy if the string contains special characters or character sequences. */ const autoStringWrap = (string, width) => { if (hasSpecialCharacters(string)) return advancedStringWrap(string, width); return basicStringWrap(string, width); }; /** * Check if the column at `columnIndex` is affected by any truncation, either * for the body or the header, according to the column descriptor or the root * configuration. */ const getIsSomeTruncateStrategy = (config, columnIndex) => { const isColumnSomeTruncateStrategy = config.columns[columnIndex]?.overflowStrategy?.includes("truncate") || config.columns[columnIndex]?.overflowHeaderStrategy?.includes("truncate") || false; const isRootSomeTruncateStrategy = config.overflowStrategy.includes("truncate") || config.overflowHeaderStrategy.includes("truncate"); return isColumnSomeTruncateStrategy || isRootSomeTruncateStrategy; }; /** * Get the overflow strategy for the column at `columnIndex` according to the * `overflowStrategy` specified in the column descriptor or the root * configuration. */ const getOverflowStrategy = (config, columnIndex, isHeader = false) => { const { overflowStrategy = config.overflowStrategy, overflowHeaderStrategy = config.overflowHeaderStrategy } = config.columns[columnIndex] ?? {}; return isHeader ? overflowHeaderStrategy : overflowStrategy; }; /** * Get the string wrapping function for the column at `columnIndex` according to * the `textHandlingStrategy` specified in the column descriptor or the root * configuration. */ const getStringWrapMethod = (config, columnIndex) => { const { textHandlingStrategy = config.textHandlingStrategy } = config.columns[columnIndex] ?? {}; switch (textHandlingStrategy) { case textHandlingStrategies.auto: return autoStringWrap; case textHandlingStrategies.advanced: return advancedStringWrap; case textHandlingStrategies.basic: return basicStringWrap; } }; const advancedStringWidth = (string, shouldCountAnsiEscapeCodes = false) => { const width = stringWidth(string, { countAnsiEscapeCodes: shouldCountAnsiEscapeCodes }); if (shouldCountAnsiEscapeCodes) return width + (string.match(ansiCodeRegex) ?? []).length; return width; }; const autoStringWidth = (string, shouldCountAnsiEscapeCodes) => { if (hasSpecialCharacters(string)) return advancedStringWidth(string, shouldCountAnsiEscapeCodes); return string.length; }; /** * Pad the given string `content` to the given `width` according to * `alignment`. * * Note that `content` is expected to be a string _not_ containing newlines. */ const pad = (config, content, _columnIndex, alignment, width) => { const contentWidth = autoStringWidth(content, config.countAnsiEscapeCodes); if (alignment == null || alignment === alignmentOptions.left) return content + " ".repeat(Math.max(0, width - contentWidth)); if (alignment === alignmentOptions.right) return " ".repeat(Math.max(0, width - contentWidth)) + content; const remainder = Math.max(0, (width - contentWidth) % 2); const sides = Math.max(0, (width - contentWidth - remainder) / 2); return " ".repeat(sides) + content + " ".repeat(sides + remainder); }; /** * The default cell content transformer. */ const toCellText = ({ value }) => { if (value === void 0) return ""; return String(value).replaceAll(pipeRegex, "\\|"); }; const defaultOptions = { align: alignmentOptions.left, columns: [], countAnsiEscapeCodes: false, headerCase: "sentenceCase", lineBreakStrategy: lineBreakStrategies.preserve, lineEnding: "\n", maxWidth: Number.POSITIVE_INFINITY, overflowStrategy: overflowStrategies.wrap, overflowHeaderStrategy: overflowStrategies.wrap, padHeaderSeparator: true, stringWidthMethod: autoStringWidth, stringWrapMethod: autoStringWrap, toCellText, unknownKeyStrategy: unknownKeyStrategies.ignore, textHandlingStrategy: textHandlingStrategies.auto, wrapWithGutters: false }; const handleDeprecatedOptions = (options) => { if (options.wrapWidth != null && options.maxWidth == null) { options.maxWidth = options.wrapWidth; delete options.wrapWidth; } if (options.caseHeaders != null && options.headerCase == null) { options.headerCase = options.caseHeaders ? "sentenceCase" : "preserve"; delete options.caseHeaders; } return options; }; const normalizeOptions = (options) => { handleDeprecatedOptions(options); const { columns,...rest } = options; const normalizedOptions = { ...defaultOptions, ...rest }; switch (normalizedOptions.textHandlingStrategy) { case textHandlingStrategies.auto: normalizedOptions.stringWidthMethod = autoStringWidth; normalizedOptions.stringWrapMethod = autoStringWrap; break; case textHandlingStrategies.advanced: normalizedOptions.stringWidthMethod = advancedStringWidth; normalizedOptions.stringWrapMethod = advancedStringWrap; break; case textHandlingStrategies.basic: normalizedOptions.stringWidthMethod = autoStringWidth; normalizedOptions.stringWrapMethod = basicStringWrap; break; } normalizedOptions.columns = columns?.map((descriptor) => { if (typeof descriptor === "string") return { name: descriptor }; return { name: descriptor.name, align: descriptor.align ?? normalizedOptions.align, maxWidth: descriptor.maxWidth ?? normalizedOptions.maxWidth, overflowStrategy: descriptor.overflowStrategy ?? normalizedOptions.overflowStrategy, overflowHeaderStrategy: descriptor.overflowHeaderStrategy ?? normalizedOptions.overflowHeaderStrategy, textHandlingStrategy: descriptor.textHandlingStrategy ?? normalizedOptions.textHandlingStrategy, width: descriptor.width }; }) ?? []; return normalizedOptions; }; /** * Replace all line breaks in `text` with the given `replacementCharacter`. */ const stripLineBreaks = (text, replacementCharacter = " ") => { return text.replaceAll(lineEndingRegex, replacementCharacter); }; /** * Calculate the visual width of a given multiline text by finding its longest * line. */ const getMaxStringWidth = (config, _columnIndex, value) => { const text = String(value); if (text.includes("\n")) return (config.lineBreakStrategy === lineBreakStrategies.strip ? [stripLineBreaks(text)] : text.split(lineEndingRegex, config.lineBreakStrategy === lineBreakStrategies.truncate ? 1 : void 0)).reduce((currentMax, nextString) => Math.max(currentMax, autoStringWidth(nextString, config.countAnsiEscapeCodes)), 0) + (config.lineBreakStrategy === lineBreakStrategies.truncate ? truncationCharacter.length : 0); return autoStringWidth(text, config.countAnsiEscapeCodes); }; /** * Convert the given string `value` to the specified `textCase`. */ const toTextCase = (value, textCase) => { if (textCase === "preserve") return value; const convertCase = changeCase[textCase]; return convertCase(value); }; //#endregion //#region src/data.ts const initializeProfile = (input, config) => { const maxWidthMap = /* @__PURE__ */ new Map(); const fieldSet = new Set(Object.keys(input[0])); return { data: input.map((record, recordIndex) => { let entries = Object.entries(record); if (config.unknownKeyStrategy === "ignore") entries = entries.filter(([key]) => fieldSet.has(key)); return Object.fromEntries(entries.map(([key, value], columnIndex) => { if (config.unknownKeyStrategy === "throw" && !fieldSet.has(key)) throw new RangeError(`Unexpected object key '${key}' at record index ${recordIndex}`); const cellValue = config.toCellText({ key, value }); const thisLength = getMaxStringWidth(config, columnIndex, cellValue); const entryLength = maxWidthMap.get(key); if (entryLength == null) maxWidthMap.set(key, thisLength); else maxWidthMap.set(key, Math.max(thisLength, entryLength)); return [key, cellValue]; })); }), maxWidthMap, alignments: [], keys: [], titles: [], widths: [] }; }; const getColumnTitle = (config, key, columnIndex) => { const { name: suppliedName } = config.columns[columnIndex] ?? {}; if (suppliedName) return suppliedName; const casedTitle = transformAnsiString(key, (part) => toTextCase(part, config.headerCase)); return config.toHeaderTitle?.({ key, title: casedTitle }) ?? casedTitle; }; const getDataProfile = (input, config) => { const profile = initializeProfile(input, config); const object = profile.data[0]; for (const [columnIndex, key] of Object.keys(object).entries()) { const title = getColumnTitle(config, key, columnIndex); const { align = config.align, maxWidth = config.maxWidth, width } = config.columns[columnIndex] ?? {}; const calculatedWidth = width ?? Math.min(Math.max(profile.maxWidthMap.get(key) ?? 0, getMaxStringWidth(config, columnIndex, title), columnsMinimumWidth), maxWidth); profile.keys.push(key); profile.titles.push(title); profile.alignments.push(align); profile.widths.push(calculatedWidth); } return profile; }; //#endregion //#region src/markdown.ts const getLine = (columns, config, forceGutters) => { const hasGutters = forceGutters ? true : config.wrapWithGutters; const columnCount = columns.length; let line = ""; line += hasGutters ? "|" : " "; line += " "; for (const [index, value] of columns.entries()) { line += value; line += " "; line += hasGutters ? "|" : " "; if (index < columnCount - 1) line += " "; } return line + config.lineEnding; }; const getRow = (columns, profile, config, isHeader = false) => { let rowHeight = 1; const cellValues = columns.map((value, columnIndex) => { const columnWidth = profile.widths[columnIndex] || 0; let valueWithLineBreakStrategy = value; switch (config.lineBreakStrategy) { case "preserve": break; case "strip": valueWithLineBreakStrategy = stripLineBreaks(value); break; case "truncate": if (value.includes("\n")) valueWithLineBreakStrategy = (value.split(lineEndingRegex, 1)[0] ?? "") + truncationCharacter; break; default: throw new RangeError(`Unknown line break strategy ${String(config.lineBreakStrategy)}`); } const overflowStrategy = getOverflowStrategy(config, columnIndex, isHeader); const cells = getStringWrapMethod(config, columnIndex)(valueWithLineBreakStrategy, columnWidth); switch (overflowStrategy) { case "wrap": rowHeight = Math.max(rowHeight, cells.length); return cells; case "truncateStart": { const lastCell = cells.at(-1) ?? ""; return cells.length === 1 ? cells : [truncationCharacter + lastCell]; } case "truncateEnd": { const firstCell = cells[0] ?? ""; return cells.length === 1 ? cells : [firstCell + truncationCharacter]; } default: throw new RangeError(`Unknown overflow strategy ${String(config.overflowStrategy)}`); } }); let row = ""; for (let rowIndex = 0; rowIndex < rowHeight; rowIndex++) { const line = []; for (const [columnIndex, cells] of cellValues.entries()) { const cellValue = cells.length > rowIndex ? cells[rowIndex] ?? "" : ""; const isSomeTruncateStrategy = getIsSomeTruncateStrategy(config, columnIndex); line.push(pad(config, cellValue, columnIndex, profile.alignments[columnIndex], (profile.widths[columnIndex] ?? columnsMinimumWidth) + (isSomeTruncateStrategy ? truncationCharacter.length : 0))); } row += getLine(line, config, rowIndex === 0); } return row; }; const getHeader = (profile, config) => { let result = getRow(profile.titles, profile, config, true); const gutterPadding = config.padHeaderSeparator ? " " : ""; result += `|${gutterPadding}`; const columnCount = profile.keys.length; for (let index = 0; index < columnCount; index++) { const paddingForTruncation = getIsSomeTruncateStrategy(config, index) ? truncationCharacter.length : 0; const requestedAlignment = profile.alignments[index]; const isCenterOrLeft = requestedAlignment === alignmentOptions.center || requestedAlignment === alignmentOptions.left; const isCenterOrRight = requestedAlignment === alignmentOptions.center || requestedAlignment === alignmentOptions.right; const dashWidthOffset = config.padHeaderSeparator ? 2 : 0; result += isCenterOrLeft ? ":" : "-"; result += "-".repeat((profile.widths[index] ?? columnsMinimumWidth) - dashWidthOffset + paddingForTruncation); result += isCenterOrRight ? ":" : "-"; result += `${gutterPadding}|`; if (index < columnCount - 1) result += gutterPadding; } return result + config.lineEnding; }; const getObjectRow = (object, profile, config) => getRow(Object.values(object), profile, config); const getRows = (profile, config) => { let allRows = ""; for (const object of profile.data) allRows += getObjectRow(object, profile, config); return allRows; }; const buildMarkdown = (input, config) => { let table = ""; const metadata = getDataProfile(input, config); table += getHeader(metadata, config); table += getRows(metadata, config); return table; }; //#endregion //#region src/index.ts const tablemark = (input, options = {}) => { if (typeof input[Symbol.iterator] !== "function") throw new TypeError(`Expected an iterable, got ${typeof input}`); if (input.length === 0) return ""; const config = normalizeOptions(options); return buildMarkdown(input, config); }; //#endregion export { alignmentOptions, headerCaseOptions, lineBreakStrategies, overflowStrategies, tablemark, textHandlingStrategies, toCellText, unknownKeyStrategies };