tablemark
Version:
Generate markdown tables from a list of objects or JSON data.
395 lines (388 loc) • 15.7 kB
JavaScript
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 };