@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.
- package/README.md +3 -1
- package/badges/RegistryMember.ts +2 -0
- package/badges/badgeSvg.ts +10 -1
- package/badges/registry.ts +5 -0
- package/cast-json.schema.json +1496 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +3 -2
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/machine/castJson.ts +17 -4
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/profileFromCastJson.ts +215 -0
- package/package.json +5 -1
- package/routes/404.astro +6 -4
- package/routes/cast.json.ts +7 -1
- package/routes/cv.astro +3 -365
- package/routes/index.astro +3 -55
- package/site/components/Activity.astro +9 -8
- package/site/components/Agentic.astro +11 -10
- package/site/components/CareerReplay.astro +11 -8
- package/site/components/CommandPalette.astro +10 -4
- package/site/components/Contact.astro +8 -5
- package/site/components/Cratis.astro +18 -16
- package/site/components/EventModel.astro +8 -5
- package/site/components/Footer.astro +9 -4
- package/site/components/Hero.astro +13 -10
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +4 -1
- package/site/components/Nav.astro +12 -9
- package/site/components/Now.astro +13 -10
- package/site/components/Projects.astro +11 -14
- package/site/components/PullRequestFeed.astro +6 -4
- package/site/components/SectionHeader.astro +6 -3
- package/site/components/Skills.astro +5 -2
- package/site/components/Stats.astro +9 -4
- package/site/components/Talks.astro +8 -5
- package/site/components/Terminal.astro +7 -2
- package/site/components/Writing.astro +9 -7
- package/site/data.ts +6 -43
- package/site/layouts/Base.astro +31 -19
- package/site/lib/blog.ts +42 -16
- package/site/lib/github.ts +56 -31
- package/site/lib/inline.ts +65 -9
- package/site/lib/nav.ts +14 -3
- package/site/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +120 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/live.ts +21 -11
- package/site/scripts/palette.ts +30 -13
- package/site/scripts/replay.ts +2 -1
- package/site/scripts/terminal.ts +40 -29
- package/writing/fetchWriting.ts +17 -2
- package/writing/writingSnapshotSchema.ts +27 -0
package/site/scripts/terminal.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
134
|
-
this.print(c.faint(`
|
|
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
|
|
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
|
|
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('
|
|
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 ??
|
|
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
|
|
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('
|
|
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
|
|
511
|
-
this.print(c.faint(`${
|
|
512
|
-
for (const r of
|
|
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
|
-
|
|
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();
|
package/writing/fetchWriting.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
+
});
|