@prairielearn/formatter 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @prairielearn/formatter
2
2
 
3
+ ## 2.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3c4799a: Upgrade all JavaScript dependencies
8
+
9
+ ## 2.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 3d31293: Add `truncateMiddle` function that truncates a string in the middle, preserving both the start and end to maintain recognizability
14
+
3
15
  ## 2.1.0
4
16
 
5
17
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { formatDate, formatDateFriendly, formatDateHMS, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
2
  export { DAY_IN_MILLISECONDS, formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, HOUR_IN_MILLISECONDS, makeInterval, MINUTE_IN_MILLISECONDS, SECOND_IN_MILLISECONDS, } from './interval.js';
3
+ export { truncateMiddle } from './string.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\nexport { truncateMiddle } from './string.js';\n"]}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { formatDate, formatDateFriendly, formatDateHMS, formatDateRangeFriendly, formatDateWithinRange, formatDateYMD, formatDateYMDHM, formatTz, } from './date.js';
2
2
  export { DAY_IN_MILLISECONDS, formatInterval, formatIntervalHM, formatIntervalMinutes, formatIntervalRelative, HOUR_IN_MILLISECONDS, makeInterval, MINUTE_IN_MILLISECONDS, SECOND_IN_MILLISECONDS, } from './interval.js';
3
+ export { truncateMiddle } from './string.js';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,qBAAqB,EACrB,aAAa,EACb,eAAe,EACf,QAAQ,GACT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,EACpB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC","sourcesContent":["export {\n formatDate,\n formatDateFriendly,\n formatDateHMS,\n formatDateRangeFriendly,\n formatDateWithinRange,\n formatDateYMD,\n formatDateYMDHM,\n formatTz,\n} from './date.js';\nexport {\n DAY_IN_MILLISECONDS,\n formatInterval,\n formatIntervalHM,\n formatIntervalMinutes,\n formatIntervalRelative,\n HOUR_IN_MILLISECONDS,\n makeInterval,\n MINUTE_IN_MILLISECONDS,\n SECOND_IN_MILLISECONDS,\n} from './interval.js';\nexport { truncateMiddle } from './string.js';\n"]}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export declare function truncateMiddle(str: string, maxLength: number): string;
8
+ //# sourceMappingURL=string.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.d.ts","sourceRoot":"","sources":["../src/string.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAYrE","sourcesContent":["/**\n * Truncates a string in the middle, preserving both the start and end to\n * maintain recognizability. Uses a 60/40 split favoring the start.\n * For example, \"CS 101 Proficiency Exam\" at maxLength 16 becomes\n * \"CS 101 P... Exam\".\n */\nexport function truncateMiddle(str: string, maxLength: number): string {\n if (str.length <= maxLength) {\n return str;\n }\n const ellipsis = '...';\n const remaining = Math.max(0, maxLength - ellipsis.length);\n const startLength = Math.ceil(remaining * 0.6);\n const endLength = remaining - startLength;\n if (endLength <= 0) {\n return str.slice(0, remaining) + ellipsis;\n }\n return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);\n}\n"]}
package/dist/string.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export function truncateMiddle(str, maxLength) {
8
+ if (str.length <= maxLength) {
9
+ return str;
10
+ }
11
+ const ellipsis = '...';
12
+ const remaining = Math.max(0, maxLength - ellipsis.length);
13
+ const startLength = Math.ceil(remaining * 0.6);
14
+ const endLength = remaining - startLength;
15
+ if (endLength <= 0) {
16
+ return str.slice(0, remaining) + ellipsis;
17
+ }
18
+ return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);
19
+ }
20
+ //# sourceMappingURL=string.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.js","sourceRoot":"","sources":["../src/string.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,SAAiB,EAAU;IACrE,IAAI,GAAG,CAAC,MAAM,IAAI,SAAS,EAAE,CAAC;QAC5B,OAAO,GAAG,CAAC;IACb,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC;IACvB,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC;IAC/C,MAAM,SAAS,GAAG,SAAS,GAAG,WAAW,CAAC;IAC1C,IAAI,SAAS,IAAI,CAAC,EAAE,CAAC;QACnB,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC;IAC5C,CAAC;IACD,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,GAAG,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAAA,CACjF","sourcesContent":["/**\n * Truncates a string in the middle, preserving both the start and end to\n * maintain recognizability. Uses a 60/40 split favoring the start.\n * For example, \"CS 101 Proficiency Exam\" at maxLength 16 becomes\n * \"CS 101 P... Exam\".\n */\nexport function truncateMiddle(str: string, maxLength: number): string {\n if (str.length <= maxLength) {\n return str;\n }\n const ellipsis = '...';\n const remaining = Math.max(0, maxLength - ellipsis.length);\n const startLength = Math.ceil(remaining * 0.6);\n const endLength = remaining - startLength;\n if (endLength <= 0) {\n return str.slice(0, remaining) + ellipsis;\n }\n return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=string.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.test.d.ts","sourceRoot":"","sources":["../src/string.test.ts"],"names":[],"mappings":"","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport { truncateMiddle } from './string.js';\n\ndescribe('truncateMiddle', () => {\n it('should not truncate a string that fits within maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');\n });\n\n it('should not truncate a string that exactly matches maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');\n });\n\n it('should truncate in the middle preserving start and end', () => {\n assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');\n });\n\n it('should favor the start of the string', () => {\n assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');\n });\n\n it('should handle realistic course names at limit 16', () => {\n assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');\n });\n\n it('should handle realistic term that is one char too long', () => {\n assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');\n });\n\n it('should handle a very short maxLength', () => {\n assert.equal(truncateMiddle('hello world', 4), 'h...');\n });\n\n it('should handle maxLength of 3', () => {\n assert.equal(truncateMiddle('hello world', 3), '...');\n });\n\n it('should handle an empty string', () => {\n assert.equal(truncateMiddle('', 10), '');\n });\n});\n"]}
@@ -0,0 +1,32 @@
1
+ import { assert, describe, it } from 'vitest';
2
+ import { truncateMiddle } from './string.js';
3
+ describe('truncateMiddle', () => {
4
+ it('should not truncate a string that fits within maxLength', () => {
5
+ assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');
6
+ });
7
+ it('should not truncate a string that exactly matches maxLength', () => {
8
+ assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');
9
+ });
10
+ it('should truncate in the middle preserving start and end', () => {
11
+ assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');
12
+ });
13
+ it('should favor the start of the string', () => {
14
+ assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');
15
+ });
16
+ it('should handle realistic course names at limit 16', () => {
17
+ assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');
18
+ });
19
+ it('should handle realistic term that is one char too long', () => {
20
+ assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');
21
+ });
22
+ it('should handle a very short maxLength', () => {
23
+ assert.equal(truncateMiddle('hello world', 4), 'h...');
24
+ });
25
+ it('should handle maxLength of 3', () => {
26
+ assert.equal(truncateMiddle('hello world', 3), '...');
27
+ });
28
+ it('should handle an empty string', () => {
29
+ assert.equal(truncateMiddle('', 10), '');
30
+ });
31
+ });
32
+ //# sourceMappingURL=string.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"string.test.js","sourceRoot":"","sources":["../src/string.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE,CAAC;IAC/B,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE,CAAC;QAClE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC;IAAA,CAChE,CAAC,CAAC;IAEH,EAAE,CAAC,6DAA6D,EAAE,GAAG,EAAE,CAAC;QACtE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,aAAa,CAAC,CAAC;IAAA,CAChE,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE,CAAC;QACjE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,2BAA2B,EAAE,EAAE,CAAC,EAAE,sBAAsB,CAAC,CAAC;IAAA,CACvF,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,4BAA4B,EAAE,EAAE,CAAC,EAAE,iBAAiB,CAAC,CAAC;IAAA,CACnF,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE,CAAC;QAC3D,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,yBAAyB,EAAE,EAAE,CAAC,EAAE,kBAAkB,CAAC,CAAC;IAAA,CACjF,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE,CAAC;QACjE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,YAAY,CAAC,CAAC;IAAA,CAC/D,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IAAA,CACxD,CAAC,CAAC;IAEH,EAAE,CAAC,8BAA8B,EAAE,GAAG,EAAE,CAAC;QACvC,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IAAA,CACvD,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,GAAG,EAAE,CAAC;QACxC,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAAA,CAC1C,CAAC,CAAC;AAAA,CACJ,CAAC,CAAC","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport { truncateMiddle } from './string.js';\n\ndescribe('truncateMiddle', () => {\n it('should not truncate a string that fits within maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');\n });\n\n it('should not truncate a string that exactly matches maxLength', () => {\n assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');\n });\n\n it('should truncate in the middle preserving start and end', () => {\n assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');\n });\n\n it('should favor the start of the string', () => {\n assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');\n });\n\n it('should handle realistic course names at limit 16', () => {\n assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');\n });\n\n it('should handle realistic term that is one char too long', () => {\n assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');\n });\n\n it('should handle a very short maxLength', () => {\n assert.equal(truncateMiddle('hello world', 4), 'h...');\n });\n\n it('should handle maxLength of 3', () => {\n assert.equal(truncateMiddle('hello world', 3), '...');\n });\n\n it('should handle an empty string', () => {\n assert.equal(truncateMiddle('', 10), '');\n });\n});\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/formatter",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,12 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@js-temporal/polyfill": "^0.5.1",
21
- "es-toolkit": "^1.44.0"
21
+ "es-toolkit": "^1.45.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@prairielearn/tsconfig": "^2.0.0",
25
- "@types/node": "^24.10.9",
26
- "@typescript/native-preview": "^7.0.0-dev.20260203.1",
25
+ "@types/node": "^24.11.0",
26
+ "@typescript/native-preview": "^7.0.0-dev.20260302.1",
27
27
  "@vitest/coverage-v8": "^4.0.18",
28
28
  "tsx": "^4.21.0",
29
29
  "typescript": "^5.9.3",
package/src/index.ts CHANGED
@@ -19,3 +19,4 @@ export {
19
19
  MINUTE_IN_MILLISECONDS,
20
20
  SECOND_IN_MILLISECONDS,
21
21
  } from './interval.js';
22
+ export { truncateMiddle } from './string.js';
@@ -0,0 +1,41 @@
1
+ import { assert, describe, it } from 'vitest';
2
+
3
+ import { truncateMiddle } from './string.js';
4
+
5
+ describe('truncateMiddle', () => {
6
+ it('should not truncate a string that fits within maxLength', () => {
7
+ assert.equal(truncateMiddle('ECE-GY 6913', 20), 'ECE-GY 6913');
8
+ });
9
+
10
+ it('should not truncate a string that exactly matches maxLength', () => {
11
+ assert.equal(truncateMiddle('ECE-GY 6913', 11), 'ECE-GY 6913');
12
+ });
13
+
14
+ it('should truncate in the middle preserving start and end', () => {
15
+ assert.equal(truncateMiddle('Introduction to Computing', 20), 'Introductio...puting');
16
+ });
17
+
18
+ it('should favor the start of the string', () => {
19
+ assert.equal(truncateMiddle('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 15), 'ABCDEFGH...WXYZ');
20
+ });
21
+
22
+ it('should handle realistic course names at limit 16', () => {
23
+ assert.equal(truncateMiddle('CS 101 Proficiency Exam', 16), 'CS 101 P... Exam');
24
+ });
25
+
26
+ it('should handle realistic term that is one char too long', () => {
27
+ assert.equal(truncateMiddle('Spring 2023', 10), 'Sprin...23');
28
+ });
29
+
30
+ it('should handle a very short maxLength', () => {
31
+ assert.equal(truncateMiddle('hello world', 4), 'h...');
32
+ });
33
+
34
+ it('should handle maxLength of 3', () => {
35
+ assert.equal(truncateMiddle('hello world', 3), '...');
36
+ });
37
+
38
+ it('should handle an empty string', () => {
39
+ assert.equal(truncateMiddle('', 10), '');
40
+ });
41
+ });
package/src/string.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Truncates a string in the middle, preserving both the start and end to
3
+ * maintain recognizability. Uses a 60/40 split favoring the start.
4
+ * For example, "CS 101 Proficiency Exam" at maxLength 16 becomes
5
+ * "CS 101 P... Exam".
6
+ */
7
+ export function truncateMiddle(str: string, maxLength: number): string {
8
+ if (str.length <= maxLength) {
9
+ return str;
10
+ }
11
+ const ellipsis = '...';
12
+ const remaining = Math.max(0, maxLength - ellipsis.length);
13
+ const startLength = Math.ceil(remaining * 0.6);
14
+ const endLength = remaining - startLength;
15
+ if (endLength <= 0) {
16
+ return str.slice(0, remaining) + ellipsis;
17
+ }
18
+ return str.slice(0, startLength) + ellipsis + str.slice(str.length - endLength);
19
+ }