diff --git a/.changeset/chilled-ducks-dream.md b/.changeset/chilled-ducks-dream.md new file mode 100644 index 00000000..fb663765 --- /dev/null +++ b/.changeset/chilled-ducks-dream.md @@ -0,0 +1,5 @@ +--- +"moderndash": minor +--- + +Add `truncate` function diff --git a/benchmark/string/truncate.bench.ts b/benchmark/string/truncate.bench.ts new file mode 100644 index 00000000..c6a3bd44 --- /dev/null +++ b/benchmark/string/truncate.bench.ts @@ -0,0 +1,21 @@ +import { truncate as lodashVersion } from "lodash-es"; +import { truncate } from "moderndash"; +import { bench, describe } from "vitest"; + +import { randomStringArray } from "../testData.js"; + +describe("truncate", () => { + const stringArray = randomStringArray(200); + + bench("moderndash", () => { + for (const str of stringArray) { + truncate(str); + } + }); + + bench("lodash", () => { + for (const str of stringArray) { + lodashVersion(str); + } + }); +}); diff --git a/package/src/string/index.ts b/package/src/string/index.ts index df5e13b6..5f3d48a4 100644 --- a/package/src/string/index.ts +++ b/package/src/string/index.ts @@ -13,3 +13,4 @@ export * from "./trim"; export * from "./trimEnd"; export * from "./trimStart"; export * from "./unescapeHtml"; +export * from "./truncate"; diff --git a/package/src/string/truncate.ts b/package/src/string/truncate.ts new file mode 100644 index 00000000..35c4be69 --- /dev/null +++ b/package/src/string/truncate.ts @@ -0,0 +1,61 @@ +type Options = { + /** + * The maximum string length. + * + * Default: 30 + */ + length?: number; + + /** + * The string to indicate text is omitted. + * + * Also named [ellipsis](https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow) + * + * Default: "...", you might want to use "…" (… U+02026) instead + */ + omission?: string; + + /** + * The separator pattern to truncate to. + * + * Default: none + */ + separator?: string; +}; + +/** + * Truncates a string if it's longer than the given maximum length. + * The last characters of the truncated string are replaced with the omission + * string which defaults to "...". + * + * @param str The string to truncate + * @param options The options object + * @returns The truncated string + */ +export function truncate(str: string, options?: Options) { + // https://stackoverflow.com/q/1199352 + // https://github.com/Maggi64/moderndash/issues/155 + // https://lodash.com/docs/4.17.15#truncate + + const { length = 30, omission = "...", separator } = options ?? {}; + + if (str.length <= length) { + return str; + } + + let maxLength = length - omission.length; + if (maxLength < 0) { + maxLength = 0; + } + const subString = str.slice( + 0, + // FYI .slice() is OK if maxLength > text.length + maxLength + ); + + return ( + (separator + ? subString.slice(0, subString.lastIndexOf(separator)) + : subString) + omission + ); +} diff --git a/package/test/string/truncate.test.ts b/package/test/string/truncate.test.ts new file mode 100644 index 00000000..37d6eff4 --- /dev/null +++ b/package/test/string/truncate.test.ts @@ -0,0 +1,54 @@ +import { truncate } from "@string/truncate"; + +// Copy-pasted and adapted from https://github.com/lodash/lodash/blob/c7c70a7da5172111b99bb45e45532ed034d7b5b9/test/truncate.spec.js +// See also https://github.com/lodash/lodash/pull/5815 + +const string = "hi-diddly-ho there, neighborino"; + +it("should use a default `length` of `30`", () => { + expect(truncate(string)).toBe("hi-diddly-ho there, neighbo..."); +}); + +it("should not truncate if `string` is <= `length`", () => { + expect(truncate(string, { length: string.length })).toBe(string); + expect(truncate(string, { length: string.length + 2 })).toBe(string); +}); + +it("should truncate string the given length", () => { + expect(truncate(string, { length: 24 })).toBe("hi-diddly-ho there, n..."); +}); + +it("should support a `omission` option", () => { + expect(truncate(string, { omission: " [...]" })).toBe( + "hi-diddly-ho there, neig [...]" + ); +}); + +it("should support empty `omission` option", () => { + expect(truncate(string, { omission: "" })).toBe( + "hi-diddly-ho there, neighborin" + ); +}); + +it("should support a `length` option", () => { + expect(truncate(string, { length: 4 })).toBe("h..."); +}); + +it("should support a `separator` option", () => { + expect(truncate(string, { length: 24, separator: " " })).toBe( + "hi-diddly-ho there,..." + ); +}); + +it("should treat negative `length` as `0`", () => { + [0, -2].forEach((length) => { + expect(truncate(string, { length })).toBe("..."); + }); +}); + +it("should work as an iteratee for methods like `_.map`", () => { + const actual = [string, string, string].map((str) => truncate(str)); + const truncated = "hi-diddly-ho there, neighbo..."; + + expect(actual).toEqual([truncated, truncated, truncated]); +});