@cratis/cast 0.1.0 → 0.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.
Files changed (57) hide show
  1. package/README.md +3 -1
  2. package/badges/RegistryMember.ts +2 -0
  3. package/badges/badgeSvg.ts +10 -1
  4. package/badges/registry.ts +5 -0
  5. package/cast-json.schema.json +1496 -0
  6. package/config/calendar.ts +34 -0
  7. package/config/castSchema.ts +3 -2
  8. package/github/gitHubSnapshotSchema.ts +58 -0
  9. package/machine/castJson.ts +17 -4
  10. package/machine/castJsonSchema.ts +35 -0
  11. package/machine/index.ts +6 -0
  12. package/machine/profileFromCastJson.ts +215 -0
  13. package/package.json +5 -1
  14. package/routes/404.astro +6 -4
  15. package/routes/cast.json.ts +7 -1
  16. package/routes/cv.astro +3 -365
  17. package/routes/index.astro +3 -55
  18. package/site/components/Activity.astro +9 -8
  19. package/site/components/Agentic.astro +11 -10
  20. package/site/components/CareerReplay.astro +11 -8
  21. package/site/components/CommandPalette.astro +10 -4
  22. package/site/components/Contact.astro +8 -5
  23. package/site/components/Cratis.astro +18 -16
  24. package/site/components/EventModel.astro +8 -5
  25. package/site/components/Footer.astro +9 -4
  26. package/site/components/Hero.astro +13 -10
  27. package/site/components/Inline.astro +45 -0
  28. package/site/components/Monogram.astro +4 -1
  29. package/site/components/Nav.astro +12 -9
  30. package/site/components/Now.astro +13 -10
  31. package/site/components/Projects.astro +11 -14
  32. package/site/components/PullRequestFeed.astro +6 -4
  33. package/site/components/SectionHeader.astro +6 -3
  34. package/site/components/Skills.astro +5 -2
  35. package/site/components/Stats.astro +9 -4
  36. package/site/components/Talks.astro +8 -5
  37. package/site/components/Terminal.astro +7 -2
  38. package/site/components/Writing.astro +9 -7
  39. package/site/data.ts +6 -43
  40. package/site/layouts/Base.astro +31 -19
  41. package/site/lib/blog.ts +42 -16
  42. package/site/lib/github.ts +56 -31
  43. package/site/lib/inline.ts +65 -9
  44. package/site/lib/nav.ts +14 -3
  45. package/site/pages/CvPage.astro +379 -0
  46. package/site/pages/ProfilePage.astro +78 -0
  47. package/site/profile/CastHost.ts +32 -0
  48. package/site/profile/CastView.ts +96 -0
  49. package/site/profile/assertRenderable.ts +151 -0
  50. package/site/profile/clientCast.ts +120 -0
  51. package/site/profile/index.ts +8 -0
  52. package/site/scripts/live.ts +21 -11
  53. package/site/scripts/palette.ts +30 -13
  54. package/site/scripts/replay.ts +2 -1
  55. package/site/scripts/terminal.ts +40 -29
  56. package/writing/fetchWriting.ts +17 -2
  57. package/writing/writingSnapshotSchema.ts +27 -0
@@ -3,16 +3,13 @@
3
3
 
4
4
  // <handle>@stream:~$ — an interactive terminal over the same data the page renders.
5
5
  // Lazy-loaded by Terminal.astro on first open.
6
- import { person, now, careerEvents, skills, cratis, talks, profileLinks, firstName, feedOrg, sections } from '../data';
7
- import { anchorFor } from '../lib/sections';
8
- import { blogHost } from '../lib/blog';
9
- import { github, cratisRepos, liveProjects as projects, mergedPrTotal, snapshotDate } from '../lib/github';
6
+ import { readClientCast, type ClientCast } from '../profile/clientCast';
10
7
  import { getLiveMerged } from './live';
11
8
  import { relativeTime } from '../lib/reltime';
12
- import { posts as blogPosts, themes as blogThemes } from '../lib/blog';
13
9
  import { copyText } from '../lib/clipboard';
14
10
  import { formatMonth } from '../lib/career';
15
- import { go, goSection, openExternal, startReplay } from '../lib/nav';
11
+ import { plainText } from '../lib/inline';
12
+ import { go, goSection, openExternal, profilePath, startReplay } from '../lib/nav';
16
13
  import { currentTheme, setTheme } from '../lib/theme';
17
14
 
18
15
  type Part = string | Node;
@@ -38,13 +35,6 @@ const ALL_SECTIONS = [
38
35
  ['skills', 'toolbox'],
39
36
  ['contact', 'contact'],
40
37
  ] as const;
41
- const SECTIONS = ALL_SECTIONS.filter(([id]) => id === 'top' || sections.some((section) => anchorFor[section.id] === id));
42
- const LINKS: Record<string, string> = {
43
- ...Object.fromEntries(profileLinks.map((link) => [link.key.toLowerCase(), link.url])),
44
- ...(cratis ? { cratis: Object.values(cratis.links)[0] ?? 'https://cratis.io' } : {}),
45
- };
46
- const prompt = `${person.handle}@stream`;
47
- const brewable = projects.filter((p) => p.install?.startsWith('brew'));
48
38
 
49
39
  // --- tiny DOM helpers --------------------------------------------------------------
50
40
  const span = (cls: string, text: string): HTMLSpanElement => {
@@ -61,12 +51,13 @@ const c = {
61
51
  faint: (t: string) => span('text-fg-faint', t),
62
52
  fg: (t: string) => span('text-fg', t),
63
53
  };
54
+ let linkRel = 'noopener';
64
55
  const link = (href: string, text = href.replace(/^https?:\/\/(www\.)?/, '')): HTMLAnchorElement => {
65
56
  const a = document.createElement('a');
66
- a.href = href;
57
+ a.href = /^(https?:|mailto:)/i.test(href) ? href : '#';
67
58
  a.textContent = text;
68
59
  a.target = '_blank';
69
- a.rel = 'noopener';
60
+ a.rel = linkRel;
70
61
  a.className = 'text-command-soft';
71
62
  return a;
72
63
  };
@@ -94,12 +85,15 @@ class Terminal {
94
85
  private returnFocus: HTMLElement | null = null;
95
86
  private greeted = false;
96
87
  private readonly commands: Record<string, Command>;
88
+ private readonly prompt: string;
97
89
 
98
90
  constructor(
91
+ private readonly cast: ClientCast,
99
92
  private readonly dialog: HTMLDialogElement,
100
93
  private readonly out: HTMLElement,
101
94
  private readonly input: HTMLInputElement,
102
95
  ) {
96
+ this.prompt = `${cast.person.handle}@stream`;
103
97
  try {
104
98
  this.history = JSON.parse(sessionStorage.getItem('cast:term-history') ?? '[]') as string[];
105
99
  } catch {
@@ -122,7 +116,7 @@ class Terminal {
122
116
  this.print(' ');
123
117
  }
124
118
  private echo(raw: string): void {
125
- this.print(c.view(prompt), c.faint(':'), c.cmd('~'), c.faint('$ '), c.fg(raw));
119
+ this.print(c.view(this.prompt), c.faint(':'), c.cmd('~'), c.faint('$ '), c.fg(raw));
126
120
  }
127
121
  clear(): void {
128
122
  this.out.replaceChildren();
@@ -130,14 +124,23 @@ class Terminal {
130
124
 
131
125
  private greet(): void {
132
126
  this.greeted = true;
133
- this.print(c.ev(prompt), c.faint(` · stream position ${careerEvents.length} · ${(getLiveMerged()?.total ?? mergedPrTotal).toLocaleString('en-US')} PRs merged in ${feedOrg}`));
134
- this.print(c.faint(`GitHub snapshot taken ${snapshotDate()}. Everything here is real data.`));
127
+ const { careerEvents, feedOrg, snapshotDate } = this.cast;
128
+ this.print(c.ev(this.prompt), c.faint(` · stream position ${careerEvents.length} · ${this.mergedTotal()} PRs merged in ${feedOrg}`));
129
+ this.print(c.faint(`GitHub snapshot taken ${snapshotDate}. Everything here is real data.`));
135
130
  this.print('Type ', c.fg('help'), ' to see what you can do, or tap a suggestion below.');
136
131
  this.blank();
137
132
  }
138
133
 
134
+ /** The merged pull request total: live once the browser has it, else the snapshot, marked when self-reported. */
135
+ private mergedTotal(): string {
136
+ const live = getLiveMerged();
137
+ const total = (live?.total ?? this.cast.mergedPrTotal).toLocaleString('en-US');
138
+ return !live && this.cast.mergedPrSelfReported ? `${total} (self-reported)` : total;
139
+ }
140
+
139
141
  // --- lifecycle ----------------------------------------------------------------
140
142
  open(): void {
143
+ linkRel = this.cast.rel;
141
144
  if (this.dialog.open) return;
142
145
  this.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
143
146
  if (!this.greeted) this.greet();
@@ -289,6 +292,13 @@ class Terminal {
289
292
 
290
293
  // --- commands -------------------------------------------------------------------
291
294
  private buildCommands(): Record<string, Command> {
295
+ const { person, now, careerEvents, skills, cratis, talks, profileLinks, firstName, feedOrg, projects, snapshotDate, themes: blogThemes, posts: blogPosts, blogHost } = this.cast;
296
+ const SECTIONS = ALL_SECTIONS.filter(([id]) => id === 'top' || this.cast.sections.includes(id));
297
+ const LINKS: Record<string, string> = {
298
+ ...Object.fromEntries(profileLinks.map((profile) => [profile.key.toLowerCase(), profile.url])),
299
+ ...(cratis ? { cratis: Object.values(cratis.links)[0] ?? 'https://cratis.io' } : {}),
300
+ };
301
+ const brewable = projects.filter((p) => p.install?.startsWith('brew'));
292
302
  const findProject = (q = '') => projects.find((p) => p.name.toLowerCase() === q.toLowerCase());
293
303
  const projectNames = () => projects.map((p) => p.name);
294
304
 
@@ -309,7 +319,7 @@ class Terminal {
309
319
  run: () => {
310
320
  this.print(c.fg(person.name), c.faint(` (${person.handle})`));
311
321
  this.print(person.title, ...(person.location ? [c.faint(' · '), person.location] : []));
312
- this.print(c.ev(person.tagline.replace(/\*/g, '')));
322
+ this.print(c.ev(plainText(person.tagline)));
313
323
  },
314
324
  },
315
325
  now: {
@@ -318,7 +328,7 @@ class Terminal {
318
328
  this.print(c.faint('Projection: '), c.view(`${firstName}.Now`), c.faint(` · projected from ${careerEvents.length} events`));
319
329
  const rows: [string, string][] = Object.entries(now).map(([key, value]) => [
320
330
  key,
321
- Array.isArray(value) ? value.join(', ') : /^\d{4}-\d{2}$/.test(value) ? formatMonth(value) : value.replace(/\*/g, ''),
331
+ Array.isArray(value) ? value.join(', ') : /^\d{4}-\d{2}$/.test(value) ? formatMonth(value) : plainText(value),
322
332
  ]);
323
333
  for (const [k, v] of rows) this.print(c.faint(pad(k, 12)), c.fg(v));
324
334
  },
@@ -346,7 +356,7 @@ class Terminal {
346
356
  run: ([to]) => {
347
357
  const id = (to ?? '').replace(/\/$/, '');
348
358
  if (!id || id === '~' || id === '/') return this.navigateSection('top');
349
- if (id === 'cv') return this.navigate('/cv');
359
+ if (id === 'cv') return this.navigate(profilePath('cv'));
350
360
  if (!SECTIONS.some(([s]) => s === id)) return this.print(c.ev('cd: '), `no such section: ${id}`);
351
361
  this.navigateSection(id);
352
362
  },
@@ -400,9 +410,9 @@ class Terminal {
400
410
  run: ([sub, flag, n]) => {
401
411
  if (sub !== 'tail') return this.print(c.ev('stream: '), 'try ', c.fg('stream tail'));
402
412
  const live = getLiveMerged();
403
- const prs = live?.latest ?? github.mergedPullRequests.latest;
413
+ const prs = live?.latest ?? this.cast.pullRequests;
404
414
  const count = flag === '-n' ? Math.max(1, Math.min(prs.length, Number(n) || prs.length)) : prs.length;
405
- this.print(c.faint(`tail ${feedOrg} · ${live ? 'live' : `snapshot taken ${snapshotDate()}`} · ${(live?.total ?? mergedPrTotal).toLocaleString('en-US')} merged in total`));
415
+ this.print(c.faint(`tail ${feedOrg} · ${live ? 'live' : `snapshot taken ${snapshotDate}`} · ${this.mergedTotal()} merged in total`));
406
416
  prs.slice(0, count).forEach((pr, i) => {
407
417
  this.print(
408
418
  c.faint(pad(i === 0 ? 'HEAD' : `HEAD~${i}`, 8)),
@@ -424,7 +434,7 @@ class Terminal {
424
434
  },
425
435
  cv: {
426
436
  desc: 'open the printable CV',
427
- run: () => this.navigate('/cv'),
437
+ run: () => this.navigate(profilePath('cv')),
428
438
  },
429
439
  skills: {
430
440
  desc: 'the toolbox, grouped',
@@ -507,9 +517,9 @@ class Terminal {
507
517
  desc: 'what Cratis is',
508
518
  run: () => {
509
519
  if (!cratis) return this.print(c.faint('Cratis makes event sourcing practical. '), link('https://cratis.io'));
510
- if (cratis.pitch) this.print(c.fg(cratis.pitch.replace(/\*/g, '')));
511
- this.print(c.faint(`${(getLiveMerged()?.total ?? mergedPrTotal).toLocaleString('en-US')} merged PRs · `), link(LINKS['cratis'] ?? 'https://cratis.io'));
512
- for (const r of cratisRepos) this.print(c.cmd(pad(r.name, 21)), c.faint(pad(r.role, 16)), r.url ? link(r.url, r.desc) : r.desc);
520
+ if (cratis.pitch) this.print(c.fg(plainText(cratis.pitch)));
521
+ this.print(c.faint(`${this.mergedTotal()} merged PRs · `), link(LINKS['cratis'] ?? 'https://cratis.io'));
522
+ for (const r of cratis.repos) this.print(c.cmd(pad(r.name, 21)), c.faint(pad(r.role, 16)), r.url ? link(r.url, r.desc) : r.desc);
513
523
  },
514
524
  },
515
525
  clear: { desc: 'clear the screen (Ctrl+L)', run: () => this.clear() },
@@ -546,10 +556,11 @@ export function openTerminal(): void {
546
556
  const dialog = document.querySelector<HTMLDialogElement>('[data-terminal]');
547
557
  const out = dialog?.querySelector<HTMLElement>('[data-terminal-out]');
548
558
  const input = dialog?.querySelector<HTMLInputElement>('[data-terminal-input]');
549
- if (!dialog || !out || !input) return;
559
+ const cast = readClientCast();
560
+ if (!dialog || !out || !input || !cast) return;
550
561
  let term = instances.get(dialog);
551
562
  if (!term) {
552
- term = new Terminal(dialog, out, input);
563
+ term = new Terminal(cast, dialog, out, input);
553
564
  instances.set(dialog, term);
554
565
  }
555
566
  term.open();
@@ -5,6 +5,7 @@ import type { z } from 'zod';
5
5
  import type { writingSourceSchema } from '../config/castSchema';
6
6
  import { mergePosts, parseFeed, parseStarlightAuthorPage } from './parse';
7
7
  import type { Post, WritingSnapshot } from './WritingSnapshot';
8
+ import { postSchema, writingLimits } from './writingSnapshotSchema';
8
9
 
9
10
  type WritingSource = z.output<typeof writingSourceSchema>;
10
11
  const maximumPages = 10;
@@ -49,6 +50,20 @@ export async function fetchWriting(sources: readonly WritingSource[], warn: (mes
49
50
  warn(`writing source ${'rss' in source ? source.rss : source.starlightBlogAuthor}: ${error instanceof Error ? error.message : String(error)}`);
50
51
  }
51
52
  }
52
- if (posts.length === 0) return undefined;
53
- return { fetchedAt: new Date().toISOString(), sources: origins, posts: mergePosts(posts) };
53
+ const kept = withinLimits(posts);
54
+ if (kept.length === 0) return undefined;
55
+ return { fetchedAt: new Date().toISOString(), sources: origins.slice(0, writingLimits.sources), posts: kept };
56
+ }
57
+
58
+ /** Newest posts first, trimmed to the limits a reader of cast.json enforces; posts that would still fail them are left out. */
59
+ export function withinLimits(posts: readonly Post[]): Post[] {
60
+ return mergePosts(posts)
61
+ .map(post => ({
62
+ ...post,
63
+ title: post.title.slice(0, writingLimits.title),
64
+ description: post.description.slice(0, writingLimits.description),
65
+ tags: post.tags.map(tag => tag.slice(0, writingLimits.tag)).slice(0, writingLimits.tags),
66
+ }))
67
+ .filter(post => postSchema.safeParse(post).success)
68
+ .slice(0, writingLimits.posts);
54
69
  }
@@ -0,0 +1,27 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { z } from 'zod';
5
+ import { isInstant } from '../config/calendar';
6
+
7
+ const instant = z.string().max(64).refine(isInstant, 'Use an ISO 8601 date and time on a real day');
8
+
9
+ /** The most a writing capture holds; the build that writes one keeps to the same limits. */
10
+ export const writingLimits = { sources: 20, posts: 300, title: 300, description: 2000, tags: 30, tag: 100 } as const;
11
+
12
+ /** One captured post. */
13
+ export const postSchema = z.object({
14
+ title: z.string().min(1).max(writingLimits.title),
15
+ url: z.url({ protocol: /^https?$/ }),
16
+ date: z.string().max(64).regex(/^\d{4}-\d{2}-\d{2}/, 'Start with YYYY-MM-DD').refine(isInstant, 'Use an ISO 8601 date on a real day'),
17
+ minutes: z.number().min(0).max(10_000).optional(),
18
+ description: z.string().max(writingLimits.description).default(''),
19
+ tags: z.array(z.string().max(writingLimits.tag)).max(writingLimits.tags).default([]),
20
+ });
21
+
22
+ /** Writing captured from feeds, as it appears in cast.json. */
23
+ export const writingSnapshotSchema = z.object({
24
+ fetchedAt: instant.optional(),
25
+ sources: z.array(z.url({ protocol: /^https?$/ })).max(writingLimits.sources).default([]),
26
+ posts: z.array(postSchema).max(writingLimits.posts).default([]),
27
+ });