@hardlydifficult/text 1.0.19 → 1.0.20

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.
Files changed (2) hide show
  1. package/README.md +259 -180
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -19,300 +19,382 @@ import {
19
19
  buildFileTree,
20
20
  convertFormat,
21
21
  createLinker,
22
+ healYaml,
23
+ escapeFence,
22
24
  } from "@hardlydifficult/text";
23
25
 
24
- // Template replacement
25
- const greeting = replaceTemplate("Hello {{name}}!", { name: "Alice" });
26
- // "Hello Alice!"
26
+ // Replace template placeholders
27
+ replaceTemplate("Hello {{name}}!", { name: "World" });
28
+ // "Hello World!"
27
29
 
28
30
  // Split long text into chunks
29
- const chunks = chunkText("Line 1\nLine 2\nLine 3", 10);
30
- // ["Line 1\nLine 2", "Line 3"]
31
+ chunkText("This is a long text", 10);
32
+ // ["This is a", "long text"]
31
33
 
32
- // Convert to URL-safe slug
33
- const slug = slugify("My Feature Name!", 10);
34
- // "my-feature"
34
+ // Convert to URL-safe slugs
35
+ slugify("My Feature Name!");
36
+ // "my-feature-name"
35
37
 
36
- // Format duration in ms
37
- const formatted = formatDuration(125_000);
38
+ // Format durations
39
+ formatDuration(125_000);
38
40
  // "2m 5s"
39
41
 
40
- // Build a file tree
41
- const tree = buildFileTree(["src/index.ts", "src/utils.ts", "README.md"]);
42
- // src/
43
- // index.ts
44
- // utils.ts
45
- //
46
- // README.md
47
-
48
- // Convert between JSON/YAML
49
- const yaml = convertFormat('{"name":"Alice"}', "yaml");
50
- // name: Alice
42
+ // Build file trees
43
+ buildFileTree(["src/index.ts", "README.md"]);
44
+ // "src/\n index.ts\n\nREADME.md"
45
+
46
+ // Convert between JSON and YAML
47
+ convertFormat('{"name":"Alice"}', "yaml");
48
+ // "name: Alice\n"
49
+ convertFormat("name: Alice", "json");
50
+ // "{\n \"name\": \"Alice\"\n}"
51
+
52
+ // Apply link rules to text
53
+ const linker = createLinker().linear("my-org");
54
+ linker.apply("Fix ENG-533", { platform: "markdown" });
55
+ // "Fix [ENG-533](https://linear.app/my-org/issue/ENG-533)"
56
+
57
+ // Heal malformed YAML
58
+ healYaml("```yaml\nkey: value\n```");
59
+ // "key: value"
51
60
  ```
52
61
 
53
- ## Error Handling
62
+ ## Error Formatting
63
+
64
+ Consistent error handling utilities for message extraction and formatting.
65
+
66
+ ### `getErrorMessage`
54
67
 
55
- Consistent error extraction and formatting utilities.
68
+ Extract a message string from an unknown error.
56
69
 
57
70
  ```typescript
58
- import { getErrorMessage, formatError, formatErrorForLog } from "@hardlydifficult/text";
71
+ import { getErrorMessage } from "@hardlydifficult/text";
59
72
 
60
- const err = new Error("disk full");
73
+ getErrorMessage(new Error("something went wrong"));
74
+ // "something went wrong"
61
75
 
62
- getErrorMessage(err); // "disk full"
63
- formatError(err, "Failed to save"); // "Failed to save: disk full"
64
- formatErrorForLog(err); // "disk full"
76
+ getErrorMessage("plain string error");
77
+ // "plain string error"
78
+
79
+ getErrorMessage(42);
80
+ // "42"
65
81
  ```
66
82
 
67
- ### Functions
83
+ ### `formatError`
84
+
85
+ Format an error for user-facing output with optional context.
68
86
 
69
- | Function | Description |
70
- |----------|-------------|
71
- | `getErrorMessage(err)` | Extract message string from unknown error |
72
- | `formatError(err, context?)` | Format error with optional context prefix |
73
- | `formatErrorForLog(err)` | Format error for logging (non-Error → string) |
87
+ ```typescript
88
+ import { formatError } from "@hardlydifficult/text";
89
+
90
+ formatError(new Error("disk full"));
91
+ // "disk full"
92
+
93
+ formatError(new Error("disk full"), "Failed to save");
94
+ // "Failed to save: disk full"
95
+ ```
96
+
97
+ ### `formatErrorForLog`
98
+
99
+ Format an error for logging (includes more detail for non-Error types).
100
+
101
+ ```typescript
102
+ import { formatErrorForLog } from "@hardlydifficult/text";
103
+
104
+ formatErrorForLog(new Error("timeout"));
105
+ // "timeout"
106
+
107
+ formatErrorForLog({ code: 500 });
108
+ // "[object Object]"
109
+ ```
74
110
 
75
111
  ## Template Replacement
76
112
 
77
113
  Simple string interpolation using `{{variable}}` syntax.
78
114
 
115
+ ### `replaceTemplate`
116
+
117
+ Replace template placeholders with values.
118
+
79
119
  ```typescript
80
- import { replaceTemplate, extractPlaceholders } from "@hardlydifficult/text";
120
+ import { replaceTemplate } from "@hardlydifficult/text";
121
+
122
+ replaceTemplate("Hello {{name}}!", { name: "World" });
123
+ // "Hello World!"
81
124
 
82
- const text = replaceTemplate("Hello {{name}}! You are {{age}}.", {
125
+ replaceTemplate("{{greeting}}, {{name}}!", {
126
+ greeting: "Hi",
83
127
  name: "Alice",
84
- age: "30",
85
128
  });
86
- // "Hello Alice! You are 30."
129
+ // "Hi, Alice!"
87
130
 
88
- const vars = extractPlaceholders("{{greeting}}, {{name}}!");
89
- // ["greeting", "name"]
131
+ replaceTemplate("Hello {{name}}!", {});
132
+ // "Hello {{name}}!"
90
133
  ```
91
134
 
92
- ### Functions
135
+ ### `extractPlaceholders`
136
+
137
+ Extract all placeholder names from a template.
138
+
139
+ ```typescript
140
+ import { extractPlaceholders } from "@hardlydifficult/text";
141
+
142
+ extractPlaceholders("{{a}} and {{b}} and {{a}} again");
143
+ // ["a", "b"]
93
144
 
94
- | Function | Description |
95
- |----------|-------------|
96
- | `replaceTemplate(template, values)` | Replace all `{{variable}}` placeholders |
97
- | `extractPlaceholders(template)` | Return unique placeholder names |
145
+ extractPlaceholders("no placeholders here");
146
+ // []
147
+ ```
98
148
 
99
149
  ## Text Chunking
100
150
 
101
- Split long text into manageable chunks respecting natural breaks.
151
+ Split long text into manageable chunks, preferring natural break points.
102
152
 
103
153
  ```typescript
104
154
  import { chunkText } from "@hardlydifficult/text";
105
155
 
106
- const text = "line one\nline two\nline three";
107
- chunkText(text, 18);
156
+ chunkText("line one\nline two\nline three", 18);
108
157
  // ["line one\nline two", "line three"]
109
- ```
110
158
 
111
- ### Behavior
159
+ chunkText("word1 word2 word3 word4 word5", 17);
160
+ // ["word1 word2 word3", "word4 word5"]
112
161
 
113
- - Breaks on newlines first, then spaces
114
- - Falls back to hard breaks when no natural break point exists
115
- - Trims leading whitespace from subsequent chunks
162
+ chunkText("abcdefghijklmnopqrstuvwxyz", 10);
163
+ // ["abcdefghij", "klmnopqrst", "uvwxyz"]
164
+ ```
116
165
 
117
166
  ## Slugification
118
167
 
119
- Convert strings to URL/filename-safe slugs.
168
+ Convert strings into URL/filename-safe slugs.
120
169
 
121
170
  ```typescript
122
171
  import { slugify } from "@hardlydifficult/text";
123
172
 
124
- slugify("My Feature Name!"); // "my-feature-name"
125
- slugify("My Feature Name!", 10); // "my-feature"
126
- ```
173
+ slugify("My Feature Name!");
174
+ // "my-feature-name"
127
175
 
128
- ### Features
176
+ slugify("My Feature Name!", 10);
177
+ // "my-feature"
129
178
 
130
- - Lowercases and replaces non-alphanumeric runs with single hyphens
131
- - Trims leading/trailing hyphens
132
- - Optional `maxLength` truncates at hyphen boundary when possible
179
+ slugify(" Hello World ");
180
+ // "hello-world"
181
+ ```
133
182
 
134
183
  ## Duration Formatting
135
184
 
136
- Format milliseconds as human-readable duration strings.
185
+ Format duration in milliseconds as a human-readable string.
137
186
 
138
187
  ```typescript
139
188
  import { formatDuration } from "@hardlydifficult/text";
140
189
 
141
- formatDuration(125_000); // "2m 5s"
142
- formatDuration(3_600_000); // "1h"
143
- formatDuration(500); // "<1s"
144
- ```
190
+ formatDuration(125_000);
191
+ // "2m 5s"
145
192
 
146
- ### Format Rules
193
+ formatDuration(3_600_000);
194
+ // "1h"
147
195
 
148
- | Duration | Output |
149
- |----------|--------|
150
- | < 1000ms | `<1s` |
151
- | 1–59 seconds | `<seconds>s` |
152
- | 1–59 minutes | `<minutes>m` or `<minutes>m <seconds>s` |
153
- | 1–23 hours | `<hours>h` or `<hours>h <minutes>m` |
154
- | ≥ 1 day | `<days>d` or `<days>d <hours>h` |
196
+ formatDuration(500);
197
+ // "<1s"
198
+ ```
155
199
 
156
200
  ## File Tree Rendering
157
201
 
158
- Build and render hierarchical file trees with depth-based truncation and annotations.
202
+ Build and render hierarchical file trees with depth-based truncation, annotations, and collapsed directory summaries.
159
203
 
160
204
  ```typescript
161
205
  import { buildFileTree, FILE_TREE_DEFAULTS } from "@hardlydifficult/text";
162
- import type { BuildTreeOptions } from "@hardlydifficult/text";
163
-
164
- const paths = [
165
- "src/index.ts",
166
- "test/unit/a.test.ts",
167
- "test/unit/b.test.ts",
168
- ];
169
-
170
- const options: BuildTreeOptions = {
171
- maxLevel2: 2,
172
- annotations: new Map([["src/index.ts", "Entry point"]]),
173
- collapseDirs: ["test"],
174
- };
175
-
176
- buildFileTree(paths, options);
177
- // src/
178
- // index.ts — Entry point
179
- //
180
- // test/
181
- // (2 files across 1 dir)
206
+
207
+ buildFileTree(["src/index.ts", "src/utils.ts", "README.md"]);
208
+ // "src/\n index.ts\n utils.ts\n\nREADME.md"
182
209
  ```
183
210
 
184
211
  ### Options
185
212
 
186
- | Option | Type | Default | Description |
187
- |--------|------|---------|-------------|
188
- | `maxLevel2` | `number` | 10 | Max children to show at depth 2 (top-level dirs) |
189
- | `maxLevel3` | `number` | 3 | Max children to show at depth 3 (files/dirs inside top dirs) |
190
- | `annotations` | `Map<string, string>` | `undefined` | File/dir descriptions |
191
- | `details` | `Map<string, string[]>` | `undefined` | Extra lines to show under file entries |
192
- | `collapseDirs` | `string[]` | `undefined` | Directory names to collapse and summarize |
213
+ | Parameter | Type | Description |
214
+ |----------------|-------------------------------------------|-----------------------------------------------------------------------------|
215
+ | `maxLevel2` | `number` | Maximum number of entries to show at level 2 (files in a directory) |
216
+ | `maxLevel3` | `number` | Maximum number of entries to show at level 3 (files in subdirectories) |
217
+ | `annotations` | `ReadonlyMap<string, string>` | Map of file/directory paths to annotation strings |
218
+ | `details` | `ReadonlyMap<string, readonly string[]>` | Map of file paths to extra detail lines to show under entries |
219
+ | `collapseDirs` | `readonly string[]` | Directory names to collapse with summary count |
220
+
221
+ ### Examples
222
+
223
+ **Annotations**
224
+
225
+ ```typescript
226
+ const annotations = new Map([
227
+ ["src/index.ts", "Main entry point"],
228
+ ["src", "Source code directory"],
229
+ ]);
230
+
231
+ buildFileTree(["src/index.ts"], { annotations });
232
+ // "src/ — Source code directory\n index.ts — Main entry point"
233
+ ```
234
+
235
+ **Details**
236
+
237
+ ```typescript
238
+ const details = new Map([
239
+ ["src/index.ts", ["> main (5-20): App entry point."]],
240
+ ]);
193
241
 
194
- ## Format Conversion
242
+ buildFileTree(["src/index.ts"], { details });
243
+ // "src/\n index.ts\n > main (5-20): App entry point."
244
+ ```
195
245
 
196
- Convert text between JSON and YAML with auto-detection.
246
+ **Collapsed directories**
247
+
248
+ ```typescript
249
+ buildFileTree(
250
+ ["src/index.ts", "test/unit/a.test.ts", "test/unit/b.test.ts"],
251
+ { collapseDirs: ["test"] }
252
+ );
253
+ // "src/\n index.ts\n\ntest/\n (2 files)"
254
+ ```
255
+
256
+ ## JSON/YAML Format Conversion
257
+
258
+ Convert between JSON and YAML with automatic input detection and clean output formatting.
197
259
 
198
260
  ```typescript
199
261
  import { convertFormat } from "@hardlydifficult/text";
200
- import type { TextFormat } from "@hardlydifficult/text";
201
262
 
202
- // JSON to YAML
203
- convertFormat('{"name":"Alice"}', "yaml");
204
- // name: Alice
263
+ convertFormat('{"name":"Alice","age":30}', "yaml");
264
+ // "name: Alice\nage: 30\n"
205
265
 
206
- // YAML to JSON (pretty-printed with 2-space indent)
207
266
  convertFormat("name: Alice\nage: 30", "json");
208
- // {
209
- // "name": "Alice",
210
- // "age": 30
211
- // }
267
+ // "{\n \"name\": \"Alice\",\n \"age\": 30\n}"
212
268
  ```
213
269
 
214
- ### Functions
270
+ ### `TextFormat`
215
271
 
216
- | Function | Description |
217
- |----------|-------------|
218
- | `convertFormat(content, to)` | Parse input and re-serialize to `json` or `yaml` |
272
+ Type alias for output format: `"json"` or `"yaml"`.
219
273
 
220
274
  ## YAML Formatting
221
275
 
222
- Serialize data to clean YAML, using block literals for long strings containing `: `.
276
+ Serialize data to clean YAML with intelligent block literal selection for long strings.
223
277
 
224
278
  ```typescript
225
279
  import { formatYaml } from "@hardlydifficult/text";
226
280
 
227
281
  formatYaml({
228
282
  purpose:
229
- "Core AI SDK: LLM integrations (Anthropic, Ollama) and streaming support.",
283
+ "Core AI SDK implementation: LLM integrations (Anthropic Claude, Ollama), agent orchestration with streaming.",
230
284
  });
285
+
286
+ // Uses block literal (|) for long strings containing ": "
231
287
  // purpose: |
232
- // Core AI SDK: LLM integrations (Anthropic, Ollama) and streaming support.
288
+ // Core AI SDK implementation: LLM integrations (Anthropic Claude, Ollama), agent orchestration with streaming.
233
289
  ```
234
290
 
235
- ### Behavior
236
-
237
- - Long strings (`>60 chars`) containing `: ` render as block literals (`|`)
238
- - Short strings and safe scalars remain plain or quoted as needed
239
- - Preserves round-trip parseability
240
-
241
291
  ## YAML Healing
242
292
 
243
- Sanitize malformed YAML from LLMs by stripping code fences and quoting scalar values containing colons.
293
+ Clean and repair YAML output from LLMs by stripping code fences and quoting problematic scalar values.
244
294
 
245
295
  ```typescript
246
296
  import { healYaml } from "@hardlydifficult/text";
247
- import { parse } from "yaml";
248
-
249
- const badYaml = `\`\`\`yaml
250
- purpose: |
251
- Core AI: LLM integrations (Anthropic, Ollama)
252
- description: Main deps: Node, TypeScript, Vitest.
253
- \`\`\``;
254
297
 
255
- const cleaned = healYaml(badYaml);
256
- // purpose: |
257
- // Core AI: LLM integrations (Anthropic, Ollama)
258
- // description: "Main deps: Node, TypeScript, Vitest."
298
+ healYaml("```yaml\nkey: value\n```");
299
+ // "key: value"
259
300
 
260
- parse(cleaned); // Parses successfully
301
+ healYaml('description: Development dependencies: Node types.');
302
+ // 'description: "Development dependencies: Node types."'
261
303
  ```
262
304
 
263
- ### Fixes Applied
305
+ ## Link Generation
264
306
 
265
- - Strips markdown code fences (` ```yaml ` or ` ``` `)
266
- - Quotes plain scalar values containing `: ` to avoid parse errors
307
+ Transform text with issue/PR references into formatted links across platforms like Slack, Discord, and Markdown.
267
308
 
268
- ## Linkification
309
+ ### `createLinker`
269
310
 
270
- Transform text with issue/PR references into formatted links.
311
+ Create a linker instance with optional initial rules.
271
312
 
272
313
  ```typescript
273
314
  import { createLinker } from "@hardlydifficult/text";
274
315
 
275
316
  const linker = createLinker()
276
- .linear("fairmint")
277
- .githubPr("Fairmint/api");
317
+ .linear("my-org")
318
+ .githubPr("my-org/my-repo");
278
319
 
279
- const output = linker.apply("Fix ENG-533 and PR#42", { platform: "slack" });
280
- // Fix <https://linear.app/fairmint/issue/ENG-533|ENG-533> <https://github.com/Fairmint/api/pull/42|PR#42>
320
+ linker.apply("Fix ENG-533 and PR#42", { platform: "slack" });
321
+ // "Fix <https://linear.app/my-org/issue/ENG-533|ENG-533> <https://github.com/my-org/my-repo/pull/42|PR#42>"
281
322
  ```
282
323
 
283
- ### Platforms
324
+ ### `Linker` Class
284
325
 
285
- | Platform | Output Format |
286
- |----------|---------------|
287
- | `slack` | `<href\|text>` |
288
- | `discord` | `[text](href)` |
289
- | `markdown` | `[text](href)` |
290
- | `plaintext` | `href` (raw URL) |
326
+ Stateful linker that applies configured rules to text.
291
327
 
292
- ### Linker Methods
328
+ **Methods:**
293
329
 
294
- | Method | Description |
295
- |--------|-------------|
296
- | `custom(pattern, toHref, options)` | Register a new rule with regex pattern and href builder |
297
- | `linear(workspace, options)` | Match `PROJECT-123` and link to Linear |
298
- | `githubPr(repository, options)` | Match `PR#123` and link to GitHub Pull Request |
299
- | `apply(text, options)` | Transform text with configured rules |
330
+ | Method | Description |
331
+ |----------------|------------------------------------------------------------------------|
332
+ | `addRule(rule)`| Add a custom link rule |
333
+ | `rule(...)` | Add a rule (supports fluent and named forms) |
334
+ | `custom(...)` | Add a custom rule with regex pattern and href builder |
335
+ | `linear(...)` | Add Linear issue reference rule (e.g., `ENG-533`) |
336
+ | `githubPr(...)`| Add GitHub PR reference rule (e.g., `PR#42`) |
337
+ | `apply(...)` | Apply linkification to text with options |
338
+ | `linkText(...)`| Alias for `apply` (same behavior) |
339
+
340
+ ### Rules
341
+
342
+ | Parameter | Type | Description |
343
+ |-----------|---------------------------|-----------------------------------------------------------------------------|
344
+ | `pattern` | `RegExp` | Match pattern (global flag is enforced automatically) |
345
+ | `href` | `string` | URL template (supports `$0`/`$&`, `$1..$N`) |
346
+ | `toHref` | `string \| LinkHrefBuilder`| Either href template or callback; takes precedence over `href` |
347
+ | `priority`| `number` | Higher priority wins for overlapping matches (default: `0`) |
300
348
 
301
349
  ### Options
302
350
 
303
- | Option | Type | Default | Description |
304
- |--------|------|---------|-------------|
305
- | `format` / `platform` | `LinkerPlatform` | `"markdown"` | Output format |
306
- | `skipCode` | `boolean` | `true` | Skip linkification inside code blocks/inline code |
307
- | `skipExistingLinks` | `boolean` | `true` | Skip linkification inside existing links |
351
+ | Parameter | Type | Description |
352
+ |-----------------------|-----------------------------|-------------------------------------------------------------------------|
353
+ | `format` / `platform` | `LinkerPlatform` | Output format: `"slack"`, `"discord"`, `"markdown"`, `"plaintext"` |
354
+ | `skipCode` | `boolean` | Skip linkification inside code spans (default: `true`) |
355
+ | `skipExistingLinks` | `boolean` | Skip linkification inside existing links (default: `true`) |
356
+
357
+ ### Platforms
358
+
359
+ | Platform | Format |
360
+ |--------------|---------------------------------|
361
+ | `slack` | `<href|text>` |
362
+ | `discord` | `[text](href)` |
363
+ | `markdown` | `[text](href)` |
364
+ | `plaintext` | `href` (raw URL) |
308
365
 
309
- ### LinkRule Options
366
+ ### Examples
310
367
 
311
- | Property | Type | Description |
312
- |----------|------|-------------|
313
- | `pattern` | `RegExp` | Match pattern (global matching enforced) |
314
- | `href` / `toHref` | `string` or `LinkHrefBuilder` | URL template (supports `$0`/`$&`, `$1`..`$N`) or callback |
315
- | `priority` | `number` | `0` | Higher priority wins overlapping matches |
368
+ **Custom rules**
369
+
370
+ ```typescript
371
+ const linker = createLinker().custom(
372
+ /\bINC-\d+\b/g,
373
+ ({ match }) => `https://incident.io/${match}`
374
+ );
375
+ linker.apply("Handle INC-99", { format: "slack" });
376
+ // "Handle <https://incident.io/INC-99|INC-99>"
377
+ ```
378
+
379
+ **Priority-based resolution**
380
+
381
+ ```typescript
382
+ const linker = createLinker()
383
+ .custom(/\bENG-\d+\b/g, "https://low.example/$0", { priority: 0 })
384
+ .custom(/\bENG-533\b/g, "https://high.example/$0", { priority: 10 });
385
+
386
+ linker.apply("ENG-533 and ENG-534", { format: "markdown" });
387
+ // "[ENG-533](https://high.example/ENG-533) and [ENG-534](https://low.example/ENG-534)"
388
+ ```
389
+
390
+ **Idempotent linkification**
391
+
392
+ ```typescript
393
+ const linker = createLinker().linear("my-org");
394
+ const first = linker.apply("Ship ENG-533", { format: "slack" });
395
+ const second = linker.apply(first, { format: "slack" });
396
+ // first === second (no double-linkification)
397
+ ```
316
398
 
317
399
  ## Text with Line Numbers
318
400
 
@@ -331,16 +413,13 @@ formatWithLineNumbers("hello\nworld", 10);
331
413
  // 11: world
332
414
  ```
333
415
 
334
- ## Markdown Fence Escaping
416
+ ## Escaping Markdown Fences
335
417
 
336
- Escape content by wrapping with more backticks than contained in the content.
418
+ Escape markdown code fences by dynamically selecting a fence delimiter longer than any backtick sequence in the content.
337
419
 
338
420
  ```typescript
339
421
  import { escapeFence } from "@hardlydifficult/text";
340
422
 
341
- const result = escapeFence("content with ``` triple backticks");
342
- // { fence: "````", content: "content with ``` triple backticks" }
343
-
344
- // Use as:
345
- // ${result.fence}${result.content}${result.fence}
423
+ escapeFence("Content with `` backticks");
424
+ // { fence: "````", content: "Content with `` backticks" }
346
425
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/text",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [