@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
|
@@ -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,120 @@
|
|
|
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
|
+
/** The total is the cast.yaml fallback, not a capture. */
|
|
38
|
+
mergedPrSelfReported: boolean;
|
|
39
|
+
snapshotDate: string;
|
|
40
|
+
posts: { headline: string; url: string; part?: number; minutes?: number; themes: string[] }[];
|
|
41
|
+
themes: WritingTheme[];
|
|
42
|
+
blogHost: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The client data for one profile view. */
|
|
46
|
+
export function clientCast(cast: CastView): ClientCast {
|
|
47
|
+
const { person } = cast;
|
|
48
|
+
return {
|
|
49
|
+
base: cast.host.base,
|
|
50
|
+
verificationUrl: cast.verificationUrl,
|
|
51
|
+
rel: cast.host.rel,
|
|
52
|
+
login: cast.config.github.login,
|
|
53
|
+
orgs: cast.config.github.orgs,
|
|
54
|
+
feedOrg: cast.feedOrg,
|
|
55
|
+
person: {
|
|
56
|
+
name: person.name,
|
|
57
|
+
handle: person.handle,
|
|
58
|
+
title: person.title,
|
|
59
|
+
tagline: person.tagline,
|
|
60
|
+
...(person.location ? { location: person.location } : {}),
|
|
61
|
+
...(person.email ? { email: person.email } : {}),
|
|
62
|
+
},
|
|
63
|
+
firstName: cast.firstName,
|
|
64
|
+
now: cast.now,
|
|
65
|
+
careerEvents: cast.careerEvents,
|
|
66
|
+
skills: cast.skills,
|
|
67
|
+
talks: cast.talks.map(talk => ({ date: talk.date, title: talk.title, host: talk.host, ...(talk.url ? { url: talk.url } : {}) })),
|
|
68
|
+
profileLinks: cast.profileLinks,
|
|
69
|
+
...(cast.cratis ? {
|
|
70
|
+
cratis: {
|
|
71
|
+
...(cast.cratis.pitch ? { pitch: cast.cratis.pitch } : {}),
|
|
72
|
+
links: cast.cratis.links,
|
|
73
|
+
repos: cast.cratisRepos.map(repo => ({ name: repo.name, role: repo.role, desc: repo.desc, ...(repo.url ? { url: repo.url } : {}) })),
|
|
74
|
+
},
|
|
75
|
+
} : {}),
|
|
76
|
+
...(cast.eventModel ? { eventModelName: cast.eventModel.name } : {}),
|
|
77
|
+
sections: cast.sections.map(section => anchorFor[section.id]),
|
|
78
|
+
projects: cast.liveProjects.map(project => ({
|
|
79
|
+
name: project.name,
|
|
80
|
+
url: project.url,
|
|
81
|
+
pitch: project.pitch,
|
|
82
|
+
category: project.category,
|
|
83
|
+
features: project.features,
|
|
84
|
+
stack: project.stack,
|
|
85
|
+
...(project.version ? { version: project.version } : {}),
|
|
86
|
+
...(project.install ? { install: project.install } : {}),
|
|
87
|
+
})),
|
|
88
|
+
pullRequests: cast.github.mergedPullRequests.latest,
|
|
89
|
+
mergedPrTotal: cast.mergedPrTotal,
|
|
90
|
+
mergedPrSelfReported: cast.mergedPrSelfReported,
|
|
91
|
+
snapshotDate: cast.snapshotDate(),
|
|
92
|
+
posts: cast.posts.map(post => ({
|
|
93
|
+
headline: post.headline,
|
|
94
|
+
url: post.url,
|
|
95
|
+
themes: post.themes,
|
|
96
|
+
...(post.part !== undefined ? { part: post.part } : {}),
|
|
97
|
+
...(post.minutes !== undefined ? { minutes: post.minutes } : {}),
|
|
98
|
+
})),
|
|
99
|
+
themes: cast.themes,
|
|
100
|
+
blogHost: cast.blogHost,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let cachedText: string | undefined;
|
|
105
|
+
let cachedValue: ClientCast | undefined;
|
|
106
|
+
|
|
107
|
+
/** Reads the client data of the page currently shown. Read again on every use: the router swaps pages. */
|
|
108
|
+
export function readClientCast(): ClientCast | undefined {
|
|
109
|
+
const text = document.querySelector('script[data-cast-client]')?.textContent ?? undefined;
|
|
110
|
+
if (!text) return undefined;
|
|
111
|
+
if (text !== cachedText) {
|
|
112
|
+
try {
|
|
113
|
+
cachedValue = JSON.parse(text) as ClientCast;
|
|
114
|
+
cachedText = text;
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return cachedValue;
|
|
120
|
+
}
|
|
@@ -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';
|
package/site/scripts/live.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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]!;
|
package/site/scripts/palette.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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: `${
|
|
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('
|
|
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('
|
|
103
|
-
else void go('
|
|
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(
|
|
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.
|
|
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 (
|
|
293
|
-
else show();
|
|
309
|
+
if (palette.isOpen()) palette.hide();
|
|
310
|
+
else palette.show();
|
|
294
311
|
}
|
|
295
312
|
});
|
|
296
313
|
}
|
package/site/scripts/replay.ts
CHANGED
|
@@ -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 {
|
|
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)];
|