@lovelaces-io/storyteller 0.1.0 → 0.2.0

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/dist/index.js CHANGED
@@ -1,3 +1,25 @@
1
+ // src/audiences/consoleAudience.ts
2
+ function consoleAudience() {
3
+ return {
4
+ name: "console",
5
+ hear: (event) => {
6
+ const prefix = "Storyteller";
7
+ const style = event.level === "Information" ? "color:#16a34a;font-weight:600" : event.level === "Warning" ? "color:#f59e0b;font-weight:600" : "color:#dc2626;font-weight:600";
8
+ const header = `${prefix}: ${event.title}`;
9
+ console.groupCollapsed(`%c${header}`, style);
10
+ const payload = JSON.stringify(event, null, 2);
11
+ if (event.level === "Information") {
12
+ console.log(header, payload);
13
+ } else if (event.level === "Warning") {
14
+ console.warn(header, payload);
15
+ } else {
16
+ console.error(header, payload);
17
+ }
18
+ console.groupEnd();
19
+ }
20
+ };
21
+ }
22
+
1
23
  // src/utils.ts
2
24
  var ANSI = {
3
25
  reset: "\x1B[0m",
@@ -5,18 +27,21 @@ var ANSI = {
5
27
  yellow: "\x1B[33m",
6
28
  red: "\x1B[38;2;250;128;114m",
7
29
  grayLight: "\x1B[37m",
8
- grayDark: "\x1B[37m"
30
+ grayDark: "\x1B[90m"
9
31
  };
10
32
  function getLevelColor(level) {
11
- if (level === "tell") return ANSI.green;
12
- if (level === "warn") return ANSI.yellow;
33
+ if (level === "Information") return ANSI.green;
34
+ if (level === "Warning") return ANSI.yellow;
13
35
  return ANSI.red;
14
36
  }
15
37
  function formatOrigin(origin) {
16
38
  if (!origin?.where) return;
17
39
  if (typeof origin.where === "string") return origin.where;
18
40
  const whereRecord = origin.where;
19
- const parts = [whereRecord.app, whereRecord.service, whereRecord.page, whereRecord.component].filter(Boolean).map(String);
41
+ const priorityKeys = ["app", "service", "page", "component"];
42
+ const priorityParts = priorityKeys.filter((key) => whereRecord[key] != null).map((key) => String(whereRecord[key]));
43
+ const extraParts = Object.entries(whereRecord).filter(([key, value]) => !priorityKeys.includes(key) && value != null).map(([_, value]) => String(value));
44
+ const parts = [...priorityParts, ...extraParts];
20
45
  return parts.length ? parts.join(" / ") : void 0;
21
46
  }
22
47
  function colorizeJsonSections(json, colors) {
@@ -44,27 +69,144 @@ function countBrackets(line) {
44
69
  return openCount - closeCount;
45
70
  }
46
71
 
47
- // src/audiences/consoleAudience.ts
48
- function consoleAudience() {
49
- return {
50
- name: "console",
51
- hear: (event) => {
52
- const prefix = "Storyteller";
53
- const style = event.level === "tell" ? "color:#16a34a;font-weight:600" : event.level === "warn" ? "color:#f59e0b;font-weight:600" : "color:#dc2626;font-weight:600";
54
- const header = `${prefix}: ${event.title}`;
55
- console.groupCollapsed(`%c${header}`, style);
56
- const payload = JSON.stringify(event, null, 2);
57
- const coloredPayload = event.level === "oops" ? `${ANSI.red}${payload}${ANSI.reset}` : payload;
58
- if (event.level === "tell") {
59
- console.log(header, payload);
60
- } else if (event.level === "warn") {
61
- console.warn(header, payload);
62
- } else {
63
- console.error(header, coloredPayload);
64
- }
65
- console.groupEnd();
66
- }
72
+ // src/formatting.ts
73
+ function formatStory(story, options = {}) {
74
+ const {
75
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
76
+ locale = "en-US",
77
+ detail = "normal",
78
+ noteLimit = 50,
79
+ showData = true,
80
+ colors = true
81
+ } = options;
82
+ const dateTimeFormatter = new Intl.DateTimeFormat(locale, {
83
+ timeZone: timezone,
84
+ year: "numeric",
85
+ month: "short",
86
+ day: "2-digit",
87
+ hour: "numeric",
88
+ minute: "2-digit",
89
+ second: "2-digit"
90
+ });
91
+ const timeFormatter = new Intl.DateTimeFormat(locale, {
92
+ timeZone: timezone,
93
+ hour: "numeric",
94
+ minute: "2-digit",
95
+ second: "2-digit"
96
+ });
97
+ const orderedNotes = [...story.notes].sort(
98
+ (noteA, noteB) => Date.parse(noteA.timestamp) - Date.parse(noteB.timestamp)
99
+ );
100
+ const durationMs = story.durationMs ?? calculateNoteDuration(orderedNotes);
101
+ const duration = durationMs != null ? formatDuration(durationMs) : void 0;
102
+ const slicedNotes = orderedNotes.slice(0, noteLimit);
103
+ const reportNotes = slicedNotes.map((note) => ({
104
+ timestamp: note.timestamp,
105
+ when: timeFormatter.format(new Date(note.timestamp)),
106
+ note: note.note,
107
+ text: formatNoteText(note, detail),
108
+ ...note.who ? { who: note.who } : {},
109
+ ...note.what ? { what: note.what } : {},
110
+ ...note.where ? { where: note.where } : {},
111
+ ...note.error ? { error: note.error } : {}
112
+ }));
113
+ const data = {
114
+ title: story.title,
115
+ level: story.level,
116
+ when: dateTimeFormatter.format(new Date(story.timestamp)),
117
+ ...durationMs != null ? { durationMs } : {},
118
+ ...duration ? { duration } : {},
119
+ ...story.origin ? { origin: story.origin } : {},
120
+ notes: reportNotes,
121
+ ...story.error ? { error: story.error } : {}
67
122
  };
123
+ const lines = buildReportText(story, data, reportNotes, orderedNotes, {
124
+ colors,
125
+ detail,
126
+ showData,
127
+ duration
128
+ });
129
+ return { text: lines.join("\n"), data };
130
+ }
131
+ function buildReportText(story, data, reportNotes, orderedNotes, options) {
132
+ const levelColor = getLevelColor(story.level);
133
+ const label = (text) => options.colors ? `${levelColor}${text}${ANSI.reset}` : text;
134
+ const originLabel = formatOrigin(story.origin);
135
+ const lines = [];
136
+ lines.push(`${label("Story")}: ${story.title}`);
137
+ lines.push(`${label("Level")}: ${story.level}`);
138
+ lines.push(`${label("Time")}: ${data.when}${options.duration ? ` (${options.duration})` : ""}`);
139
+ if (originLabel) {
140
+ lines.push(`${label("Origin")}: ${originLabel}`);
141
+ }
142
+ if (story.error) {
143
+ const errorLine = [story.error.name, story.error.message].filter(Boolean).join(": ");
144
+ if (errorLine) lines.push(`${label("Error")}: ${errorLine}`);
145
+ }
146
+ if (options.detail !== "brief" && reportNotes.length) {
147
+ lines.push(`${label("Notes")}:`);
148
+ for (const note of reportNotes) {
149
+ lines.push(` ${note.when} \u2014 ${note.text}`);
150
+ }
151
+ if (orderedNotes.length > reportNotes.length) {
152
+ lines.push(` \u2026 (${orderedNotes.length - reportNotes.length} more)`);
153
+ }
154
+ }
155
+ if (options.showData) {
156
+ lines.push(`${label("Data")}:`);
157
+ const json = JSON.stringify(data, null, 2);
158
+ if (options.colors) {
159
+ const colored = colorizeJsonSections(json, {
160
+ base: ANSI.grayLight,
161
+ notes: ANSI.grayDark,
162
+ reset: ANSI.reset
163
+ });
164
+ lines.push(...colored);
165
+ } else {
166
+ lines.push(...json.split("\n"));
167
+ }
168
+ }
169
+ return lines;
170
+ }
171
+ function calculateNoteDuration(notes) {
172
+ if (notes.length <= 1) return void 0;
173
+ const startTime = Date.parse(notes[0].timestamp);
174
+ const endTime = Date.parse(notes[notes.length - 1].timestamp);
175
+ return Number.isFinite(startTime) && Number.isFinite(endTime) ? Math.max(0, endTime - startTime) : void 0;
176
+ }
177
+ function formatDuration(milliseconds) {
178
+ if (milliseconds < 1e3) return `${milliseconds}ms`;
179
+ const seconds = milliseconds / 1e3;
180
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
181
+ const minutes = Math.floor(seconds / 60);
182
+ const remainingSeconds = Math.round(seconds % 60).toString().padStart(2, "0");
183
+ return `${minutes}:${remainingSeconds}m`;
184
+ }
185
+ var summarizeStory = formatStory;
186
+ function formatNoteText(note, verbosity) {
187
+ if (verbosity !== "full") return note.note;
188
+ const details = [];
189
+ const what = note.what;
190
+ const where = note.where;
191
+ if (typeof what === "string") {
192
+ details.push(`what=${what}`);
193
+ } else if (what) {
194
+ for (const [key, value] of Object.entries(what)) {
195
+ if (value != null) details.push(`${key}=${String(value)}`);
196
+ }
197
+ }
198
+ if (typeof where === "string") {
199
+ details.push(`where=${where}`);
200
+ } else if (where) {
201
+ for (const [key, value] of Object.entries(where)) {
202
+ if (value != null) details.push(`${key}=${String(value)}`);
203
+ }
204
+ }
205
+ if (note.error) {
206
+ const errorLine = [note.error.name, note.error.message].filter(Boolean).join(": ");
207
+ if (errorLine) details.push(`error=${errorLine}`);
208
+ }
209
+ return details.length ? `${note.note} (${details.join(" ")})` : note.note;
68
210
  }
69
211
 
70
212
  // src/storyteller.ts
@@ -88,6 +230,14 @@ var AudienceRegistry = class {
88
230
  getOnly(names) {
89
231
  return names.map((name) => this.members.get(name)).filter(Boolean);
90
232
  }
233
+ /** Check if an audience member is registered by name */
234
+ has(name) {
235
+ return this.members.has(name);
236
+ }
237
+ /** List the names of all registered audience members */
238
+ names() {
239
+ return [...this.members.keys()];
240
+ }
91
241
  };
92
242
  var Storyteller = class {
93
243
  audience = new AudienceRegistry();
@@ -98,7 +248,17 @@ var Storyteller = class {
98
248
  this.audience.add(consoleAudience());
99
249
  options?.audiences?.forEach((audience) => this.audience.add(audience));
100
250
  }
101
- /** Add a timestamped note with optional context (who, what, where, error) */
251
+ /**
252
+ * Add a timestamped note to the current story.
253
+ * @param text - What happened
254
+ * @param data - Optional context: who did it, what was involved, where it happened, any error
255
+ * @returns `this` for chaining
256
+ *
257
+ * @example
258
+ * ```ts
259
+ * story.note("Card charged", { what: { amount: "$42" }, where: "stripe" });
260
+ * ```
261
+ */
102
262
  note(text, data = {}) {
103
263
  this.notes.push({
104
264
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -115,13 +275,13 @@ var Storyteller = class {
115
275
  this.notes = [];
116
276
  return this;
117
277
  }
118
- /** Generate a formatted summary of current notes without emitting or clearing them */
278
+ /** Preview the current notes as a formatted report without emitting or clearing them */
119
279
  summarize(options = {}) {
120
280
  const {
121
281
  title = "Story preview",
122
- level = "tell",
282
+ level = "Information",
123
283
  error,
124
- ...summaryOptions
284
+ ...reportOptions
125
285
  } = options;
126
286
  const event = {
127
287
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -131,23 +291,23 @@ var Storyteller = class {
131
291
  notes: [...this.notes],
132
292
  ...error ? { error: normalizeError(error) } : {}
133
293
  };
134
- return summarizeStory(event, summaryOptions);
294
+ return formatStory(event, reportOptions);
135
295
  }
136
- /** Emit a story at the "tell" level (success / informational) */
296
+ /** Tell a success story — everything went well */
137
297
  tell(title) {
138
298
  return this.createDelivery("tell", title);
139
299
  }
140
- /** Emit a story at the "warn" level (something was off) */
300
+ /** Tell a cautionary story — something was off but it's handled */
141
301
  warn(title) {
142
302
  return this.createDelivery("warn", title);
143
303
  }
144
- /** Emit a story at the "oops" level (something broke) with an optional error */
304
+ /** Tell an error story — something broke. Pass the error for automatic normalization. */
145
305
  oops(title, error) {
146
306
  return this.createDelivery("oops", title, error);
147
307
  }
148
308
  /** Build a story event and schedule delivery, returning a handle to override the audience list */
149
- createDelivery(level, title, error) {
150
- const event = this.buildEvent(level, title, error);
309
+ createDelivery(method, title, error) {
310
+ const event = this.buildEvent(method, title, error);
151
311
  let delivered = false;
152
312
  let defaultCancelled = false;
153
313
  queueMicrotask(() => {
@@ -165,21 +325,26 @@ var Storyteller = class {
165
325
  };
166
326
  }
167
327
  /** Assemble the story event from current notes and clear notes for the next story */
168
- buildEvent(level, title, error) {
328
+ buildEvent(method, title, error) {
169
329
  const now = (/* @__PURE__ */ new Date()).toISOString();
170
- const collectedNotes = [...this.notes];
330
+ const level = methodToLevel(method);
331
+ const sortedNotes = [...this.notes].sort(
332
+ (noteA, noteB) => Date.parse(noteA.timestamp) - Date.parse(noteB.timestamp)
333
+ );
171
334
  this.notes = [];
335
+ const durationMs = calculateNoteDuration2(sortedNotes).durationMs;
172
336
  const event = {
173
337
  timestamp: now,
174
338
  level,
175
339
  title,
176
340
  ...this.origin ? { origin: this.origin } : {},
177
- notes: collectedNotes,
341
+ notes: sortedNotes,
342
+ ...durationMs != null ? { durationMs } : {},
178
343
  ...error ? { error: normalizeError(error) } : {}
179
344
  };
180
345
  const eventWithSummary = event;
181
346
  Object.defineProperty(eventWithSummary, "summarize", {
182
- value: (options) => summarizeStory(event, options),
347
+ value: (options) => formatStory(event, options),
183
348
  enumerable: false
184
349
  });
185
350
  return eventWithSummary;
@@ -192,6 +357,11 @@ var Storyteller = class {
192
357
  );
193
358
  }
194
359
  };
360
+ function methodToLevel(method) {
361
+ if (method === "tell") return "Information";
362
+ if (method === "warn") return "Warning";
363
+ return "Error";
364
+ }
195
365
  function normalizeError(rawError) {
196
366
  if (rawError instanceof Error) {
197
367
  const normalized = {
@@ -209,7 +379,7 @@ function normalizeError(rawError) {
209
379
  }
210
380
  return { message: String(rawError) };
211
381
  }
212
- function calculateNoteDuration(notes) {
382
+ function calculateNoteDuration2(notes) {
213
383
  if (notes.length <= 1) {
214
384
  return {
215
385
  durationMs: void 0
@@ -221,125 +391,6 @@ function calculateNoteDuration(notes) {
221
391
  durationMs: Number.isFinite(startTime) && Number.isFinite(endTime) ? Math.max(0, endTime - startTime) : void 0
222
392
  };
223
393
  }
224
- function summarizeStory(story, options = {}) {
225
- const {
226
- timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
227
- locale = "en-US",
228
- verbosity = "normal",
229
- maxNotes = 50,
230
- showData = true,
231
- colorize = true
232
- } = options;
233
- const dateTimeFormatter = new Intl.DateTimeFormat(locale, {
234
- timeZone: timezone,
235
- year: "numeric",
236
- month: "short",
237
- day: "2-digit",
238
- hour: "numeric",
239
- minute: "2-digit",
240
- second: "2-digit"
241
- });
242
- const timeFormatter = new Intl.DateTimeFormat(locale, {
243
- timeZone: timezone,
244
- hour: "numeric",
245
- minute: "2-digit",
246
- second: "2-digit"
247
- });
248
- const orderedNotes = [...story.notes].sort(
249
- (noteA, noteB) => Date.parse(noteA.timestamp) - Date.parse(noteB.timestamp)
250
- );
251
- const noteTiming = calculateNoteDuration(orderedNotes);
252
- const originLabel = formatOrigin(story.origin);
253
- const duration = noteTiming.durationMs != null ? formatDuration(noteTiming.durationMs) : void 0;
254
- const slicedNotes = orderedNotes.slice(0, maxNotes);
255
- const summaryNotes = slicedNotes.map((note) => ({
256
- timestamp: note.timestamp,
257
- when: timeFormatter.format(new Date(note.timestamp)),
258
- note: note.note,
259
- text: formatNoteText(note, verbosity),
260
- ...note.who ? { who: note.who } : {},
261
- ...note.what ? { what: note.what } : {},
262
- ...note.where ? { where: note.where } : {},
263
- ...note.error ? { error: note.error } : {}
264
- }));
265
- const data = {
266
- title: story.title,
267
- level: story.level,
268
- when: dateTimeFormatter.format(new Date(story.timestamp)),
269
- ...noteTiming.durationMs != null ? { durationMs: noteTiming.durationMs } : {},
270
- ...duration ? { duration } : {},
271
- ...story.origin ? { origin: story.origin } : {},
272
- notes: summaryNotes,
273
- ...story.error ? { error: story.error } : {}
274
- };
275
- const levelColor = getLevelColor(story.level);
276
- const label = (text) => colorize ? `${levelColor}${text}${ANSI.reset}` : text;
277
- const lines = [];
278
- lines.push(`${label("Story")}: ${story.title}`);
279
- lines.push(`${label("Level")}: ${story.level}`);
280
- lines.push(`${label("Time")}: ${data.when}${duration ? ` (${duration})` : ""}`);
281
- if (originLabel) {
282
- lines.push(`${label("Origin")}: ${originLabel}`);
283
- }
284
- if (story.error) {
285
- const errorLine = [story.error.name, story.error.message].filter(Boolean).join(": ");
286
- if (errorLine) lines.push(`${label("Error")}: ${errorLine}`);
287
- }
288
- if (verbosity !== "brief" && summaryNotes.length) {
289
- lines.push(`${label("Notes")}:`);
290
- for (const note of summaryNotes) {
291
- lines.push(` ${note.when} \u2014 ${note.text}`);
292
- }
293
- if (orderedNotes.length > summaryNotes.length) {
294
- lines.push(` \u2026 (${orderedNotes.length - summaryNotes.length} more)`);
295
- }
296
- }
297
- if (showData) {
298
- lines.push(`${label("Data")}:`);
299
- const json = JSON.stringify(data, null, 2);
300
- if (colorize) {
301
- const colored = colorizeJsonSections(json, {
302
- base: ANSI.grayLight,
303
- notes: ANSI.grayDark,
304
- reset: ANSI.reset
305
- });
306
- lines.push(...colored);
307
- } else {
308
- lines.push(...json.split("\n"));
309
- }
310
- }
311
- return { text: lines.join("\n"), data };
312
- }
313
- function formatDuration(milliseconds) {
314
- if (milliseconds < 1e3) return `${milliseconds}ms`;
315
- const seconds = milliseconds / 1e3;
316
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
317
- const minutes = Math.floor(seconds / 60);
318
- const remainingSeconds = Math.round(seconds % 60).toString().padStart(2, "0");
319
- return `${minutes}:${remainingSeconds}m`;
320
- }
321
- function formatNoteText(note, verbosity) {
322
- if (verbosity !== "full") return note.note;
323
- const details = [];
324
- const what = note.what;
325
- const where = note.where;
326
- if (typeof what === "string") {
327
- details.push(`what=${what}`);
328
- } else if (what) {
329
- if (what.field) details.push(`field=${String(what.field)}`);
330
- if (what.status) details.push(`status=${String(what.status)}`);
331
- }
332
- if (typeof where === "string") {
333
- details.push(`where=${where}`);
334
- } else if (where) {
335
- if (where.component) details.push(`component=${String(where.component)}`);
336
- }
337
- if (note.error) {
338
- const errorLine = [note.error.name, note.error.message].filter(Boolean).join(": ");
339
- if (errorLine) details.push(`error=${errorLine}`);
340
- }
341
- return details.length ? `${note.note} (${details.join(" ")})` : note.note;
342
- }
343
394
 
344
395
  // src/useStoryteller.ts
345
396
  var sharedInstance;
@@ -355,7 +406,7 @@ function useStoryteller(options = {}) {
355
406
  function dbAudience(insert) {
356
407
  return {
357
408
  name: "db",
358
- accepts: (event) => event.level === "warn" || event.level === "oops",
409
+ accepts: (event) => event.level === "Warning" || event.level === "Error",
359
410
  hear: async (event) => {
360
411
  await insert(event);
361
412
  }
@@ -367,10 +418,10 @@ function writeStoryReport(stories, options = {}) {
367
418
  const {
368
419
  timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
369
420
  locale = "en-US",
370
- verbosity = "normal",
371
- maxNotesPerStory = 50,
421
+ detail = "normal",
422
+ noteLimit = 50,
372
423
  showData = true,
373
- colorize = true
424
+ colors = true
374
425
  } = options;
375
426
  if (!stories.length) {
376
427
  return "Storyteller Report\n\n(no stories)\n";
@@ -407,17 +458,17 @@ function writeStoryReport(stories, options = {}) {
407
458
  for (const [day, dayStories] of storiesByDay) {
408
459
  lines.push(day);
409
460
  for (const story of dayStories) {
410
- const summary = summarizeStory(story, {
461
+ const report = formatStory(story, {
411
462
  timezone,
412
463
  locale,
413
- verbosity,
414
- maxNotes: maxNotesPerStory,
415
- colorize
464
+ detail,
465
+ noteLimit,
466
+ colors
416
467
  });
417
- const { data } = summary;
468
+ const { data } = report;
418
469
  const originLabel = formatOrigin(story.origin);
419
470
  const levelColor = getLevelColor(story.level);
420
- const label = (text) => colorize ? `${levelColor}${text}${ANSI.reset}` : text;
471
+ const label = (text) => colors ? `${levelColor}${text}${ANSI.reset}` : text;
421
472
  const duration = data.duration ? ` (${data.duration})` : "";
422
473
  lines.push(`${label("Story")}: ${story.title}`);
423
474
  lines.push(`${label("Level")}: ${story.level}`);
@@ -432,10 +483,10 @@ function writeStoryReport(stories, options = {}) {
432
483
  ].filter(Boolean).join(": ");
433
484
  if (errorLine) lines.push(`${label("Error")}: ${errorLine}`);
434
485
  }
435
- if (verbosity !== "brief" && data.notes.length) {
486
+ if (detail !== "brief" && data.notes.length) {
436
487
  lines.push(` ${label("Notes")}:`);
437
- for (const summaryNote of data.notes) {
438
- lines.push(` ${summaryNote.when} \u2014 ${summaryNote.text}`);
488
+ for (const reportNote of data.notes) {
489
+ lines.push(` ${reportNote.when} \u2014 ${reportNote.text}`);
439
490
  }
440
491
  if (story.notes.length > data.notes.length) {
441
492
  lines.push(
@@ -446,7 +497,7 @@ function writeStoryReport(stories, options = {}) {
446
497
  if (showData) {
447
498
  lines.push(`${label("Data")}:`);
448
499
  const json = JSON.stringify(data, null, 2);
449
- if (colorize) {
500
+ if (colors) {
450
501
  const colored = colorizeJsonSections(json, {
451
502
  base: ANSI.grayLight,
452
503
  notes: ANSI.grayDark,
@@ -465,11 +516,11 @@ function writeStoryReport(stories, options = {}) {
465
516
  export {
466
517
  ANSI,
467
518
  Storyteller,
468
- colorizeJsonSections,
469
519
  consoleAudience,
470
- countBrackets,
471
520
  dbAudience,
521
+ formatDuration,
472
522
  formatOrigin,
523
+ formatStory,
473
524
  getLevelColor,
474
525
  summarizeStory,
475
526
  useStoryteller,