@cratis/cast 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.
Files changed (56) hide show
  1. package/README.md +3 -1
  2. package/badges/RegistryMember.ts +2 -0
  3. package/badges/registry.ts +5 -0
  4. package/cast-json.schema.json +1496 -0
  5. package/config/calendar.ts +34 -0
  6. package/config/castSchema.ts +3 -2
  7. package/github/gitHubSnapshotSchema.ts +58 -0
  8. package/machine/castJson.ts +17 -4
  9. package/machine/castJsonSchema.ts +35 -0
  10. package/machine/index.ts +6 -0
  11. package/machine/profileFromCastJson.ts +214 -0
  12. package/package.json +5 -1
  13. package/routes/404.astro +6 -4
  14. package/routes/cast.json.ts +7 -1
  15. package/routes/cv.astro +3 -365
  16. package/routes/index.astro +3 -55
  17. package/site/components/Activity.astro +8 -6
  18. package/site/components/Agentic.astro +11 -10
  19. package/site/components/CareerReplay.astro +11 -8
  20. package/site/components/CommandPalette.astro +10 -4
  21. package/site/components/Contact.astro +8 -5
  22. package/site/components/Cratis.astro +17 -15
  23. package/site/components/EventModel.astro +8 -5
  24. package/site/components/Footer.astro +9 -4
  25. package/site/components/Hero.astro +13 -10
  26. package/site/components/Inline.astro +45 -0
  27. package/site/components/Monogram.astro +4 -1
  28. package/site/components/Nav.astro +12 -9
  29. package/site/components/Now.astro +13 -10
  30. package/site/components/Projects.astro +11 -14
  31. package/site/components/PullRequestFeed.astro +6 -4
  32. package/site/components/SectionHeader.astro +6 -3
  33. package/site/components/Skills.astro +5 -2
  34. package/site/components/Stats.astro +7 -2
  35. package/site/components/Talks.astro +8 -5
  36. package/site/components/Terminal.astro +7 -2
  37. package/site/components/Writing.astro +9 -7
  38. package/site/data.ts +6 -43
  39. package/site/layouts/Base.astro +31 -19
  40. package/site/lib/blog.ts +42 -16
  41. package/site/lib/github.ts +48 -31
  42. package/site/lib/inline.ts +65 -9
  43. package/site/lib/nav.ts +14 -3
  44. package/site/pages/CvPage.astro +379 -0
  45. package/site/pages/ProfilePage.astro +78 -0
  46. package/site/profile/CastHost.ts +32 -0
  47. package/site/profile/CastView.ts +96 -0
  48. package/site/profile/assertRenderable.ts +151 -0
  49. package/site/profile/clientCast.ts +117 -0
  50. package/site/profile/index.ts +8 -0
  51. package/site/scripts/live.ts +21 -11
  52. package/site/scripts/palette.ts +30 -13
  53. package/site/scripts/replay.ts +2 -1
  54. package/site/scripts/terminal.ts +28 -25
  55. package/writing/fetchWriting.ts +17 -2
  56. package/writing/writingSnapshotSchema.ts +27 -0
@@ -0,0 +1,151 @@
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 { isCalendarDate, isCalendarMonth, isInstant, isPartialDate } from '../../config/calendar';
5
+ import { accentTokens } from '../lib/accent';
6
+ import { alsoText, apply, formatMonth, initialProjection, normalizeDate, yearsBetween } from '../lib/career';
7
+ import { foldCalendar, formatDay, formatMonthLong } from '../lib/contributions';
8
+ import { parseInline, plainText } from '../lib/inline';
9
+ import { safeJson } from '../lib/safeJson';
10
+ import { clientCast } from './clientCast';
11
+ import type { CastView } from './CastView';
12
+
13
+ const webProtocols = new Set(['http:', 'https:']);
14
+
15
+ function fail(what: string, value: unknown): never {
16
+ throw new Error(`${what}: cannot render ${JSON.stringify(value)?.slice(0, 120)}`);
17
+ }
18
+
19
+ function url(what: string, value: string, base?: string, protocols: ReadonlySet<string> = webProtocols): void {
20
+ let parsed: URL;
21
+ try {
22
+ parsed = new URL(value, base);
23
+ } catch {
24
+ fail(what, value);
25
+ }
26
+ if (!protocols.has(parsed.protocol)) fail(what, value);
27
+ }
28
+
29
+ function count(what: string, value: number | undefined): void {
30
+ if (value !== undefined && !(Number.isSafeInteger(value) && value >= 0)) fail(what, value);
31
+ }
32
+
33
+ /** A date the components format: an unreadable one shows as 'Invalid Date', or as a month name that does not exist. */
34
+ function shown(what: string, formatted: string, value: string): void {
35
+ if (formatted.includes('Invalid Date') || formatted.startsWith(' ')) fail(what, value);
36
+ }
37
+
38
+ function inline(what: string, text: string | undefined): void {
39
+ if (text === undefined) return;
40
+ if (typeof text !== 'string') fail(what, text);
41
+ parseInline(text);
42
+ plainText(text);
43
+ }
44
+
45
+ function links(cast: CastView): void {
46
+ const { config, person, github, posts } = cast;
47
+ for (const [key, value] of Object.entries(person.links)) url(`person.links.${key}`, value);
48
+ if (person.email) url('person.email', `mailto:${person.email}`, undefined, new Set(['mailto:']));
49
+ url('site.url', config.site.url);
50
+ if (config.site.source) url('site.source', config.site.source);
51
+ // Base resolves the portrait against the site; the directory resolves relative links in text against the member's site.
52
+ if (cast.photoUrl) url('photo', cast.photoUrl, config.site.url, new Set(['https:']));
53
+ if (cast.host.linkOrigin) url('linkOrigin', cast.host.linkOrigin);
54
+ for (const [key, value] of Object.entries(cast.cratis?.links ?? {})) url(`cratis.links.${key}`, value);
55
+ for (const repo of cast.cratisRepos) if (repo.url) url(`cratis.repos.${repo.name}`, repo.url);
56
+ for (const project of cast.liveProjects) url(`projects.${project.name}`, project.url);
57
+ for (const talk of cast.talks) if (talk.url) url(`talks.${talk.title}`, talk.url);
58
+ for (const certification of cast.certifications) if (certification.url) url(`certifications.${certification.title}`, certification.url);
59
+ for (const item of cast.agentic?.items ?? []) if (item.url) url(`agentic.items.${item.name}`, item.url);
60
+ if (cast.eventModel) url('eventModel.repo', cast.eventModel.repo);
61
+ for (const event of cast.careerEvents) if (event.orgUrl) url(`career.${event.title}.orgUrl`, event.orgUrl);
62
+ for (const pullRequest of github.mergedPullRequests.latest) url(`github.pullRequest.${pullRequest.number}`, pullRequest.url);
63
+ for (const [name, release] of Object.entries(github.releases)) url(`github.releases.${name}`, release.url);
64
+ if (github.profile?.avatarUrl) url('github.avatarUrl', github.profile.avatarUrl);
65
+ for (const source of cast.writing.sources) url('writing.sources', source);
66
+ for (const post of posts) url(`writing.posts.${post.title}`, post.url);
67
+ }
68
+
69
+ function texts(cast: CastView): void {
70
+ const { person, cratis, agentic, eventModel } = cast;
71
+ for (const [what, text] of [['tagline', person.tagline], ['intro', person.intro], ['cratis.pitch', cratis?.pitch], ['cratis.role', cratis?.role], ['agentic.pitch', agentic?.pitch], ['eventModel.intro', eventModel?.intro]] as const) inline(what, text);
72
+ person.about.forEach((text, index) => inline(`about.${index}`, text));
73
+ cast.careerEvents.forEach((event, index) => inline(`career.${index}.summary`, event.summary));
74
+ cast.projects.forEach((project, index) => inline(`projects.${index}.pitch`, project.pitch));
75
+ cast.talks.forEach((talk, index) => inline(`talks.${index}.summary`, talk.summary));
76
+ cast.cratisRepos.forEach((repo, index) => inline(`cratis.repos.${index}.desc`, repo.desc));
77
+ agentic?.workflow.forEach((step, index) => inline(`agentic.workflow.${index}`, step.text));
78
+ agentic?.items.forEach((item, index) => inline(`agentic.items.${index}`, item.text));
79
+ eventModel?.cards.forEach((card, index) => inline(`eventModel.cards.${index}`, card.note));
80
+ eventModel?.steps.forEach((step, index) => inline(`eventModel.steps.${index}`, step.text));
81
+ cast.sections.forEach((section, index) => {
82
+ inline(`sections.${index}.title`, section.title);
83
+ inline(`sections.${index}.intro`, section.intro);
84
+ });
85
+ }
86
+
87
+ /** The career replay and the CV fold every event in order: each position must reduce to dates they can show. */
88
+ function career(cast: CastView): void {
89
+ let state = initialProjection;
90
+ for (const event of cast.careerEvents) {
91
+ if (!isCalendarMonth(event.date)) fail(`career.${event.title}.date`, event.date);
92
+ if (event.endDate !== undefined && !isCalendarMonth(event.endDate)) fail(`career.${event.title}.endDate`, event.endDate);
93
+ state = apply(state, event);
94
+ alsoText(state);
95
+ if (!isCalendarMonth(normalizeDate(event.date))) fail(`career.${event.title}.date`, event.date);
96
+ }
97
+ for (const role of state.roles) [role.from, role.to].forEach(date => date !== undefined && shown(`career.roles.${role.org}`, formatMonth(date), date));
98
+ if (!Number.isFinite(yearsBetween(state.shippingSince, state.clock))) fail('career.years', state.clock);
99
+ }
100
+
101
+ function calendar(cast: CastView): void {
102
+ const { github } = cast;
103
+ for (const week of github.contributions.weeks) {
104
+ if (!isCalendarDate(week.start)) fail('github.contributions.weeks.start', week.start);
105
+ week.counts.forEach(value => count('github.contributions.weeks.counts', value));
106
+ }
107
+ const folded = foldCalendar(github.contributions.weeks);
108
+ for (const day of [folded.first, folded.last, folded.busiest.date].filter(Boolean)) shown('github.contributions', formatDay(day), day);
109
+ for (const { month } of folded.months) shown('github.contributions.months', formatMonthLong(month), month);
110
+ }
111
+
112
+ function dates(cast: CastView): void {
113
+ const { github } = cast;
114
+ for (const [what, value] of [['github.fetchedAt', github.fetchedAt], ['writing.fetchedAt', cast.blogFetchedAt]] as const) {
115
+ if (value !== undefined && !isInstant(value)) fail(what, value);
116
+ }
117
+ shown('snapshotDate', cast.snapshotDate(), String(github.fetchedAt));
118
+ for (const pullRequest of github.mergedPullRequests.latest) if (!isInstant(pullRequest.mergedAt)) fail('github.pullRequest.mergedAt', pullRequest.mergedAt);
119
+ for (const release of Object.values(github.releases)) if (!isInstant(release.publishedAt)) fail('github.releases.publishedAt', release.publishedAt);
120
+ for (const post of cast.posts) if (!isInstant(post.date)) fail('writing.posts.date', post.date);
121
+ for (const entry of [...cast.talks, ...cast.community]) if (!isPartialDate(entry.date)) fail('talks.date', entry.date);
122
+ for (const value of Object.values(cast.now)) if (typeof value === 'string' && /^\d{4}-\d{2}$/.test(value)) shown('now', formatMonth(value), value);
123
+ }
124
+
125
+ function counts(cast: CastView): void {
126
+ const { github } = cast;
127
+ count('github.contributions.total', github.contributions.total);
128
+ count('github.contributions.commits', github.contributions.commits);
129
+ count('github.mergedPullRequests.total', github.mergedPullRequests.total);
130
+ for (const value of Object.values(github.mergedPullRequests.byOrg)) count('github.mergedPullRequests.byOrg', value);
131
+ for (const standing of Object.values(github.repositories)) for (const value of Object.values(standing)) count('github.repositories', value);
132
+ for (const value of Object.values(github.stars)) count('github.stars', value);
133
+ count('mergedPrTotal', cast.mergedPrTotal);
134
+ for (const stat of cast.stats) if (!Number.isFinite(stat.value)) fail(`stats.${stat.label}`, stat.value);
135
+ }
136
+
137
+ /**
138
+ * Computes what the profile components compute from a view (URLs, the contribution calendar, the career
139
+ * projection, dates, inline markup and the browser data), without rendering. Throws on the first value they
140
+ * could not render, so a builder can refuse that data before a page fails the whole build.
141
+ */
142
+ export function assertRenderable(cast: CastView): void {
143
+ links(cast);
144
+ texts(cast);
145
+ career(cast);
146
+ calendar(cast);
147
+ dates(cast);
148
+ counts(cast);
149
+ accentTokens(cast.config.theme.accent);
150
+ safeJson(clientCast(cast));
151
+ }
@@ -0,0 +1,117 @@
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 type { CareerEvent } from '../../config/CastConfig';
5
+ import type { PullRequest } from '../../github/GitHubSnapshot';
6
+ import type { WritingTheme } from '../lib/blog';
7
+ import { anchorFor } from '../lib/sections';
8
+ import type { CastView, ProfileLink } from './CastView';
9
+
10
+ /**
11
+ * What the browser scripts (live feed, replay, palette, terminal) read about the profile on the page. The
12
+ * layout embeds it as a JSON island, so the same scripts serve every profile the directory renders.
13
+ */
14
+ export type ClientCast = {
15
+ /** Path of the profile's home page, ending with '/'. */
16
+ base: string;
17
+ verificationUrl: string;
18
+ /** rel for links from the member's data. */
19
+ rel: string;
20
+ login: string;
21
+ orgs: string[];
22
+ feedOrg: string;
23
+ person: { name: string; handle: string; title: string; location?: string; tagline: string; email?: string };
24
+ firstName: string;
25
+ now: Record<string, string | string[]>;
26
+ careerEvents: CareerEvent[];
27
+ skills: Record<string, string[]>;
28
+ talks: { date: string; title: string; host: string; url?: string }[];
29
+ profileLinks: ProfileLink[];
30
+ cratis?: { pitch?: string; links: Record<string, string>; repos: { name: string; role: string; url?: string; desc: string }[] };
31
+ eventModelName?: string;
32
+ /** DOM ids of the sections that render, in page order. */
33
+ sections: string[];
34
+ projects: { name: string; url: string; pitch: string; version?: string; category: string; features: string[]; stack: string[]; install?: string }[];
35
+ pullRequests: PullRequest[];
36
+ mergedPrTotal: number;
37
+ snapshotDate: string;
38
+ posts: { headline: string; url: string; part?: number; minutes?: number; themes: string[] }[];
39
+ themes: WritingTheme[];
40
+ blogHost: string;
41
+ };
42
+
43
+ /** The client data for one profile view. */
44
+ export function clientCast(cast: CastView): ClientCast {
45
+ const { person } = cast;
46
+ return {
47
+ base: cast.host.base,
48
+ verificationUrl: cast.verificationUrl,
49
+ rel: cast.host.rel,
50
+ login: cast.config.github.login,
51
+ orgs: cast.config.github.orgs,
52
+ feedOrg: cast.feedOrg,
53
+ person: {
54
+ name: person.name,
55
+ handle: person.handle,
56
+ title: person.title,
57
+ tagline: person.tagline,
58
+ ...(person.location ? { location: person.location } : {}),
59
+ ...(person.email ? { email: person.email } : {}),
60
+ },
61
+ firstName: cast.firstName,
62
+ now: cast.now,
63
+ careerEvents: cast.careerEvents,
64
+ skills: cast.skills,
65
+ talks: cast.talks.map(talk => ({ date: talk.date, title: talk.title, host: talk.host, ...(talk.url ? { url: talk.url } : {}) })),
66
+ profileLinks: cast.profileLinks,
67
+ ...(cast.cratis ? {
68
+ cratis: {
69
+ ...(cast.cratis.pitch ? { pitch: cast.cratis.pitch } : {}),
70
+ links: cast.cratis.links,
71
+ repos: cast.cratisRepos.map(repo => ({ name: repo.name, role: repo.role, desc: repo.desc, ...(repo.url ? { url: repo.url } : {}) })),
72
+ },
73
+ } : {}),
74
+ ...(cast.eventModel ? { eventModelName: cast.eventModel.name } : {}),
75
+ sections: cast.sections.map(section => anchorFor[section.id]),
76
+ projects: cast.liveProjects.map(project => ({
77
+ name: project.name,
78
+ url: project.url,
79
+ pitch: project.pitch,
80
+ category: project.category,
81
+ features: project.features,
82
+ stack: project.stack,
83
+ ...(project.version ? { version: project.version } : {}),
84
+ ...(project.install ? { install: project.install } : {}),
85
+ })),
86
+ pullRequests: cast.github.mergedPullRequests.latest,
87
+ mergedPrTotal: cast.mergedPrTotal,
88
+ snapshotDate: cast.snapshotDate(),
89
+ posts: cast.posts.map(post => ({
90
+ headline: post.headline,
91
+ url: post.url,
92
+ themes: post.themes,
93
+ ...(post.part !== undefined ? { part: post.part } : {}),
94
+ ...(post.minutes !== undefined ? { minutes: post.minutes } : {}),
95
+ })),
96
+ themes: cast.themes,
97
+ blogHost: cast.blogHost,
98
+ };
99
+ }
100
+
101
+ let cachedText: string | undefined;
102
+ let cachedValue: ClientCast | undefined;
103
+
104
+ /** Reads the client data of the page currently shown. Read again on every use: the router swaps pages. */
105
+ export function readClientCast(): ClientCast | undefined {
106
+ const text = document.querySelector('script[data-cast-client]')?.textContent ?? undefined;
107
+ if (!text) return undefined;
108
+ if (text !== cachedText) {
109
+ try {
110
+ cachedValue = JSON.parse(text) as ClientCast;
111
+ cachedText = text;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ return cachedValue;
117
+ }
@@ -0,0 +1,8 @@
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
+ export * from './assertRenderable';
5
+ export * from './CastHost';
6
+ export * from './CastView';
7
+ export * from './clientCast';
8
+ export { parseInline, plainText, type InlineToken } from '../lib/inline';
@@ -5,23 +5,28 @@
5
5
  import { onPageLoad } from '../lib/lifecycle';
6
6
  import { relativeTime } from '../lib/reltime';
7
7
  import type { PullRequest } from '../lib/github';
8
- import { config } from '../data';
8
+ import { readClientCast } from '../profile/clientCast';
9
9
 
10
10
  type SearchItem = { repository_url?: string; pull_request?: { merged_at?: string }; title?: unknown; number?: unknown; html_url?: string };
11
11
  type SearchResponse = { total_count?: unknown; incomplete_results?: boolean; items?: unknown };
12
12
  type PublicEvent = { type?: string; repo?: { name?: string }; created_at?: string; payload?: { action?: string; ref_type?: string; pull_request?: { merged?: boolean } } };
13
13
 
14
- const login = encodeURIComponent(config.github.login);
15
- const orgs = config.github.orgs;
16
- const orgPattern = new RegExp(`^https://api\\.github\\.com/repos/(${orgs.map(org => org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/([^/]+)$`, 'i');
14
+ const orgPatternFor = (orgs: string[]) =>
15
+ new RegExp(`^https://api\\.github\\.com/repos/(${orgs.map(org => org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/([^/]+)$`, 'i');
17
16
 
18
17
  const TTL = 5 * 60_000;
19
18
  type Merged = { total: number; latest: PullRequest[] };
20
19
  type Activity = { type: string; repo: string; at: string };
21
- let merged: { at: number; data: Merged } | undefined;
20
+ // Per login: the directory renders many profiles on one origin.
21
+ const merged = new Map<string, { at: number; data: Merged }>();
22
22
  const pending = new Map<string, Promise<unknown>>();
23
23
 
24
- export const getLiveMerged = (): Merged | undefined => merged && Date.now() - merged.at < TTL ? merged.data : undefined;
24
+ /** The live merged pull requests of the profile on the page, while fresh. */
25
+ export const getLiveMerged = (): Merged | undefined => {
26
+ const login = readClientCast()?.login.toLowerCase();
27
+ const entry = login ? merged.get(login) : undefined;
28
+ return entry && Date.now() - entry.at < TTL ? entry.data : undefined;
29
+ };
25
30
 
26
31
  async function cached<T>(key: string, path: string, parse: (data: unknown) => T): Promise<T> {
27
32
  try {
@@ -42,7 +47,7 @@ async function cached<T>(key: string, path: string, parse: (data: unknown) => T)
42
47
  try { return await request; } finally { pending.delete(key); }
43
48
  }
44
49
 
45
- function parseMerged(raw: unknown): Merged {
50
+ function parseMerged(raw: unknown, orgPattern: RegExp): Merged {
46
51
  const data = raw as SearchResponse | null;
47
52
  const total = data?.total_count;
48
53
  if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0 || data?.incomplete_results || !Array.isArray(data?.items)) throw new Error('Invalid search');
@@ -105,18 +110,23 @@ function renderMerged(data: Merged): void {
105
110
  }
106
111
 
107
112
  onPageLoad(() => {
108
- if (!document.querySelector('[data-hero]')) return;
113
+ const cast = readClientCast();
114
+ if (!document.querySelector('[data-hero]') || !cast) return;
115
+ const login = encodeURIComponent(cast.login);
116
+ const key = cast.login.toLowerCase();
117
+ const orgs = cast.orgs;
118
+ const orgPattern = orgPatternFor(orgs);
109
119
  let active = true;
110
120
  let idle: number | undefined;
111
121
  let timer: ReturnType<typeof setTimeout> | undefined;
112
122
  const refresh = () => {
113
123
  if (!active || document.visibilityState !== 'visible') return;
114
- void cached('merged', `/search/issues?q=author:${login}+${orgs.map(org => `org:${encodeURIComponent(org)}`).join('+')}+is:public+is:pr+is:merged&sort=updated&order=desc&per_page=12`, parseMerged)
124
+ void cached(`${key}:merged`, `/search/issues?q=author:${login}+${orgs.map(org => `org:${encodeURIComponent(org)}`).join('+')}+is:public+is:pr+is:merged&sort=updated&order=desc&per_page=12`, (raw) => parseMerged(raw, orgPattern))
115
125
  .then((data) => {
116
- merged = { at: Date.now(), data };
126
+ merged.set(key, { at: Date.now(), data });
117
127
  if (active && document.visibilityState === 'visible') renderMerged(data);
118
128
  }).catch(() => { /* Quietly keep the build-time snapshot. */ });
119
- void cached('events', `/users/${login}/events/public?per_page=30`, parseEvents)
129
+ void cached(`${key}:events`, `/users/${login}/events/public?per_page=30`, parseEvents)
120
130
  .then((events) => {
121
131
  if (!active || document.visibilityState !== 'visible' || !events.length) return;
122
132
  const event = events[0]!;
@@ -2,12 +2,11 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  // ⌘K command palette: fuzzy filtering, full keyboard navigation, focus return.
5
- import { person, sections, profileLinks, cratis, eventModel } from '../data';
6
- import { anchorFor } from '../lib/sections';
5
+ import { readClientCast } from '../profile/clientCast';
7
6
  import { copyText } from '../lib/clipboard';
8
7
  import { motionReduced, toggleMotion } from '../lib/motion';
9
8
  import { currentTheme, toggleTheme } from '../lib/theme';
10
- import { go, goSection, openExternal, startReplay } from '../lib/nav';
9
+ import { go, goSection, openExternal, profilePath, startReplay } from '../lib/nav';
11
10
 
12
11
  type Kind = 'command' | 'event' | 'view';
13
12
  type Item = {
@@ -53,11 +52,24 @@ export function fuzzyScore(query: string, text: string): number {
53
52
  return score;
54
53
  }
55
54
 
55
+ type Controller = { show: () => void; hide: () => void; isOpen: () => boolean };
56
+
57
+ // One controller per dialog: the dialog persists across pages of one profile and is replaced for another.
58
+ const controllers = new WeakMap<HTMLDialogElement, Controller>();
59
+ const current = (): Controller | undefined => {
60
+ const dialog = document.querySelector<HTMLDialogElement>('[data-palette]');
61
+ return dialog ? controllers.get(dialog) : undefined;
62
+ };
63
+ let documentBound = false;
64
+
65
+ /** Mounts the palette of the page shown, once per dialog element. Safe to call on every page load. */
56
66
  export function mountPalette(): void {
57
67
  const dialog = document.querySelector<HTMLDialogElement>('[data-palette]');
58
68
  const input = dialog?.querySelector<HTMLInputElement>('[data-palette-input]');
59
69
  const list = dialog?.querySelector<HTMLUListElement>('[data-palette-list]');
60
- if (!dialog || !input || !list) return;
70
+ const cast = readClientCast();
71
+ if (!dialog || !input || !list || !cast || controllers.has(dialog)) return;
72
+ const { person, sections, profileLinks, cratis } = cast;
61
73
 
62
74
  let toast: HTMLElement | null = null;
63
75
  const notify = (msg: string) => {
@@ -75,7 +87,7 @@ export function mountPalette(): void {
75
87
  const allItems: Item[] = [
76
88
  { id: 'about', group: 'Navigate', label: 'Now & about', keywords: 'read model projection photo', kind: 'view', run: () => goSection('about') },
77
89
  { id: 'career', group: 'Navigate', label: 'Career event stream', keywords: 'experience jobs timeline', kind: 'event', run: () => goSection('career') },
78
- { id: 'model', group: 'Navigate', label: 'How I think: event model', keywords: `${eventModel?.name ?? ''} event modeling board slice`, kind: 'command', run: () => goSection('model') },
90
+ { id: 'model', group: 'Navigate', label: 'How I think: event model', keywords: `${cast.eventModelName ?? ''} event modeling board slice`, kind: 'command', run: () => goSection('model') },
79
91
  { id: 'cratis', group: 'Navigate', label: 'Cratis', keywords: 'open source chronicle arc', kind: 'command', run: () => goSection('cratis') },
80
92
  { id: 'activity', group: 'Navigate', label: 'Contribution event store', keywords: 'github heatmap activity calendar', kind: 'event', run: () => goSection('activity') },
81
93
  { id: 'agentic', group: 'Navigate', label: 'Agentic engineering', keywords: 'ai agents harness pi review workflow', kind: 'view', run: () => goSection('agentic') },
@@ -95,12 +107,12 @@ export function mountPalette(): void {
95
107
  hint: () => '`',
96
108
  run: () => void document.dispatchEvent(new CustomEvent('terminal:open')),
97
109
  },
98
- { id: 'cv', group: 'Actions', label: 'Open CV', keywords: 'resume curriculum vitae', kind: 'view', run: () => go('/cv') },
110
+ { id: 'cv', group: 'Actions', label: 'Open CV', keywords: 'resume curriculum vitae', kind: 'view', run: () => go(profilePath('cv')) },
99
111
  {
100
112
  id: 'print', group: 'Actions', label: 'Print CV / save as PDF', keywords: 'resume pdf download', kind: 'view',
101
113
  run: () => {
102
- if (location.pathname.startsWith('/cv')) window.print();
103
- else void go('/cv?print');
114
+ if (location.pathname.startsWith(profilePath('cv'))) window.print();
115
+ else void go(profilePath('cv?print'));
104
116
  },
105
117
  },
106
118
  ...(person.email ? [<Item>{
@@ -126,9 +138,9 @@ export function mountPalette(): void {
126
138
  ...Object.entries(cratis?.links ?? {}).map(([label, url]): Item => ({
127
139
  id: `cratis-${label}`, group: 'Links' as const, label, keywords: 'cratis website docs repositories', kind: 'event', run: () => open(url),
128
140
  })),
129
- { id: 'cast', group: 'Links' as const, label: 'Cast verification', keywords: 'badge cratis cast tier verified', kind: 'view', run: () => open(`https://cast.cratis.io/${person.handle.toLowerCase()}`) },
141
+ { id: 'cast', group: 'Links' as const, label: 'Cast verification', keywords: 'badge cratis cast tier verified', kind: 'view', run: () => open(cast.verificationUrl) },
130
142
  ];
131
- const items = allItems.filter((item) => item.group !== 'Navigate' || item.id === 'top' || sections.some((section) => anchorFor[section.id] === item.id));
143
+ const items = allItems.filter((item) => item.group !== 'Navigate' || item.id === 'top' || sections.includes(item.id));
132
144
 
133
145
  let results: Item[] = items;
134
146
  let active = 0;
@@ -283,14 +295,19 @@ export function mountPalette(): void {
283
295
  });
284
296
  dialog.querySelector('[data-palette-close]')?.addEventListener('click', hide);
285
297
 
298
+ controllers.set(dialog, { show, hide, isOpen: () => dialog.open });
299
+ if (documentBound) return;
300
+ documentBound = true;
286
301
  document.addEventListener('click', (e) => {
287
- if ((e.target as Element).closest('[data-palette-open]')) show();
302
+ if ((e.target as Element).closest('[data-palette-open]')) current()?.show();
288
303
  });
289
304
  document.addEventListener('keydown', (e) => {
290
305
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
306
+ const palette = current();
307
+ if (!palette) return;
291
308
  e.preventDefault();
292
- if (dialog.open) hide();
293
- else show();
309
+ if (palette.isOpen()) palette.hide();
310
+ else palette.show();
294
311
  }
295
312
  });
296
313
  }
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  // Career replay: renders the projection produced by folding career events up to a position.
5
- import { careerEvents } from '../data';
5
+ import { readClientCast } from '../profile/clientCast';
6
6
  import { project, formatMonth, yearsBetween, alsoText, type CareerProjection } from '../lib/career';
7
7
  import { motionReduced } from '../lib/motion';
8
8
 
@@ -11,6 +11,7 @@ const pad = (n: number) => String(n).padStart(2, '0');
11
11
 
12
12
  export function mountReplay(root: HTMLElement): () => void {
13
13
  const cleanups: (() => void)[] = [];
14
+ const careerEvents = readClientCast()?.careerEvents ?? [];
14
15
  const total = careerEvents.length;
15
16
  const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
16
17
  const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
@@ -3,16 +3,12 @@
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 { go, goSection, openExternal, profilePath, startReplay } from '../lib/nav';
16
12
  import { currentTheme, setTheme } from '../lib/theme';
17
13
 
18
14
  type Part = string | Node;
@@ -38,13 +34,6 @@ const ALL_SECTIONS = [
38
34
  ['skills', 'toolbox'],
39
35
  ['contact', 'contact'],
40
36
  ] 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
37
 
49
38
  // --- tiny DOM helpers --------------------------------------------------------------
50
39
  const span = (cls: string, text: string): HTMLSpanElement => {
@@ -61,12 +50,13 @@ const c = {
61
50
  faint: (t: string) => span('text-fg-faint', t),
62
51
  fg: (t: string) => span('text-fg', t),
63
52
  };
53
+ let linkRel = 'noopener';
64
54
  const link = (href: string, text = href.replace(/^https?:\/\/(www\.)?/, '')): HTMLAnchorElement => {
65
55
  const a = document.createElement('a');
66
- a.href = href;
56
+ a.href = /^(https?:|mailto:)/i.test(href) ? href : '#';
67
57
  a.textContent = text;
68
58
  a.target = '_blank';
69
- a.rel = 'noopener';
59
+ a.rel = linkRel;
70
60
  a.className = 'text-command-soft';
71
61
  return a;
72
62
  };
@@ -94,12 +84,15 @@ class Terminal {
94
84
  private returnFocus: HTMLElement | null = null;
95
85
  private greeted = false;
96
86
  private readonly commands: Record<string, Command>;
87
+ private readonly prompt: string;
97
88
 
98
89
  constructor(
90
+ private readonly cast: ClientCast,
99
91
  private readonly dialog: HTMLDialogElement,
100
92
  private readonly out: HTMLElement,
101
93
  private readonly input: HTMLInputElement,
102
94
  ) {
95
+ this.prompt = `${cast.person.handle}@stream`;
103
96
  try {
104
97
  this.history = JSON.parse(sessionStorage.getItem('cast:term-history') ?? '[]') as string[];
105
98
  } catch {
@@ -122,7 +115,7 @@ class Terminal {
122
115
  this.print(' ');
123
116
  }
124
117
  private echo(raw: string): void {
125
- this.print(c.view(prompt), c.faint(':'), c.cmd('~'), c.faint('$ '), c.fg(raw));
118
+ this.print(c.view(this.prompt), c.faint(':'), c.cmd('~'), c.faint('$ '), c.fg(raw));
126
119
  }
127
120
  clear(): void {
128
121
  this.out.replaceChildren();
@@ -130,14 +123,16 @@ class Terminal {
130
123
 
131
124
  private greet(): void {
132
125
  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.`));
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.`));
135
129
  this.print('Type ', c.fg('help'), ' to see what you can do, or tap a suggestion below.');
136
130
  this.blank();
137
131
  }
138
132
 
139
133
  // --- lifecycle ----------------------------------------------------------------
140
134
  open(): void {
135
+ linkRel = this.cast.rel;
141
136
  if (this.dialog.open) return;
142
137
  this.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
143
138
  if (!this.greeted) this.greet();
@@ -289,6 +284,13 @@ class Terminal {
289
284
 
290
285
  // --- commands -------------------------------------------------------------------
291
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'));
292
294
  const findProject = (q = '') => projects.find((p) => p.name.toLowerCase() === q.toLowerCase());
293
295
  const projectNames = () => projects.map((p) => p.name);
294
296
 
@@ -346,7 +348,7 @@ class Terminal {
346
348
  run: ([to]) => {
347
349
  const id = (to ?? '').replace(/\/$/, '');
348
350
  if (!id || id === '~' || id === '/') return this.navigateSection('top');
349
- if (id === 'cv') return this.navigate('/cv');
351
+ if (id === 'cv') return this.navigate(profilePath('cv'));
350
352
  if (!SECTIONS.some(([s]) => s === id)) return this.print(c.ev('cd: '), `no such section: ${id}`);
351
353
  this.navigateSection(id);
352
354
  },
@@ -400,9 +402,9 @@ class Terminal {
400
402
  run: ([sub, flag, n]) => {
401
403
  if (sub !== 'tail') return this.print(c.ev('stream: '), 'try ', c.fg('stream tail'));
402
404
  const live = getLiveMerged();
403
- const prs = live?.latest ?? github.mergedPullRequests.latest;
405
+ const prs = live?.latest ?? this.cast.pullRequests;
404
406
  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`));
407
+ this.print(c.faint(`tail ${feedOrg} · ${live ? 'live' : `snapshot taken ${snapshotDate}`} · ${(live?.total ?? mergedPrTotal).toLocaleString('en-US')} merged in total`));
406
408
  prs.slice(0, count).forEach((pr, i) => {
407
409
  this.print(
408
410
  c.faint(pad(i === 0 ? 'HEAD' : `HEAD~${i}`, 8)),
@@ -424,7 +426,7 @@ class Terminal {
424
426
  },
425
427
  cv: {
426
428
  desc: 'open the printable CV',
427
- run: () => this.navigate('/cv'),
429
+ run: () => this.navigate(profilePath('cv')),
428
430
  },
429
431
  skills: {
430
432
  desc: 'the toolbox, grouped',
@@ -509,7 +511,7 @@ class Terminal {
509
511
  if (!cratis) return this.print(c.faint('Cratis makes event sourcing practical. '), link('https://cratis.io'));
510
512
  if (cratis.pitch) this.print(c.fg(cratis.pitch.replace(/\*/g, '')));
511
513
  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);
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);
513
515
  },
514
516
  },
515
517
  clear: { desc: 'clear the screen (Ctrl+L)', run: () => this.clear() },
@@ -546,10 +548,11 @@ export function openTerminal(): void {
546
548
  const dialog = document.querySelector<HTMLDialogElement>('[data-terminal]');
547
549
  const out = dialog?.querySelector<HTMLElement>('[data-terminal-out]');
548
550
  const input = dialog?.querySelector<HTMLInputElement>('[data-terminal-input]');
549
- if (!dialog || !out || !input) return;
551
+ const cast = readClientCast();
552
+ if (!dialog || !out || !input || !cast) return;
550
553
  let term = instances.get(dialog);
551
554
  if (!term) {
552
- term = new Terminal(dialog, out, input);
555
+ term = new Terminal(cast, dialog, out, input);
553
556
  instances.set(dialog, term);
554
557
  }
555
558
  term.open();