@cratis/cast 0.0.1 → 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/README.md +13 -1
- package/badges/RegistryMember.ts +18 -0
- package/badges/ResolvedTier.ts +17 -0
- package/badges/Tier.ts +14 -0
- package/badges/TierSource.ts +14 -0
- package/badges/badgeSvg.ts +137 -0
- package/badges/constants.ts +17 -0
- package/badges/cratisMark.ts +15 -0
- package/badges/index.ts +12 -0
- package/badges/registry.ts +68 -0
- package/badges/resolveTier.ts +24 -0
- package/cast-json.schema.json +1496 -0
- package/cast.schema.json +1113 -0
- package/config/CastConfig.ts +17 -0
- package/config/CastConfigError.ts +10 -0
- package/config/LiveStat.ts +17 -0
- package/config/ResolvedSection.ts +13 -0
- package/config/SectionId.ts +21 -0
- package/config/ThemeMode.ts +9 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +263 -0
- package/config/index.ts +12 -0
- package/config/parseCast.ts +58 -0
- package/config/resolveSections.ts +48 -0
- package/github/GitHubSnapshot.ts +66 -0
- package/github/fetchGitHub.ts +235 -0
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/github/parse.ts +63 -0
- package/index.ts +88 -0
- package/integration/CastData.ts +20 -0
- package/integration/CastOptions.ts +17 -0
- package/integration/Snapshot.ts +12 -0
- package/integration/loadRegistry.ts +37 -0
- package/integration/resolveData.ts +138 -0
- package/integration/snapshotFile.ts +23 -0
- package/integration/virtualModule.ts +43 -0
- package/machine/castJson.ts +77 -0
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/profileFromCastJson.ts +214 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +80 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +42 -0
- package/routes/apple-touch-icon.png.ts +17 -0
- package/routes/badge-card.svg.ts +13 -0
- package/routes/badge.svg.ts +11 -0
- package/routes/cast.json.ts +17 -0
- package/routes/cv.astro +9 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +9 -0
- package/routes/llms.txt.ts +10 -0
- package/routes/og.png.ts +23 -0
- package/routes/resume.json.ts +13 -0
- package/routes/robots.txt.ts +10 -0
- package/site/components/Activity.astro +196 -0
- package/site/components/Agentic.astro +144 -0
- package/site/components/CareerReplay.astro +415 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +122 -0
- package/site/components/Contact.astro +166 -0
- package/site/components/Cratis.astro +235 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +265 -0
- package/site/components/Footer.astro +33 -0
- package/site/components/Hero.astro +211 -0
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +28 -0
- package/site/components/Nav.astro +127 -0
- package/site/components/Now.astro +116 -0
- package/site/components/Projects.astro +185 -0
- package/site/components/PullRequestFeed.astro +88 -0
- package/site/components/SectionHeader.astro +30 -0
- package/site/components/Skills.astro +44 -0
- package/site/components/Stats.astro +106 -0
- package/site/components/Talks.astro +76 -0
- package/site/components/Terminal.astro +164 -0
- package/site/components/Writing.astro +128 -0
- package/site/data.ts +11 -0
- package/site/layouts/Base.astro +156 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +58 -0
- package/site/lib/career.ts +148 -0
- package/site/lib/clipboard.ts +12 -0
- package/site/lib/contributions.ts +63 -0
- package/site/lib/github.ts +72 -0
- package/site/lib/inline.ts +79 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +49 -0
- package/site/lib/reltime.ts +33 -0
- package/site/lib/safeJson.ts +15 -0
- package/site/lib/sections.ts +41 -0
- package/site/lib/theme.ts +28 -0
- 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 +117 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/ambient.ts +93 -0
- package/site/scripts/event-board.ts +403 -0
- package/site/scripts/event-model.ts +155 -0
- package/site/scripts/heatmap.ts +94 -0
- package/site/scripts/live.ts +155 -0
- package/site/scripts/palette.ts +313 -0
- package/site/scripts/replay.ts +252 -0
- package/site/scripts/terminal.ts +559 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +69 -0
- package/writing/parse.ts +72 -0
- package/writing/writingSnapshotSchema.ts +27 -0
|
@@ -0,0 +1,559 @@
|
|
|
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
|
+
// <handle>@stream:~$ — an interactive terminal over the same data the page renders.
|
|
5
|
+
// Lazy-loaded by Terminal.astro on first open.
|
|
6
|
+
import { readClientCast, type ClientCast } from '../profile/clientCast';
|
|
7
|
+
import { getLiveMerged } from './live';
|
|
8
|
+
import { relativeTime } from '../lib/reltime';
|
|
9
|
+
import { copyText } from '../lib/clipboard';
|
|
10
|
+
import { formatMonth } from '../lib/career';
|
|
11
|
+
import { go, goSection, openExternal, profilePath, startReplay } from '../lib/nav';
|
|
12
|
+
import { currentTheme, setTheme } from '../lib/theme';
|
|
13
|
+
|
|
14
|
+
type Part = string | Node;
|
|
15
|
+
type Command = {
|
|
16
|
+
desc: string;
|
|
17
|
+
usage?: string;
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
run: (args: string[], raw: string) => void | Promise<void>;
|
|
20
|
+
complete?: (args: string[]) => string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ALL_SECTIONS = [
|
|
24
|
+
['top', 'hero'],
|
|
25
|
+
['about', 'now & about'],
|
|
26
|
+
['career', 'career replay'],
|
|
27
|
+
['model', 'event model'],
|
|
28
|
+
['cratis', 'open source'],
|
|
29
|
+
['activity', 'contributions'],
|
|
30
|
+
['agentic', 'agentic engineering'],
|
|
31
|
+
['projects', 'things I build'],
|
|
32
|
+
['writing', 'blog posts'],
|
|
33
|
+
['talks', 'talks & writing'],
|
|
34
|
+
['skills', 'toolbox'],
|
|
35
|
+
['contact', 'contact'],
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
// --- tiny DOM helpers --------------------------------------------------------------
|
|
39
|
+
const span = (cls: string, text: string): HTMLSpanElement => {
|
|
40
|
+
const s = document.createElement('span');
|
|
41
|
+
s.className = cls;
|
|
42
|
+
s.textContent = text;
|
|
43
|
+
return s;
|
|
44
|
+
};
|
|
45
|
+
const c = {
|
|
46
|
+
ev: (t: string) => span('text-event', t),
|
|
47
|
+
cmd: (t: string) => span('text-command', t),
|
|
48
|
+
view: (t: string) => span('text-view', t),
|
|
49
|
+
muted: (t: string) => span('text-fg-muted', t),
|
|
50
|
+
faint: (t: string) => span('text-fg-faint', t),
|
|
51
|
+
fg: (t: string) => span('text-fg', t),
|
|
52
|
+
};
|
|
53
|
+
let linkRel = 'noopener';
|
|
54
|
+
const link = (href: string, text = href.replace(/^https?:\/\/(www\.)?/, '')): HTMLAnchorElement => {
|
|
55
|
+
const a = document.createElement('a');
|
|
56
|
+
a.href = /^(https?:|mailto:)/i.test(href) ? href : '#';
|
|
57
|
+
a.textContent = text;
|
|
58
|
+
a.target = '_blank';
|
|
59
|
+
a.rel = linkRel;
|
|
60
|
+
a.className = 'text-command-soft';
|
|
61
|
+
return a;
|
|
62
|
+
};
|
|
63
|
+
const pad = (s: string, n: number) => s + ' '.repeat(Math.max(1, n - s.length));
|
|
64
|
+
|
|
65
|
+
/** Levenshtein distance, for "did you mean" suggestions. */
|
|
66
|
+
function distance(a: string, b: string): number {
|
|
67
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
68
|
+
for (let i = 1; i <= a.length; i++) {
|
|
69
|
+
let prev = row[0]!;
|
|
70
|
+
row[0] = i;
|
|
71
|
+
for (let j = 1; j <= b.length; j++) {
|
|
72
|
+
const tmp = row[j]!;
|
|
73
|
+
row[j] = Math.min(row[j]! + 1, row[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
74
|
+
prev = tmp;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return row[b.length]!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class Terminal {
|
|
81
|
+
private history: string[] = [];
|
|
82
|
+
private cursor = 0;
|
|
83
|
+
private draft = '';
|
|
84
|
+
private returnFocus: HTMLElement | null = null;
|
|
85
|
+
private greeted = false;
|
|
86
|
+
private readonly commands: Record<string, Command>;
|
|
87
|
+
private readonly prompt: string;
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly cast: ClientCast,
|
|
91
|
+
private readonly dialog: HTMLDialogElement,
|
|
92
|
+
private readonly out: HTMLElement,
|
|
93
|
+
private readonly input: HTMLInputElement,
|
|
94
|
+
) {
|
|
95
|
+
this.prompt = `${cast.person.handle}@stream`;
|
|
96
|
+
try {
|
|
97
|
+
this.history = JSON.parse(sessionStorage.getItem('cast:term-history') ?? '[]') as string[];
|
|
98
|
+
} catch {
|
|
99
|
+
this.history = [];
|
|
100
|
+
}
|
|
101
|
+
this.cursor = this.history.length;
|
|
102
|
+
this.commands = this.buildCommands();
|
|
103
|
+
this.bind();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- output ------------------------------------------------------------------
|
|
107
|
+
print(...parts: Part[]): void {
|
|
108
|
+
const line = document.createElement('div');
|
|
109
|
+
line.className = 'ln text-fg-muted';
|
|
110
|
+
line.append(...parts);
|
|
111
|
+
this.out.append(line);
|
|
112
|
+
this.out.scrollTop = this.out.scrollHeight;
|
|
113
|
+
}
|
|
114
|
+
blank(): void {
|
|
115
|
+
this.print(' ');
|
|
116
|
+
}
|
|
117
|
+
private echo(raw: string): void {
|
|
118
|
+
this.print(c.view(this.prompt), c.faint(':'), c.cmd('~'), c.faint('$ '), c.fg(raw));
|
|
119
|
+
}
|
|
120
|
+
clear(): void {
|
|
121
|
+
this.out.replaceChildren();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private greet(): void {
|
|
125
|
+
this.greeted = true;
|
|
126
|
+
const { careerEvents, mergedPrTotal, feedOrg, snapshotDate } = this.cast;
|
|
127
|
+
this.print(c.ev(this.prompt), c.faint(` · stream position ${careerEvents.length} · ${(getLiveMerged()?.total ?? mergedPrTotal).toLocaleString('en-US')} PRs merged in ${feedOrg}`));
|
|
128
|
+
this.print(c.faint(`GitHub snapshot taken ${snapshotDate}. Everything here is real data.`));
|
|
129
|
+
this.print('Type ', c.fg('help'), ' to see what you can do, or tap a suggestion below.');
|
|
130
|
+
this.blank();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- lifecycle ----------------------------------------------------------------
|
|
134
|
+
open(): void {
|
|
135
|
+
linkRel = this.cast.rel;
|
|
136
|
+
if (this.dialog.open) return;
|
|
137
|
+
this.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
138
|
+
if (!this.greeted) this.greet();
|
|
139
|
+
this.dialog.showModal();
|
|
140
|
+
document.documentElement.style.overflow = 'hidden';
|
|
141
|
+
this.input.focus();
|
|
142
|
+
}
|
|
143
|
+
close(restoreFocus = true): void {
|
|
144
|
+
if (!restoreFocus) this.returnFocus = null;
|
|
145
|
+
if (this.dialog.open) this.dialog.close();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private bind(): void {
|
|
149
|
+
const { dialog, input, out } = this;
|
|
150
|
+
dialog.addEventListener('close', () => {
|
|
151
|
+
document.documentElement.style.overflow = '';
|
|
152
|
+
if (this.returnFocus && this.returnFocus !== document.body && this.returnFocus.isConnected) this.returnFocus.focus({ preventScroll: true });
|
|
153
|
+
// Nothing to return to: drop focus from the closed dialog once the browser is done closing it.
|
|
154
|
+
else window.setTimeout(() => dialog.contains(document.activeElement) && (document.activeElement as HTMLElement).blur(), 0);
|
|
155
|
+
});
|
|
156
|
+
dialog.addEventListener('click', (e) => {
|
|
157
|
+
if (e.target === dialog) this.close();
|
|
158
|
+
});
|
|
159
|
+
dialog.querySelector('[data-terminal-close]')?.addEventListener('click', () => this.close());
|
|
160
|
+
dialog.querySelector('[data-terminal-form]')?.addEventListener('submit', (e) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
void this.exec(input.value);
|
|
163
|
+
});
|
|
164
|
+
dialog.querySelector('[data-terminal-chips]')?.addEventListener('click', (e) => {
|
|
165
|
+
const chip = (e.target as Element).closest<HTMLButtonElement>('[data-cmd]');
|
|
166
|
+
if (!chip) return;
|
|
167
|
+
void this.exec(chip.dataset['cmd'] ?? '');
|
|
168
|
+
input.focus();
|
|
169
|
+
});
|
|
170
|
+
// Clicking the output focuses the prompt, unless the visitor is selecting text or a link.
|
|
171
|
+
out.addEventListener('click', (e) => {
|
|
172
|
+
if ((e.target as Element).closest('a') || window.getSelection()?.toString()) return;
|
|
173
|
+
input.focus();
|
|
174
|
+
});
|
|
175
|
+
input.addEventListener('keydown', (e) => this.onKey(e));
|
|
176
|
+
// Keep Tab completion inside the prompt and Shift+Tab cycling within the dialog.
|
|
177
|
+
dialog.addEventListener('keydown', (e) => {
|
|
178
|
+
if (e.key !== 'Tab' || e.target === input) return;
|
|
179
|
+
const stops = [...dialog.querySelectorAll<HTMLElement>('input, button, a[href]')];
|
|
180
|
+
const first = stops[0];
|
|
181
|
+
const last = stops[stops.length - 1];
|
|
182
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
last?.focus();
|
|
185
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
first?.focus();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private onKey(e: KeyboardEvent): void {
|
|
193
|
+
const { input } = this;
|
|
194
|
+
if (e.key === 'ArrowUp') {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
if (this.cursor === this.history.length) this.draft = input.value;
|
|
197
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
198
|
+
input.value = this.history[this.cursor] ?? this.draft;
|
|
199
|
+
requestAnimationFrame(() => input.setSelectionRange(input.value.length, input.value.length));
|
|
200
|
+
} else if (e.key === 'ArrowDown') {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
this.cursor = Math.min(this.history.length, this.cursor + 1);
|
|
203
|
+
input.value = this.cursor === this.history.length ? this.draft : (this.history[this.cursor] ?? '');
|
|
204
|
+
} else if (e.key === 'Tab' && !e.shiftKey) {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
this.complete();
|
|
207
|
+
} else if (e.key.toLowerCase() === 'l' && e.ctrlKey) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
this.clear();
|
|
210
|
+
} else if (e.key.toLowerCase() === 'c' && e.ctrlKey && input.selectionStart === input.selectionEnd) {
|
|
211
|
+
// Ctrl+C with no selection cancels the line, like a shell.
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
this.echo(`${input.value}^C`);
|
|
214
|
+
input.value = '';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private complete(): void {
|
|
219
|
+
const { input } = this;
|
|
220
|
+
const value = input.value;
|
|
221
|
+
const parts = value.split(/\s+/);
|
|
222
|
+
let candidates: string[];
|
|
223
|
+
let prefix: string;
|
|
224
|
+
if (parts.length <= 1) {
|
|
225
|
+
prefix = parts[0] ?? '';
|
|
226
|
+
candidates = Object.entries(this.commands).filter(([, cmd]) => !cmd.hidden).map(([name]) => name);
|
|
227
|
+
} else {
|
|
228
|
+
prefix = parts[parts.length - 1] ?? '';
|
|
229
|
+
candidates = this.commands[parts[0] ?? '']?.complete?.(parts.slice(1, -1)) ?? [];
|
|
230
|
+
}
|
|
231
|
+
const matches = candidates.filter((x) => x.startsWith(prefix));
|
|
232
|
+
if (matches.length === 1) {
|
|
233
|
+
parts[parts.length - 1] = matches[0]!;
|
|
234
|
+
input.value = `${parts.join(' ')} `;
|
|
235
|
+
} else if (matches.length > 1) {
|
|
236
|
+
// Extend to the longest common prefix, then list the options.
|
|
237
|
+
let common = matches[0]!;
|
|
238
|
+
for (const m of matches) while (!m.startsWith(common)) common = common.slice(0, -1);
|
|
239
|
+
if (common.length > prefix.length) {
|
|
240
|
+
parts[parts.length - 1] = common;
|
|
241
|
+
input.value = parts.join(' ');
|
|
242
|
+
} else {
|
|
243
|
+
this.echo(value);
|
|
244
|
+
this.print(...matches.flatMap((m) => [c.fg(m), ' ']));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async exec(raw: string): Promise<void> {
|
|
250
|
+
const line = raw.trim();
|
|
251
|
+
this.input.value = '';
|
|
252
|
+
this.echo(line);
|
|
253
|
+
if (!line) return;
|
|
254
|
+
if (this.history[this.history.length - 1] !== line) this.history.push(line);
|
|
255
|
+
this.history = this.history.slice(-50);
|
|
256
|
+
this.cursor = this.history.length;
|
|
257
|
+
this.draft = '';
|
|
258
|
+
try {
|
|
259
|
+
sessionStorage.setItem('cast:term-history', JSON.stringify(this.history));
|
|
260
|
+
} catch {
|
|
261
|
+
/* ignore */
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (/^sudo\b/.test(line)) return this.print(c.ev('Permission denied:'), ' event streams are append-only.');
|
|
265
|
+
if (/^rm\b/.test(line)) return this.print(c.ev('Events are immutable.'), ' Try appending a compensating event.');
|
|
266
|
+
|
|
267
|
+
const [name = '', ...args] = line.split(/\s+/);
|
|
268
|
+
const cmd = this.commands[name.toLowerCase()];
|
|
269
|
+
if (!cmd) {
|
|
270
|
+
const names = Object.entries(this.commands).filter(([, x]) => !x.hidden).map(([n]) => n);
|
|
271
|
+
const near = names
|
|
272
|
+
.map((n) => ({ n, d: distance(name.toLowerCase(), n) }))
|
|
273
|
+
.filter((x) => x.d <= 2 || x.n.startsWith(name.toLowerCase()))
|
|
274
|
+
.sort((a, b) => a.d - b.d)
|
|
275
|
+
.slice(0, 3)
|
|
276
|
+
.map((x) => x.n);
|
|
277
|
+
this.print(c.ev('command not found: '), c.fg(name));
|
|
278
|
+
if (near.length) this.print('Did you mean ', ...near.flatMap((n, i) => [c.fg(n), i < near.length - 1 ? ', ' : '']), '? Or type ', c.fg('help'), '.');
|
|
279
|
+
else this.print('Type ', c.fg('help'), ' for the list of commands.');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await cmd.run(args, line);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- commands -------------------------------------------------------------------
|
|
286
|
+
private buildCommands(): Record<string, Command> {
|
|
287
|
+
const { person, now, careerEvents, skills, cratis, talks, profileLinks, firstName, feedOrg, projects, mergedPrTotal, snapshotDate, themes: blogThemes, posts: blogPosts, blogHost } = this.cast;
|
|
288
|
+
const SECTIONS = ALL_SECTIONS.filter(([id]) => id === 'top' || this.cast.sections.includes(id));
|
|
289
|
+
const LINKS: Record<string, string> = {
|
|
290
|
+
...Object.fromEntries(profileLinks.map((profile) => [profile.key.toLowerCase(), profile.url])),
|
|
291
|
+
...(cratis ? { cratis: Object.values(cratis.links)[0] ?? 'https://cratis.io' } : {}),
|
|
292
|
+
};
|
|
293
|
+
const brewable = projects.filter((p) => p.install?.startsWith('brew'));
|
|
294
|
+
const findProject = (q = '') => projects.find((p) => p.name.toLowerCase() === q.toLowerCase());
|
|
295
|
+
const projectNames = () => projects.map((p) => p.name);
|
|
296
|
+
|
|
297
|
+
const cmds: Record<string, Command> = {
|
|
298
|
+
help: {
|
|
299
|
+
desc: 'list commands',
|
|
300
|
+
run: () => {
|
|
301
|
+
this.print(c.faint('Commands are projections over the same event stream as the page.'));
|
|
302
|
+
for (const [name, cmd] of Object.entries(this.commands)) {
|
|
303
|
+
if (cmd.hidden) continue;
|
|
304
|
+
this.print(c.cmd(pad(cmd.usage ?? name, 26)), cmd.desc);
|
|
305
|
+
}
|
|
306
|
+
this.print(c.faint('Keys: ↑/↓ history · Tab complete · Ctrl+L clear · Esc close'));
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
whoami: {
|
|
310
|
+
desc: 'who is behind this stream',
|
|
311
|
+
run: () => {
|
|
312
|
+
this.print(c.fg(person.name), c.faint(` (${person.handle})`));
|
|
313
|
+
this.print(person.title, ...(person.location ? [c.faint(' · '), person.location] : []));
|
|
314
|
+
this.print(c.ev(person.tagline.replace(/\*/g, '')));
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
now: {
|
|
318
|
+
desc: `the ${firstName}.Now read model`,
|
|
319
|
+
run: () => {
|
|
320
|
+
this.print(c.faint('Projection: '), c.view(`${firstName}.Now`), c.faint(` · projected from ${careerEvents.length} events`));
|
|
321
|
+
const rows: [string, string][] = Object.entries(now).map(([key, value]) => [
|
|
322
|
+
key,
|
|
323
|
+
Array.isArray(value) ? value.join(', ') : /^\d{4}-\d{2}$/.test(value) ? formatMonth(value) : value.replace(/\*/g, ''),
|
|
324
|
+
]);
|
|
325
|
+
for (const [k, v] of rows) this.print(c.faint(pad(k, 12)), c.fg(v));
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
ls: {
|
|
329
|
+
desc: 'list sections, projects or blog posts',
|
|
330
|
+
usage: 'ls [projects|blog]',
|
|
331
|
+
complete: () => ['projects', 'blog'],
|
|
332
|
+
run: ([what]) => {
|
|
333
|
+
if (what === 'blog') return this.commands['blog']!.run([], 'blog');
|
|
334
|
+
if (what === 'projects') {
|
|
335
|
+
for (const p of projects) this.print(c.view(pad(p.name, 12)), c.faint(pad(p.version ?? '', 9)), c.faint(pad(p.category, 8)), p.pitch);
|
|
336
|
+
this.print(c.faint('cat <project> for details'));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (what) return this.print(c.ev('ls: '), `cannot access '${what}': try `, c.fg('ls'), ' or ', c.fg('ls projects'));
|
|
340
|
+
this.print(...SECTIONS.flatMap(([id]) => [c.cmd(`${id}/`), ' ']));
|
|
341
|
+
this.print(c.faint('cd <section> to jump there'));
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
cd: {
|
|
345
|
+
desc: 'jump to a section',
|
|
346
|
+
usage: 'cd <section>',
|
|
347
|
+
complete: () => SECTIONS.map(([id]) => id),
|
|
348
|
+
run: ([to]) => {
|
|
349
|
+
const id = (to ?? '').replace(/\/$/, '');
|
|
350
|
+
if (!id || id === '~' || id === '/') return this.navigateSection('top');
|
|
351
|
+
if (id === 'cv') return this.navigate(profilePath('cv'));
|
|
352
|
+
if (!SECTIONS.some(([s]) => s === id)) return this.print(c.ev('cd: '), `no such section: ${id}`);
|
|
353
|
+
this.navigateSection(id);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
cat: {
|
|
357
|
+
desc: 'show a project',
|
|
358
|
+
usage: 'cat <project>',
|
|
359
|
+
complete: projectNames,
|
|
360
|
+
run: ([name]) => {
|
|
361
|
+
const p = findProject(name);
|
|
362
|
+
if (!p) {
|
|
363
|
+
this.print(c.ev('cat: '), name ? `${name}: no such project` : 'missing project name');
|
|
364
|
+
return this.print('Try one of: ', ...projectNames().flatMap((n, i, a) => [c.fg(n), i < a.length - 1 ? ', ' : '']));
|
|
365
|
+
}
|
|
366
|
+
this.print(c.view(p.name), p.version ? c.faint(` ${p.version}`) : '', c.faint(` · ${p.category}`));
|
|
367
|
+
this.print(c.fg(p.pitch));
|
|
368
|
+
for (const f of p.features) this.print(c.ev(' • '), f);
|
|
369
|
+
this.print(c.faint('stack '), p.stack.join(', '));
|
|
370
|
+
if (p.install) this.print(c.faint('install '), c.cmd(p.install));
|
|
371
|
+
this.print(c.faint('source '), link(p.url));
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
git: {
|
|
375
|
+
desc: 'career events, newest first',
|
|
376
|
+
usage: 'git log [--oneline]',
|
|
377
|
+
complete: (args) => (args.length ? ['--oneline'] : ['log']),
|
|
378
|
+
run: ([sub, flag]) => {
|
|
379
|
+
if (sub !== 'log') return this.print(c.ev('git: '), `'${sub ?? ''}' is not supported here. Try `, c.fg('git log'), ' or ', c.fg('git log --oneline'));
|
|
380
|
+
const events = [...careerEvents].reverse();
|
|
381
|
+
events.forEach((e, i) => {
|
|
382
|
+
const head = i === 0 ? [c.faint(' ('), c.cmd('HEAD -> '), c.view('stream'), c.faint(')')] : [];
|
|
383
|
+
if (flag === '--oneline') {
|
|
384
|
+
this.print(c.ev(`#${String(e.seq).padStart(2, '0')}`), ...head, ' ', c.faint(e.date), ' ', c.ev(e.type), ' ', c.fg(e.title), e.org ? c.faint(` · ${e.org}`) : '');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.print(c.ev(`event #${e.seq}`), ...head);
|
|
388
|
+
this.print(c.faint('Type: '), c.ev(e.type));
|
|
389
|
+
this.print(c.faint('Date: '), formatMonth(e.date));
|
|
390
|
+
if (e.org) this.print(c.faint('Org: '), e.org);
|
|
391
|
+
this.blank();
|
|
392
|
+
this.print(' ', c.fg(e.title));
|
|
393
|
+
this.print(' ', e.summary);
|
|
394
|
+
this.blank();
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
stream: {
|
|
399
|
+
desc: 'latest merged PRs from GitHub',
|
|
400
|
+
usage: 'stream tail [-n N]',
|
|
401
|
+
complete: (args) => (args.length ? ['-n'] : ['tail']),
|
|
402
|
+
run: ([sub, flag, n]) => {
|
|
403
|
+
if (sub !== 'tail') return this.print(c.ev('stream: '), 'try ', c.fg('stream tail'));
|
|
404
|
+
const live = getLiveMerged();
|
|
405
|
+
const prs = live?.latest ?? this.cast.pullRequests;
|
|
406
|
+
const count = flag === '-n' ? Math.max(1, Math.min(prs.length, Number(n) || prs.length)) : prs.length;
|
|
407
|
+
this.print(c.faint(`tail ${feedOrg} · ${live ? 'live' : `snapshot taken ${snapshotDate}`} · ${(live?.total ?? mergedPrTotal).toLocaleString('en-US')} merged in total`));
|
|
408
|
+
prs.slice(0, count).forEach((pr, i) => {
|
|
409
|
+
this.print(
|
|
410
|
+
c.faint(pad(i === 0 ? 'HEAD' : `HEAD~${i}`, 8)),
|
|
411
|
+
c.ev('PullRequestMerged '),
|
|
412
|
+
c.cmd(`${pr.repository}#${pr.number} `),
|
|
413
|
+
link(pr.url, pr.title),
|
|
414
|
+
c.faint(` · ${relativeTime(pr.mergedAt)}`),
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
if (!prs.length) this.print(c.faint(live ? 'EventNotFound: the live feed has no merged pull requests.' : 'EventNotFound: the snapshot has no merged pull requests.'));
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
replay: {
|
|
421
|
+
desc: 'replay my career on the page',
|
|
422
|
+
run: () => {
|
|
423
|
+
this.close(false);
|
|
424
|
+
startReplay();
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
cv: {
|
|
428
|
+
desc: 'open the printable CV',
|
|
429
|
+
run: () => this.navigate(profilePath('cv')),
|
|
430
|
+
},
|
|
431
|
+
skills: {
|
|
432
|
+
desc: 'the toolbox, grouped',
|
|
433
|
+
run: () => {
|
|
434
|
+
for (const [group, list] of Object.entries(skills)) {
|
|
435
|
+
this.print(c.view(group));
|
|
436
|
+
this.print(' ', list.join(' · '));
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
contact: {
|
|
441
|
+
desc: 'how to reach me',
|
|
442
|
+
usage: 'contact [--copy]',
|
|
443
|
+
complete: () => ['--copy'],
|
|
444
|
+
run: async ([flag]) => {
|
|
445
|
+
if (person.email) this.print(c.faint(pad('email', 10)), link(`mailto:${person.email}`, person.email));
|
|
446
|
+
for (const profile of profileLinks) this.print(c.faint(pad(profile.key, 10)), link(profile.url));
|
|
447
|
+
if (!person.email) return;
|
|
448
|
+
if (flag === '--copy') this.print((await copyText(person.email)) ? c.view(`✓ copied ${person.email}`) : c.ev('copy failed; select the address above'));
|
|
449
|
+
else this.print(c.faint('contact --copy copies the email address'));
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
open: {
|
|
453
|
+
desc: 'open a profile in a new tab',
|
|
454
|
+
usage: `open ${Object.keys(LINKS).join('|')}`,
|
|
455
|
+
complete: () => Object.keys(LINKS),
|
|
456
|
+
run: ([target]) => {
|
|
457
|
+
const url = LINKS[target ?? ''];
|
|
458
|
+
if (!url) return this.print(c.ev('open: '), 'choose one of ', ...Object.keys(LINKS).flatMap((k, i, a) => [c.fg(k), i < a.length - 1 ? ', ' : '']));
|
|
459
|
+
this.print(c.faint('opening '), link(url));
|
|
460
|
+
openExternal(url);
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
brew: {
|
|
464
|
+
desc: 'install one of my CLI tools',
|
|
465
|
+
usage: 'brew install <tool>',
|
|
466
|
+
complete: (args) => (args.length ? brewable.map((p) => p.name) : ['install']),
|
|
467
|
+
run: async ([sub, name]) => {
|
|
468
|
+
if (sub !== 'install' || !name) return this.print('Usage: ', c.fg('brew install <tool>'), c.faint(` (${brewable.map((p) => p.name).join(', ')})`));
|
|
469
|
+
const p = brewable.find((x) => x.name === name.toLowerCase());
|
|
470
|
+
if (!p?.install) return this.print(c.ev('Error: '), `No available formula with the name "${name}". Try: ${brewable.map((x) => x.name).join(', ')}`);
|
|
471
|
+
this.print(c.cmd('==> '), c.fg(`${p.name} ${p.version ?? ''}`.trim()), c.faint(` · ${p.pitch}`));
|
|
472
|
+
this.print(c.faint('Run this in your own terminal:'));
|
|
473
|
+
this.print(' ', c.cmd(p.install));
|
|
474
|
+
this.print((await copyText(p.install)) ? c.view('✓ copied to the clipboard') : c.faint('(select the line above to copy it)'));
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
theme: {
|
|
478
|
+
desc: 'switch the color theme',
|
|
479
|
+
usage: 'theme light|dark',
|
|
480
|
+
complete: () => ['light', 'dark'],
|
|
481
|
+
run: ([t]) => {
|
|
482
|
+
if (t !== 'light' && t !== 'dark') return this.print('theme is ', c.fg(currentTheme()), c.faint('; use theme light or theme dark'));
|
|
483
|
+
setTheme(t);
|
|
484
|
+
this.print(c.view('✓ '), `theme set to ${t}`);
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
blog: {
|
|
488
|
+
desc: blogHost ? `my posts on ${blogHost}` : 'my posts',
|
|
489
|
+
usage: 'blog [theme]',
|
|
490
|
+
complete: () => blogThemes.map((t) => t.id),
|
|
491
|
+
run: ([theme]) => {
|
|
492
|
+
const t = blogThemes.find((x) => x.id === theme);
|
|
493
|
+
if (theme && !t) return this.print(c.ev('blog: '), 'themes are ', ...blogThemes.flatMap((x, i, a) => [c.fg(x.id), i < a.length - 1 ? ', ' : '']));
|
|
494
|
+
const list = t ? blogPosts.filter((p) => p.themes.includes(t.id)) : blogPosts;
|
|
495
|
+
this.print(c.faint(`${list.length} post${list.length === 1 ? '' : 's'}${t ? ` about ${t.label}` : ''}${blogHost ? ` on ${blogHost}` : ''}`));
|
|
496
|
+
for (const p of list) this.print(c.ev(pad(p.part !== undefined ? `Part ${String(p.part).padStart(2, '0')}` : 'Overview', 9)), link(p.url, p.headline), c.faint(p.minutes ? ` · ${p.minutes} min` : ''));
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
talks: {
|
|
500
|
+
desc: 'talks & writing',
|
|
501
|
+
run: () => {
|
|
502
|
+
for (const t of talks) {
|
|
503
|
+
this.print(c.ev(t.date.slice(0, 4)), ' ', c.fg(t.title), c.faint(` · ${t.host}`));
|
|
504
|
+
if (t.url) this.print(' ', link(t.url, 'watch'));
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
cratis: {
|
|
509
|
+
desc: 'what Cratis is',
|
|
510
|
+
run: () => {
|
|
511
|
+
if (!cratis) return this.print(c.faint('Cratis makes event sourcing practical. '), link('https://cratis.io'));
|
|
512
|
+
if (cratis.pitch) this.print(c.fg(cratis.pitch.replace(/\*/g, '')));
|
|
513
|
+
this.print(c.faint(`${(getLiveMerged()?.total ?? mergedPrTotal).toLocaleString('en-US')} merged PRs · `), link(LINKS['cratis'] ?? 'https://cratis.io'));
|
|
514
|
+
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);
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
clear: { desc: 'clear the screen (Ctrl+L)', run: () => this.clear() },
|
|
518
|
+
exit: { desc: 'close the terminal (Esc)', run: () => this.close() },
|
|
519
|
+
// Hidden aliases.
|
|
520
|
+
history: {
|
|
521
|
+
desc: 'command history',
|
|
522
|
+
hidden: true,
|
|
523
|
+
run: () => this.history.forEach((h, i) => this.print(c.faint(String(i + 1).padStart(4)), ' ', h)),
|
|
524
|
+
},
|
|
525
|
+
pwd: { desc: '', hidden: true, run: () => this.print(location.pathname) },
|
|
526
|
+
echo: { desc: '', hidden: true, run: (_, raw) => this.print(raw.replace(/^echo\s?/, '')) },
|
|
527
|
+
man: { desc: '', hidden: true, run: () => this.commands['help']!.run([], 'help') },
|
|
528
|
+
vim: { desc: '', hidden: true, run: () => this.print('You are now trapped. Just kidding: press ', c.fg('Esc'), '.') },
|
|
529
|
+
};
|
|
530
|
+
return cmds;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private navigateSection(id: string): void {
|
|
534
|
+
this.close(false);
|
|
535
|
+
goSection(id);
|
|
536
|
+
}
|
|
537
|
+
private navigate(href: string): void {
|
|
538
|
+
this.print(c.faint(`navigating to ${href}…`));
|
|
539
|
+
this.close(false);
|
|
540
|
+
void go(href);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// One Terminal per dialog element: the view-transition router replaces the DOM on navigation.
|
|
545
|
+
const instances = new WeakMap<HTMLDialogElement, Terminal>();
|
|
546
|
+
|
|
547
|
+
export function openTerminal(): void {
|
|
548
|
+
const dialog = document.querySelector<HTMLDialogElement>('[data-terminal]');
|
|
549
|
+
const out = dialog?.querySelector<HTMLElement>('[data-terminal-out]');
|
|
550
|
+
const input = dialog?.querySelector<HTMLInputElement>('[data-terminal-input]');
|
|
551
|
+
const cast = readClientCast();
|
|
552
|
+
if (!dialog || !out || !input || !cast) return;
|
|
553
|
+
let term = instances.get(dialog);
|
|
554
|
+
if (!term) {
|
|
555
|
+
term = new Terminal(cast, dialog, out, input);
|
|
556
|
+
instances.set(dialog, term);
|
|
557
|
+
}
|
|
558
|
+
term.open();
|
|
559
|
+
}
|