@dbx-tools/shared-core 0.1.2

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Fluent, callable predicate combinators that preserve TypeScript type guards.
3
+ *
4
+ * A Predicate<T, U> is:
5
+ * - Callable as `(value: T) => value is U`
6
+ * - Composable through `.and()`, `.or()`, and `.negate()`
7
+ *
8
+ * Ordinary boolean predicates are treated as narrowing to `T`, meaning they
9
+ * do not narrow the input type by themselves. Composed predicates passed to
10
+ * `.and()` / `.or()` may return any value; they are tested for truthiness.
11
+ */
12
+
13
+ /** An ordinary boolean predicate. */
14
+ export type PredicateFunction<T> = (value: T) => boolean;
15
+
16
+ /** A predicate that narrows T to U. */
17
+ export type TypePredicateFunction<T, U extends T> = (value: T) => value is U;
18
+
19
+ /** A predicate tested for truthiness when composed with `.and()` / `.or()`. */
20
+ export type PredicateInput<T> = (value: T) => unknown;
21
+
22
+ /**
23
+ * Extracts the narrowed type from a type predicate.
24
+ *
25
+ * Ordinary boolean predicates do not narrow, so they produce T. A guard whose
26
+ * narrowed type is disjoint from T (`Extract<U, T>` is `never`) is treated as a
27
+ * non-narrowing filter and keeps T, rather than collapsing the chain to `never` -
28
+ * this is what a negated narrowing guard looks like (e.g. `hasName(...).negate()`,
29
+ * which widens back to a supertype of T).
30
+ */
31
+ type NarrowedBy<T, P> = P extends (value: any) => value is infer U
32
+ ? [Extract<U, T>] extends [never]
33
+ ? T
34
+ : Extract<U, T>
35
+ : T;
36
+
37
+ /** Intersects the narrowed types produced by a tuple of predicates. */
38
+ type AndNarrowed<T, P extends readonly PredicateInput<T>[], Result = T> = P extends readonly [
39
+ infer First,
40
+ ...infer Rest extends readonly PredicateInput<T>[],
41
+ ]
42
+ ? AndNarrowed<T, Rest, Result & NarrowedBy<T, First>>
43
+ : Result;
44
+
45
+ /** Unions the narrowed types produced by a tuple of predicates. */
46
+ type OrNarrowed<T, P extends readonly PredicateInput<T>[], Result = never> = P extends readonly [
47
+ infer First,
48
+ ...infer Rest extends readonly PredicateInput<T>[],
49
+ ]
50
+ ? OrNarrowed<T, Rest, Result | NarrowedBy<T, First>>
51
+ : Result;
52
+
53
+ /**
54
+ * A callable predicate with fluent composition methods.
55
+ *
56
+ * T is the accepted input type.
57
+ * U is the type established when the predicate returns true.
58
+ */
59
+ export interface Predicate<T, U extends T = T> {
60
+ (value: T): value is U;
61
+
62
+ /**
63
+ * Returns a predicate requiring this predicate and every supplied predicate
64
+ * to match.
65
+ *
66
+ * Type guards are intersected. Additional predicates are checked against the
67
+ * type already established by this predicate.
68
+ */
69
+ and<const P extends readonly PredicateInput<U>[]>(
70
+ ...predicates: P
71
+ ): Predicate<T, Extract<U & AndNarrowed<U, P>, T>>;
72
+
73
+ /**
74
+ * Returns a predicate requiring this predicate or any supplied predicate
75
+ * to match.
76
+ *
77
+ * Type guards are unioned. Because an ordinary boolean predicate could
78
+ * accept any value of `U`, including one causes the resulting predicate to
79
+ * narrow only to `U`.
80
+ */
81
+ or<const P extends readonly PredicateInput<U>[]>(
82
+ ...predicates: P
83
+ ): Predicate<T, Extract<U | OrNarrowed<U, P>, T>>;
84
+
85
+ /**
86
+ * Returns the logical inverse of this predicate.
87
+ *
88
+ * For a type predicate narrowing T to U, the result narrows to
89
+ * Exclude<T, U>.
90
+ */
91
+ negate(): Predicate<T, Exclude<T, U>>;
92
+ }
93
+
94
+ /** Coerce a predicate result to boolean the way `if (...)` does. */
95
+ function isTruthy<T>(test: (value: T) => unknown): PredicateFunction<T> {
96
+ return (value) => Boolean(test(value));
97
+ }
98
+
99
+ /**
100
+ * Creates the callable predicate object.
101
+ *
102
+ * The public generic behavior is provided by Predicate<T, U>. Runtime
103
+ * composition coerces predicate results to boolean; type predicates are
104
+ * ordinary boolean functions at runtime.
105
+ */
106
+ function buildPredicate<T, U extends T>(test: (value: T) => unknown): Predicate<T, U> {
107
+ const check = isTruthy(test);
108
+ const callable = ((value: T): value is U => check(value)) as (value: T) => value is U;
109
+
110
+ return Object.assign(callable, {
111
+ and<const P extends readonly PredicateInput<U>[]>(
112
+ ...predicates: P
113
+ ): Predicate<T, Extract<U & AndNarrowed<U, P>, T>> {
114
+ type Result = Extract<U & AndNarrowed<U, P>, T>;
115
+ return buildPredicate<T, Result>(
116
+ (value) => check(value) && predicates.every((predicate) => isTruthy(predicate)(value as U)),
117
+ );
118
+ },
119
+
120
+ or<const P extends readonly PredicateInput<U>[]>(
121
+ ...predicates: P
122
+ ): Predicate<T, Extract<U | OrNarrowed<U, P>, T>> {
123
+ type Result = Extract<U | OrNarrowed<U, P>, T>;
124
+ return buildPredicate<T, Result>(
125
+ (value) => check(value) || predicates.some((predicate) => isTruthy(predicate)(value as U)),
126
+ );
127
+ },
128
+
129
+ negate(): Predicate<T, Exclude<T, U>> {
130
+ return buildPredicate<T, Exclude<T, U>>((value) => !check(value));
131
+ },
132
+ }) as Predicate<T, U>;
133
+ }
134
+
135
+ /**
136
+ * Wraps a type predicate while preserving its narrowed type.
137
+ */
138
+ export function create<T, U extends T>(
139
+ predicate: TypePredicateFunction<T, U>,
140
+ ): Predicate<T, U>;
141
+
142
+ /**
143
+ * Wraps an ordinary boolean or truthy predicate.
144
+ */
145
+ export function create<T>(predicate: PredicateInput<T>): Predicate<T, T>;
146
+
147
+ export function create<T, U extends T = T>(
148
+ predicate: TypePredicateFunction<T, U> | PredicateInput<T>,
149
+ ): Predicate<T, U> {
150
+ return buildPredicate<T, U>(predicate);
151
+ }
package/src/string.ts ADDED
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Browser-safe string toolkit: tokenization, identifier / slug
3
+ * generation (with hash-suffix collision resistance), HTML escaping,
4
+ * header-value trimming, and a nested description-tree renderer for
5
+ * long-form LLM prompt / tool-description text. Depends only on the
6
+ * local {@link fnvHashWithOptions} for deterministic hash suffixes.
7
+ */
8
+ import { fnvHashWithOptions } from "./hash";
9
+
10
+ export type TokenizeOptions = {
11
+ distinct?: boolean;
12
+ lowerCase?: boolean;
13
+ capitalize?: boolean;
14
+ omitUriScheme?: boolean;
15
+ omitEmailDomain?: boolean;
16
+ camelCase?: boolean;
17
+ };
18
+
19
+ // Keys/identifiers/slugs are always lowercased; `lowerCase` is not a
20
+ // caller-configurable option.
21
+ export type KeyOptions = Omit<TokenizeOptions, "lowerCase" | "capitalize"> & {
22
+ maxLength?: number;
23
+ truncateStrategy?: "hash" | "trim" | "empty";
24
+ truncateHashLength?: number;
25
+ };
26
+
27
+ export type IdentifierOptions = KeyOptions & {
28
+ delimiter?: string;
29
+ };
30
+
31
+ type ResolvedTokenizeOptions = Required<TokenizeOptions>;
32
+ type ResolvedIdentifierOptions = Required<
33
+ IdentifierOptions & Pick<TokenizeOptions, "lowerCase" | "capitalize">
34
+ >;
35
+
36
+ const TOKENIZE_CAMEL_CASE_REGEXP = /[A-Z]?[a-z]+|[0-9]+|[A-Z]+(?![a-z])/g;
37
+ const TOKENIZE_NON_ALPHANUMERIC_REGEXP = /[a-zA-Z0-9]+/g;
38
+ const TOKENIZE_OVERRIDES: ((token: string, options: TokenizeOptions) => string)[] = [
39
+ (token, options) => {
40
+ if (options.capitalize && token.toLowerCase() === "ai") {
41
+ return "AI";
42
+ }
43
+ return token;
44
+ },
45
+ ];
46
+ const URI_REGEXP = /^([a-zA-Z][a-zA-Z0-9+.-]*)?:\/\/([^\s/?#][^\s]*)?$/;
47
+ const EMAIL_REGEXP = /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+)@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)$/;
48
+
49
+ const TOKENIZE_DEFAULTS: ResolvedTokenizeOptions = {
50
+ distinct: false,
51
+ lowerCase: false,
52
+ capitalize: false,
53
+ omitUriScheme: false,
54
+ omitEmailDomain: false,
55
+ camelCase: true,
56
+ };
57
+
58
+ const IDENTIFIER_DEFAULTS: ResolvedIdentifierOptions = {
59
+ ...TOKENIZE_DEFAULTS,
60
+ lowerCase: true,
61
+ maxLength: Infinity,
62
+ truncateStrategy: "hash",
63
+ truncateHashLength: 6,
64
+ delimiter: "-",
65
+ };
66
+
67
+ export function* tokenizeWithOptions(
68
+ options: TokenizeOptions,
69
+ ...values: unknown[]
70
+ ): Generator<string> {
71
+ const opts: ResolvedTokenizeOptions = { ...TOKENIZE_DEFAULTS, ...options };
72
+ const seen = opts.distinct ? new Set<string>() : undefined;
73
+ const regexp = opts.camelCase ? TOKENIZE_CAMEL_CASE_REGEXP : TOKENIZE_NON_ALPHANUMERIC_REGEXP;
74
+
75
+ for (const value of values) {
76
+ if (value == null) continue;
77
+ let stringValue = typeof value === "string" ? value : String(value);
78
+ if (!stringValue) continue;
79
+ if (opts.omitUriScheme) {
80
+ const match = stringValue.match(URI_REGEXP);
81
+ if (match) stringValue = match[2] ?? "";
82
+ }
83
+ if (opts.omitEmailDomain) {
84
+ const match = stringValue.match(EMAIL_REGEXP);
85
+ if (match) stringValue = match[1] ?? "";
86
+ }
87
+ if (!stringValue) continue;
88
+ for (const tokenMatch of stringValue.matchAll(regexp)) {
89
+ let token = tokenMatch[0]!;
90
+ if (opts.lowerCase) token = token.toLowerCase();
91
+ if (opts.capitalize) token = token.charAt(0).toUpperCase() + token.slice(1);
92
+ if (!token) continue;
93
+ for (const override of TOKENIZE_OVERRIDES) {
94
+ token = override(token, opts);
95
+ if (!token) break;
96
+ }
97
+ if (!token || seen?.has(token)) continue;
98
+ seen?.add(token);
99
+ yield token;
100
+ }
101
+ }
102
+ }
103
+
104
+ export function* tokenize(...values: unknown[]): Generator<string> {
105
+ yield* tokenizeWithOptions({}, ...values);
106
+ }
107
+
108
+ /**
109
+ * Join tokenized values with `delimiter`. When the next token would push the
110
+ * result over `maxLength`: `trim` stops adding; `empty` returns `""`; `hash`
111
+ * appends a digest of accepted tokens plus the overflow token if the result
112
+ * still fits, otherwise `""`.
113
+ */
114
+ export function toIdentifierWithOptions(options: IdentifierOptions, ...values: unknown[]): string {
115
+ const opts: ResolvedIdentifierOptions = {
116
+ ...IDENTIFIER_DEFAULTS,
117
+ ...options,
118
+ lowerCase: true,
119
+ };
120
+ const tokens: string[] = [];
121
+ let currentLength = 0;
122
+
123
+ for (const token of tokenizeWithOptions(opts, ...values)) {
124
+ const sepLength = tokens.length > 0 ? opts.delimiter.length : 0;
125
+ const nextLength = currentLength + sepLength + token.length;
126
+
127
+ if (nextLength > opts.maxLength) {
128
+ if (opts.truncateStrategy === "empty") return "";
129
+ if (opts.truncateStrategy === "trim") break;
130
+
131
+ const hash = digestTokens(opts.truncateHashLength, tokens, token);
132
+ if (currentLength + sepLength + hash.length <= opts.maxLength) {
133
+ return tokens.length > 0 ? tokens.join(opts.delimiter) + opts.delimiter + hash : hash;
134
+ }
135
+ return "";
136
+ }
137
+
138
+ tokens.push(token);
139
+ currentLength = nextLength;
140
+ }
141
+
142
+ return tokens.join(opts.delimiter);
143
+ }
144
+
145
+ export function toIdentifier(...values: unknown[]): string {
146
+ return toIdentifierWithOptions({}, ...values);
147
+ }
148
+
149
+ /**
150
+ * Slugified identifier: same rules as {@link toIdentifierWithOptions} with the
151
+ * delimiter forced to `-`. Accepts {@link KeyOptions} so callers cannot
152
+ * override the delimiter.
153
+ */
154
+ export function toSlugWithOptions(options: KeyOptions, ...values: unknown[]): string {
155
+ return toIdentifierWithOptions({ ...options, delimiter: "-" }, ...values);
156
+ }
157
+
158
+ export function toSlug(...values: unknown[]): string {
159
+ return toSlugWithOptions({}, ...values);
160
+ }
161
+
162
+ /**
163
+ * Trim `value` and return `null` for non-strings, `undefined`, or
164
+ * strings that are empty after trimming. Lets call sites collapse the
165
+ * common
166
+ *
167
+ * ```ts
168
+ * typeof v === "string" && v.trim() ? v.trim() : null
169
+ * ```
170
+ *
171
+ * dance into a single helper. Useful for HTTP header / query / form
172
+ * extractors where downstream code wants `string | null` to drive a
173
+ * cheap `??` / `if (x)` cascade.
174
+ */
175
+ export function trimToNull(value: unknown): string | null {
176
+ if (typeof value !== "string") return null;
177
+ const trimmed = value.trim();
178
+ return trimmed ? trimmed : null;
179
+ }
180
+
181
+ /**
182
+ * Trim the first usable string out of `value`. Returns `null` when
183
+ * `value` is `undefined`, `null`, an empty string, or an array whose
184
+ * first string member is empty. Mirrors how Express / Node header
185
+ * accessors expose single vs. repeated headers - the first
186
+ * non-empty entry wins, everything else is ignored.
187
+ */
188
+ export function firstNonEmpty(value: unknown): string | null {
189
+ if (Array.isArray(value)) {
190
+ for (const item of value) {
191
+ const trimmed = trimToNull(item);
192
+ if (trimmed) return trimmed;
193
+ }
194
+ return null;
195
+ }
196
+ return trimToNull(value);
197
+ }
198
+
199
+ /**
200
+ * Escape the five characters significant in HTML text and
201
+ * double-quoted attribute values (`&`, `<`, `>`, `"`, `'`) so an
202
+ * untrusted string can be interpolated into markup without breaking
203
+ * out of its context. `&` is replaced first so ampersands introduced
204
+ * by the later replacements aren't double-escaped.
205
+ */
206
+ export function escapeHtml(value: string): string {
207
+ return value
208
+ .replace(/&/g, "&amp;")
209
+ .replace(/</g, "&lt;")
210
+ .replace(/>/g, "&gt;")
211
+ .replace(/"/g, "&quot;")
212
+ .replace(/'/g, "&#39;");
213
+ }
214
+
215
+ /**
216
+ * Slugify `value` (using the standard {@link toIdentifierWithOptions}
217
+ * tokenizer + delimiter rules) and **always** suffix a short
218
+ * deterministic hash. Use when you need a stable, slugified id that
219
+ * is guaranteed to be unique across descriptions sharing the same
220
+ * leading tokens (tool ids, cache keys, etc.).
221
+ *
222
+ * Behaviour differs from `toIdentifierWithOptions({ maxLength,
223
+ * truncateStrategy: "hash" })`: that helper only appends a hash when
224
+ * the slug *overflows* `maxLength`. This helper appends a hash
225
+ * unconditionally so the result is collision-resistant even for
226
+ * short inputs. The hash is computed over the raw `value` so two
227
+ * descriptions producing the same slug still get different ids.
228
+ *
229
+ * @param value - Source string (typically a tool/agent description).
230
+ * @param options.delimiter - Token separator (default `"_"`).
231
+ * @param options.slugMaxLength - Cap on the slug portion (the part
232
+ * before the hash). Default 32.
233
+ * @param options.hashLength - Length of the suffix produced by
234
+ * {@link fnvHashWithOptions} (Crockford-style base-32 alphabet, max 7
235
+ * chars). Default 6.
236
+ * @param options.fallbackPrefix - Prefix used when the slug is empty
237
+ * (e.g. punctuation-only input). Default `"id"`.
238
+ */
239
+ export function toUniqueSlug(
240
+ value: string,
241
+ options: {
242
+ delimiter?: string;
243
+ slugMaxLength?: number;
244
+ hashLength?: number;
245
+ fallbackPrefix?: string;
246
+ } = {},
247
+ ): string {
248
+ const delimiter = options.delimiter ?? "_";
249
+ const slugMaxLength = options.slugMaxLength ?? 32;
250
+ const hashLength = options.hashLength ?? 6;
251
+ const fallbackPrefix = options.fallbackPrefix ?? "id";
252
+ const slug = toIdentifierWithOptions(
253
+ { delimiter, maxLength: slugMaxLength, truncateStrategy: "trim" },
254
+ value,
255
+ );
256
+ const suffix = fnvHashWithOptions({ length: hashLength }, value);
257
+ return slug ? `${slug}${delimiter}${suffix}` : `${fallbackPrefix}${delimiter}${suffix}`;
258
+ }
259
+
260
+ function digestTokens(length: number, parts: readonly string[], extra?: string): string {
261
+ let combined = "";
262
+ for (const part of parts) combined += part + "\0";
263
+ if (extra !== undefined) combined += extra + "\0";
264
+ return fnvHashWithOptions({ length }, combined);
265
+ }
266
+
267
+ /**
268
+ * A node in the description tree consumed by {@link toDescription}.
269
+ *
270
+ * - `string` - a text paragraph.
271
+ * - `Description[]` - a sequence of stacked blocks at the same level
272
+ * (no list markers). Plain text adjacent to a list (either direction)
273
+ * flushes together so the prose reads as a lead-in or trailing
274
+ * summary. Two text paragraphs, two adjacent lists, and anything
275
+ * touching a map get a blank-line break.
276
+ * - `{ bullets: [...] }` / `{ numbered: [...] }` - explicit list. A
277
+ * list of one bare string drops its marker (`-` / `1.`); a list of
278
+ * one item with nested children keeps its marker as the visual anchor
279
+ * for the indented children.
280
+ * - any other object - headers map: each key becomes a `Header:` line
281
+ * followed by a blank line and the rendered value.
282
+ */
283
+ export type Description = string | readonly Description[] | { readonly [key: string]: Description };
284
+
285
+ const LIST_KEYS = ["bullets", "numbered"] as const;
286
+ type ListKind = (typeof LIST_KEYS)[number];
287
+
288
+ /**
289
+ * Format a nested description tree as a Markdown-ish string suitable
290
+ * for an LLM system prompt, Zod `.describe()` block, Mastra tool
291
+ * description, or any other long-form text destination.
292
+ *
293
+ * Every string section is dedented (common leading whitespace stripped),
294
+ * right-trimmed line by line, and freed of leading / trailing blank
295
+ * lines, so callers can write multi-line template literals indented
296
+ * naturally in source without leaking that indentation into the
297
+ * consumer-facing output. Plain-string inputs flow through unchanged
298
+ * apart from the same normalization pass, so a single multi-line
299
+ * template literal works directly:
300
+ *
301
+ * ```ts
302
+ * toDescription(`
303
+ * Ask the Genie space "${alias}" a question.
304
+ * Pass the answer through as-is.
305
+ * `);
306
+ * // Ask the Genie space "default" a question.
307
+ * // Pass the answer through as-is.
308
+ *
309
+ * toDescription([
310
+ * `
311
+ * Ask the Genie space a question.
312
+ * Phrase it from the user's perspective.
313
+ * `,
314
+ * { bullets: [
315
+ * ["Pass the answer through as-is", { numbered: ["item", "item"] }],
316
+ * ]},
317
+ * { Instructions: "Reply with the SQL only." },
318
+ * ]);
319
+ * // Ask the Genie space a question.
320
+ * // Phrase it from the user's perspective.
321
+ * // - Pass the answer through as-is
322
+ * // 1. item
323
+ * // 2. item
324
+ * //
325
+ * // Instructions:
326
+ * //
327
+ * // Reply with the SQL only.
328
+ * ```
329
+ *
330
+ * See {@link Description} for the node grammar.
331
+ */
332
+ export function toDescription(node: Description): string {
333
+ return renderBlock(node, "")
334
+ .replace(/[ \t]+$/gm, "")
335
+ .replace(/\n+$/, "");
336
+ }
337
+
338
+ function renderBlock(node: Description, pad: string): string {
339
+ if (node == null) return "";
340
+ if (typeof node === "string") return prependPad(dedentSection(node), pad);
341
+ if (Array.isArray(node)) return renderSequence(node, pad);
342
+ const kind = listKind(node as Record<string, unknown>);
343
+ if (kind) {
344
+ return renderList((node as Record<ListKind, readonly Description[]>)[kind], pad, kind);
345
+ }
346
+ return renderMap(node as Record<string, Description>, pad);
347
+ }
348
+
349
+ /**
350
+ * Normalize a string section: right-strip every line, drop the
351
+ * common leading-whitespace prefix shared by all non-blank lines,
352
+ * and trim leading / trailing blank lines. Matches Python's
353
+ * `textwrap.dedent` semantics so embedded indented template
354
+ * literals round-trip cleanly.
355
+ */
356
+ function dedentSection(text: string): string {
357
+ if (!text) return "";
358
+ const lines = text.split("\n").map((line) => line.replace(/[ \t]+$/, ""));
359
+ let min = Infinity;
360
+ for (const line of lines) {
361
+ if (!line) continue;
362
+ const match = /^[ \t]*/.exec(line);
363
+ const width = match ? match[0].length : 0;
364
+ if (width < min) min = width;
365
+ }
366
+ const stripped =
367
+ min === Infinity || min === 0 ? lines : lines.map((line) => (line ? line.slice(min) : ""));
368
+ let start = 0;
369
+ let end = stripped.length;
370
+ while (start < end && !stripped[start]) start += 1;
371
+ while (end > start && !stripped[end - 1]) end -= 1;
372
+ return stripped.slice(start, end).join("\n");
373
+ }
374
+
375
+ /**
376
+ * An object is treated as a typed list only when it has exactly one
377
+ * own key, that key is `bullets` or `numbered`, and the value is an
378
+ * array. Everything else is a headers map - so callers wanting a
379
+ * single header literally named `bullets` or `numbered` can use a
380
+ * multi-key map or rename.
381
+ */
382
+ function listKind(node: Record<string, unknown>): ListKind | null {
383
+ const keys = Object.keys(node);
384
+ if (keys.length !== 1) return null;
385
+ const key = keys[0]!;
386
+ if ((LIST_KEYS as readonly string[]).includes(key) && Array.isArray(node[key])) {
387
+ return key as ListKind;
388
+ }
389
+ return null;
390
+ }
391
+
392
+ function prependPad(text: string, pad: string): string {
393
+ if (!text) return "";
394
+ if (!pad) return text;
395
+ return text
396
+ .split("\n")
397
+ .map((line) => (line ? pad + line : ""))
398
+ .join("\n");
399
+ }
400
+
401
+ function renderSequence(items: readonly Description[], pad: string): string {
402
+ const rendered: { text: string; node: Description }[] = [];
403
+ for (const item of items) {
404
+ const text = renderBlock(item, pad);
405
+ if (!text) continue;
406
+ rendered.push({ text, node: item });
407
+ }
408
+ if (rendered.length === 0) return "";
409
+ let out = rendered[0]!.text;
410
+ for (let i = 1; i < rendered.length; i += 1) {
411
+ const sep = needsBlankLineBetween(rendered[i - 1]!.node, rendered[i]!.node) ? "\n\n" : "\n";
412
+ out += sep + rendered[i]!.text;
413
+ }
414
+ return out;
415
+ }
416
+
417
+ /**
418
+ * Maps always create their own section boundary (a `Header:` line plus
419
+ * a blank line before the body), so anything touching a map gets a
420
+ * blank-line break. Plain text adjacent to a typed list flushes
421
+ * together in either direction: text-before-list reads as a lead-in,
422
+ * text-after-list as a trailing summary. Two text paragraphs and two
423
+ * adjacent lists both get a blank-line break for legibility.
424
+ */
425
+ function needsBlankLineBetween(prev: Description, curr: Description): boolean {
426
+ if (isMap(prev) || isMap(curr)) return true;
427
+ const prevIsText = typeof prev === "string";
428
+ const currIsText = typeof curr === "string";
429
+ if (prevIsText !== currIsText) return false;
430
+ return true;
431
+ }
432
+
433
+ /**
434
+ * A node is a headers map when it is a non-array, non-list object.
435
+ * `bullets` / `numbered` single-key objects are the only structured
436
+ * objects that aren't maps.
437
+ */
438
+ function isMap(node: Description): boolean {
439
+ if (node == null) return false;
440
+ if (typeof node === "string") return false;
441
+ if (Array.isArray(node)) return false;
442
+ return listKind(node as Record<string, unknown>) === null;
443
+ }
444
+
445
+ function renderList(items: readonly Description[], pad: string, kind: ListKind): string {
446
+ if (items.length === 0) return "";
447
+ if (items.length === 1 && typeof items[0] === "string") {
448
+ return prependPad(items[0], pad);
449
+ }
450
+ const lines: string[] = [];
451
+ for (let i = 0; i < items.length; i += 1) {
452
+ const item = items[i]!;
453
+ const marker = kind === "bullets" ? "- " : `${i + 1}. `;
454
+ const body = renderBlock(item, "");
455
+ const bodyLines = body.split("\n");
456
+ lines.push(`${pad}${marker}${bodyLines[0] ?? ""}`);
457
+ const continuation = pad + " ".repeat(marker.length);
458
+ for (const line of bodyLines.slice(1)) {
459
+ lines.push(line ? `${continuation}${line}` : "");
460
+ }
461
+ }
462
+ return lines.join("\n");
463
+ }
464
+
465
+ function renderMap(node: Record<string, Description>, pad: string): string {
466
+ const parts: string[] = [];
467
+ for (const [header, value] of Object.entries(node)) {
468
+ const body = renderBlock(value, pad);
469
+ if (!body && !header.trim()) continue;
470
+ const headerLine = header.trim() ? `${pad}${header}:` : "";
471
+ parts.push(body ? `${headerLine}\n\n${body}` : headerLine);
472
+ }
473
+ return parts.join("\n\n");
474
+ }
475
+
476
+ /**
477
+ * Format a count with its noun, pluralizing (naive `+s`) unless the count is 1:
478
+ * `pluralize(1, "barrel")` -> `"1 barrel"`, `pluralize(3, "barrel")` ->
479
+ * `"3 barrels"`. Collapses the `${n} noun${n === 1 ? "" : "s"}` idiom.
480
+ */
481
+ export function pluralize(count: number, noun: string): string {
482
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
483
+ }