@cratis/cast 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/badges/RegistryMember.ts +2 -0
- package/badges/badgeSvg.ts +10 -1
- package/badges/registry.ts +5 -0
- package/cast-json.schema.json +1496 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +3 -2
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/machine/castJson.ts +17 -4
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/profileFromCastJson.ts +215 -0
- package/package.json +5 -1
- package/routes/404.astro +6 -4
- package/routes/cast.json.ts +7 -1
- package/routes/cv.astro +3 -365
- package/routes/index.astro +3 -55
- package/site/components/Activity.astro +9 -8
- package/site/components/Agentic.astro +11 -10
- package/site/components/CareerReplay.astro +11 -8
- package/site/components/CommandPalette.astro +10 -4
- package/site/components/Contact.astro +8 -5
- package/site/components/Cratis.astro +18 -16
- package/site/components/EventModel.astro +8 -5
- package/site/components/Footer.astro +9 -4
- package/site/components/Hero.astro +13 -10
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +4 -1
- package/site/components/Nav.astro +12 -9
- package/site/components/Now.astro +13 -10
- package/site/components/Projects.astro +11 -14
- package/site/components/PullRequestFeed.astro +6 -4
- package/site/components/SectionHeader.astro +6 -3
- package/site/components/Skills.astro +5 -2
- package/site/components/Stats.astro +9 -4
- package/site/components/Talks.astro +8 -5
- package/site/components/Terminal.astro +7 -2
- package/site/components/Writing.astro +9 -7
- package/site/data.ts +6 -43
- package/site/layouts/Base.astro +31 -19
- package/site/lib/blog.ts +42 -16
- package/site/lib/github.ts +56 -31
- package/site/lib/inline.ts +65 -9
- package/site/lib/nav.ts +14 -3
- package/site/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +120 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/live.ts +21 -11
- package/site/scripts/palette.ts +30 -13
- package/site/scripts/replay.ts +2 -1
- package/site/scripts/terminal.ts +40 -29
- package/writing/fetchWriting.ts +17 -2
- package/writing/writingSnapshotSchema.ts +27 -0
package/site/lib/nav.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
+
import { readClientCast } from '../profile/clientCast';
|
|
4
5
|
import { motionReduced } from './motion';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/** Path of the profile's home page: '/' on a Cast site, '/<login>/' in the directory. */
|
|
8
|
+
export const homePath = (): string => readClientCast()?.base ?? '/';
|
|
9
|
+
|
|
10
|
+
/** A path under the profile's home page. */
|
|
11
|
+
export const profilePath = (path = ''): string => `${homePath()}${path.replace(/^\//, '')}`;
|
|
12
|
+
|
|
13
|
+
export const onHome = (): boolean => {
|
|
14
|
+
const base = homePath();
|
|
15
|
+
const path = location.pathname;
|
|
16
|
+
return path === base || path === `${base}index.html` || `${path}/` === base;
|
|
17
|
+
};
|
|
7
18
|
|
|
8
19
|
/** Navigates to another page, through the view-transition router when it is active. */
|
|
9
20
|
export async function go(href: string): Promise<void> {
|
|
@@ -25,14 +36,14 @@ export function goSection(id: string): void {
|
|
|
25
36
|
focusTarget.setAttribute('tabindex', '-1');
|
|
26
37
|
focusTarget.focus({ preventScroll: true });
|
|
27
38
|
} else {
|
|
28
|
-
void go(
|
|
39
|
+
void go(profilePath(`#${id}`));
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
/** Scrolls to the career section and starts the replay, from any page. */
|
|
33
44
|
export function startReplay(): void {
|
|
34
45
|
if (onHome() && document.querySelector('[data-replay]')) document.dispatchEvent(new CustomEvent('replay:start'));
|
|
35
|
-
else void go('
|
|
46
|
+
else void go(profilePath('?replay#career'));
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
export const openExternal = (url: string): void => void window.open(url, '_blank', 'noopener');
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
3
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
4
|
+
|
|
5
|
+
// The printable CV of one profile. A Cast site serves it at /cv; the directory at /<login>/cv.
|
|
6
|
+
import Base from '../layouts/Base.astro';
|
|
7
|
+
import Nav from '../components/Nav.astro';
|
|
8
|
+
import Footer from '../components/Footer.astro';
|
|
9
|
+
import CastBadge from '../components/CastBadge.astro';
|
|
10
|
+
import Inline from '../components/Inline.astro';
|
|
11
|
+
import type { CastView } from '../profile/CastView';
|
|
12
|
+
import { project, formatMonth, normalizeDate } from '../lib/career';
|
|
13
|
+
import { plainText } from '../lib/inline';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
cast: CastView;
|
|
17
|
+
}
|
|
18
|
+
const { cast } = Astro.props;
|
|
19
|
+
const { config, person, careerEvents, cratis, skills, education, certifications, talks, firstName, githubUrl, profileLinks, tier, liveProjects: projects, mergedPrFloor, mergedPrTotal, cratisRepos } = cast;
|
|
20
|
+
|
|
21
|
+
const projection = project(careerEvents);
|
|
22
|
+
|
|
23
|
+
// Milestones that happened during a role are listed under it.
|
|
24
|
+
const MILESTONES = new Set(['SystemDeployed', 'LeadershipAssigned']);
|
|
25
|
+
const roles = [...projection.roles].reverse().map((r) => ({
|
|
26
|
+
...r,
|
|
27
|
+
milestones: careerEvents.filter((e) => {
|
|
28
|
+
const d = normalizeDate(e.date);
|
|
29
|
+
// Same organization (an assignment such as 'Azets (via Everdeen)' counts) and within the role's dates.
|
|
30
|
+
return MILESTONES.has(e.type) && !!e.org?.includes(r.org) && d >= normalizeDate(r.from) && (!r.to || d < normalizeDate(r.to));
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const EARLIER = new Set(['VolunteerTeamJoined', 'PartTimeRoleStarted', 'ContractStarted']);
|
|
35
|
+
const earlier = careerEvents.filter((e) => EARLIER.has(e.type)).reverse();
|
|
36
|
+
|
|
37
|
+
const range = (from: string, to?: string) => `${formatMonth(from)} – ${to ? formatMonth(to) : 'present'}`;
|
|
38
|
+
const host = (url: string) => url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
|
|
39
|
+
const siteHost = host(config.site.url);
|
|
40
|
+
const list = (names: string[], joiner = ', ') => names.join(joiner);
|
|
41
|
+
// One factual line for the Cratis role, built only from what the data shows.
|
|
42
|
+
const cratisFacts = (() => {
|
|
43
|
+
if (!cratis) return '';
|
|
44
|
+
const founded = cratis.repos.filter((x) => x.founded).map((x) => x.name);
|
|
45
|
+
const leads = cratisRepos.filter((x) => x.rank === 1 && !x.founded).map((x) => x.name);
|
|
46
|
+
const seconds = cratisRepos.filter((x) => x.rank === 2 && x.group === 'pipeline').map((x) => x.name);
|
|
47
|
+
const parts = [
|
|
48
|
+
founded.length ? `Founded ${list(founded)}` : '',
|
|
49
|
+
leads.length ? `${founded.length ? 'also ' : ''}#1 committer on ${list(leads)}` : '',
|
|
50
|
+
seconds.length ? `#2 all-time on ${list(seconds, ' and ')}` : '',
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
const sentence = parts.length ? `${parts.join('; ')}.` : '';
|
|
53
|
+
const prs = mergedPrTotal > 0 ? ` ${new Intl.NumberFormat('en-US').format(mergedPrFloor)}${mergedPrTotal >= 100 ? '+' : ''} merged pull requests in the Cratis organization.` : '';
|
|
54
|
+
return `${sentence}${prs}`.trim();
|
|
55
|
+
})();
|
|
56
|
+
const talkYear = (date: string) => date.slice(0, 4);
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
<Base
|
|
60
|
+
cast={cast}
|
|
61
|
+
title={`CV · ${person.name}`}
|
|
62
|
+
description={`Curriculum vitae of ${person.fullName ?? person.name}, ${person.title}${person.location ? `, ${person.location}` : ''}.`}
|
|
63
|
+
bodyClass="cv-page"
|
|
64
|
+
>
|
|
65
|
+
<Nav slot="nav" cast={cast} home={false}><slot name="lead" slot="lead" /></Nav>
|
|
66
|
+
|
|
67
|
+
<div class="container-x max-w-5xl pt-[calc(var(--nav-h)+2.5rem)] pb-24 print:p-0">
|
|
68
|
+
<div class="no-print mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
69
|
+
<a href={cast.href()} class="link-quiet font-mono text-xs">← {cast.host.verified ? `${person.name}'s profile` : siteHost}</a>
|
|
70
|
+
<div class="flex flex-wrap gap-2">
|
|
71
|
+
{careerEvents.length > 0 && <a href={cast.href('#career')} class="btn btn-sm">Replay as event stream</a>}
|
|
72
|
+
<button type="button" class="btn btn-sm btn-primary" data-print>
|
|
73
|
+
<svg viewBox="0 0 16 16" class="size-4" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4.5 6V2.5h7V6M4.5 11.5h-2v-5h11v5h-2M4.5 9.5h7v4h-7z" stroke-linejoin="round"></path></svg>
|
|
74
|
+
Print / Save as PDF
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<article class="cv panel p-6 sm:p-10 md:p-14" aria-labelledby="cv-name">
|
|
80
|
+
<header class="cv-head flex flex-col gap-6 border-b border-line pb-8 md:flex-row md:items-end md:justify-between">
|
|
81
|
+
<div>
|
|
82
|
+
<p class="no-print mb-4 flex flex-wrap items-center gap-3"><span class="note">Projection: {firstName}.CV</span><CastBadge tier={tier.tier} handle={person.handle} size="compact" claim={!cast.host.verified} /></p>
|
|
83
|
+
<h1 id="cv-name" transition:name="person-name" class="text-[clamp(2.25rem,5vw,3.5rem)] leading-none font-[800] tracking-[-0.045em]">{person.fullName ?? person.name}</h1>
|
|
84
|
+
<p class="mt-3 text-lg text-fg-muted">{person.title}</p>
|
|
85
|
+
</div>
|
|
86
|
+
<ul class="cv-contact space-y-1 font-mono text-[0.8125rem] text-fg-muted md:text-right">
|
|
87
|
+
{person.location && <li>{person.location}</li>}
|
|
88
|
+
{person.email && <li><a href={`mailto:${person.email}`} class="hover:text-fg" data-no-url>{person.email}</a></li>}
|
|
89
|
+
<li><a href={config.site.url} rel={cast.host.rel} class="hover:text-fg" data-no-url>{siteHost}</a></li>
|
|
90
|
+
<li><a href={githubUrl} rel={cast.host.rel} class="hover:text-fg" data-no-url>{host(githubUrl)}</a></li>
|
|
91
|
+
{profileLinks.filter((link) => link.key !== 'github').slice(0, 2).map((link) => <li><a href={link.url} rel={cast.host.rel} class="hover:text-fg" data-no-url>{host(link.url)}</a></li>)}
|
|
92
|
+
</ul>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
<div class="cv-grid mt-10 grid gap-12 md:grid-cols-[1fr_15rem]">
|
|
96
|
+
<div class="cv-main space-y-12">
|
|
97
|
+
<section aria-labelledby="cv-summary">
|
|
98
|
+
<h2 id="cv-summary" class="cv-h">Summary</h2>
|
|
99
|
+
<div class="space-y-3 leading-relaxed text-fg-muted">
|
|
100
|
+
{(person.about.length ? person.about : [person.intro]).map((p) => <p><Inline text={p} host={cast.host} /></p>)}
|
|
101
|
+
</div>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section aria-labelledby="cv-exp">
|
|
105
|
+
<h2 id="cv-exp" class="cv-h">Experience</h2>
|
|
106
|
+
<ol class="space-y-8">
|
|
107
|
+
{
|
|
108
|
+
roles.map((r) => (
|
|
109
|
+
<li class="cv-item">
|
|
110
|
+
<div class="flex flex-wrap items-baseline justify-between gap-x-4">
|
|
111
|
+
<h3 class="text-lg font-[640] tracking-[-0.01em]">
|
|
112
|
+
{r.title}
|
|
113
|
+
<span class="font-sans font-normal text-fg-muted">
|
|
114
|
+
{' · '}
|
|
115
|
+
{r.orgUrl ? <a href={r.orgUrl} rel={cast.host.rel} data-print-url={host(r.orgUrl)} class="underline decoration-line-strong hover:text-fg">{r.org}</a> : r.org}
|
|
116
|
+
</span>
|
|
117
|
+
</h3>
|
|
118
|
+
<p class="font-mono text-xs whitespace-nowrap text-fg-faint">
|
|
119
|
+
{range(r.from, r.to)}
|
|
120
|
+
{r.location && ` · ${r.location}`}
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
<p class="mt-2 leading-relaxed text-fg-muted">{plainText(r.summary)}</p>
|
|
124
|
+
{r.org === 'Cratis' && cratisFacts && (
|
|
125
|
+
<p class="mt-2 leading-relaxed text-fg-muted">{cratisFacts}</p>
|
|
126
|
+
)}
|
|
127
|
+
{r.milestones.length > 0 && (
|
|
128
|
+
<ul class="mt-2 space-y-1 text-sm text-fg-muted">
|
|
129
|
+
{r.milestones.map((m) => (
|
|
130
|
+
<li class="flex gap-2">
|
|
131
|
+
<span class="font-mono text-xs leading-5 whitespace-nowrap text-event">{formatMonth(m.date)}</span>
|
|
132
|
+
<span><span class="text-fg">{m.title}.</span> {plainText(m.summary)}</span>
|
|
133
|
+
</li>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
{r.skills.length > 0 && <p class="mt-2 font-mono text-[0.75rem] text-fg-faint">{r.skills.join(' · ')}</p>}
|
|
138
|
+
</li>
|
|
139
|
+
))
|
|
140
|
+
}
|
|
141
|
+
</ol>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
{earlier.length > 0 && <section aria-labelledby="cv-earlier">
|
|
145
|
+
<h2 id="cv-earlier" class="cv-h">Earlier</h2>
|
|
146
|
+
<ol class="space-y-5">
|
|
147
|
+
{
|
|
148
|
+
earlier.map((e) => (
|
|
149
|
+
<li class="cv-item">
|
|
150
|
+
<div class="flex flex-wrap items-baseline justify-between gap-x-4">
|
|
151
|
+
<h3 class="font-[620]">
|
|
152
|
+
{e.title}
|
|
153
|
+
<span class="font-sans font-normal text-fg-muted">
|
|
154
|
+
{' · '}
|
|
155
|
+
{e.orgUrl ? <a href={e.orgUrl} rel={cast.host.rel} data-print-url={host(e.orgUrl)} class="underline decoration-line-strong hover:text-fg">{e.org}</a> : e.org}
|
|
156
|
+
</span>
|
|
157
|
+
</h3>
|
|
158
|
+
<p class="font-mono text-xs whitespace-nowrap text-fg-faint">{e.endDate ? range(e.date, e.endDate) : `from ${formatMonth(e.date)}`}</p>
|
|
159
|
+
</div>
|
|
160
|
+
<p class="mt-1.5 text-sm leading-relaxed text-fg-muted">{plainText(e.summary)}</p>
|
|
161
|
+
</li>
|
|
162
|
+
))
|
|
163
|
+
}
|
|
164
|
+
</ol>
|
|
165
|
+
</section>}
|
|
166
|
+
|
|
167
|
+
{projects.length > 0 && <section aria-labelledby="cv-projects">
|
|
168
|
+
<h2 id="cv-projects" class="cv-h">Selected projects</h2>
|
|
169
|
+
<ul class="cv-projects grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
|
170
|
+
{
|
|
171
|
+
projects.map((p) => (
|
|
172
|
+
<li class="cv-item">
|
|
173
|
+
<h3 class="font-[620]">
|
|
174
|
+
<a href={p.url} rel={cast.host.rel} data-print-url={host(p.url)} class="hover:text-event-soft">{p.name}</a>
|
|
175
|
+
{p.version && <span class="ml-1.5 font-mono text-xs font-normal text-fg-faint">{p.version}</span>}
|
|
176
|
+
</h3>
|
|
177
|
+
<p class="mt-0.5 text-sm leading-relaxed text-fg-muted">{plainText(p.pitch)}</p>
|
|
178
|
+
<p class="mt-0.5 font-mono text-[0.75rem] text-fg-faint">{p.stack.join(' · ')}</p>
|
|
179
|
+
</li>
|
|
180
|
+
))
|
|
181
|
+
}
|
|
182
|
+
</ul>
|
|
183
|
+
</section>}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<aside class="cv-side space-y-10 text-sm">
|
|
187
|
+
{Object.keys(skills).length > 0 && <section aria-labelledby="cv-skills">
|
|
188
|
+
<h2 id="cv-skills" class="cv-h">Skills</h2>
|
|
189
|
+
<dl class="space-y-4">
|
|
190
|
+
{
|
|
191
|
+
Object.entries(skills).map(([group, list]) => (
|
|
192
|
+
<div>
|
|
193
|
+
<dt class="mb-1 font-medium text-fg">{group}</dt>
|
|
194
|
+
<dd class="leading-relaxed text-fg-muted">{list.join(', ')}</dd>
|
|
195
|
+
</div>
|
|
196
|
+
))
|
|
197
|
+
}
|
|
198
|
+
</dl>
|
|
199
|
+
</section>}
|
|
200
|
+
|
|
201
|
+
{education.length > 0 && <section aria-labelledby="cv-edu">
|
|
202
|
+
<h2 id="cv-edu" class="cv-h">Education</h2>
|
|
203
|
+
{
|
|
204
|
+
education.map((e) => (
|
|
205
|
+
<div>
|
|
206
|
+
<p class="font-medium text-fg">{e.degree}</p>
|
|
207
|
+
<p class="text-fg-muted">{e.school}</p>
|
|
208
|
+
<p class="font-mono text-xs text-fg-faint">{e.from}{e.to ? ` – ${e.to}` : ''}</p>
|
|
209
|
+
</div>
|
|
210
|
+
))
|
|
211
|
+
}
|
|
212
|
+
</section>}
|
|
213
|
+
|
|
214
|
+
{certifications.length > 0 && <section aria-labelledby="cv-certs">
|
|
215
|
+
<h2 id="cv-certs" class="cv-h">Certifications</h2>
|
|
216
|
+
<ul class="space-y-3">
|
|
217
|
+
{
|
|
218
|
+
certifications.map((c) => (
|
|
219
|
+
<li>
|
|
220
|
+
{c.url ? <a href={c.url} rel={cast.host.rel} class="font-medium text-fg hover:text-event-soft" data-no-url>{c.title}</a> : <span class="font-medium text-fg">{c.title}</span>}
|
|
221
|
+
<p class="font-mono text-xs text-fg-faint">{c.issuer} · {c.year}</p>
|
|
222
|
+
</li>
|
|
223
|
+
))
|
|
224
|
+
}
|
|
225
|
+
</ul>
|
|
226
|
+
</section>}
|
|
227
|
+
|
|
228
|
+
{talks.length > 0 && <section aria-labelledby="cv-talks">
|
|
229
|
+
<h2 id="cv-talks" class="cv-h">Talks</h2>
|
|
230
|
+
{
|
|
231
|
+
talks.map((t) => (
|
|
232
|
+
<div>
|
|
233
|
+
{t.url ? <a href={t.url} rel={cast.host.rel} class="font-medium text-fg hover:text-event-soft" data-no-url>{t.title}</a> : <span class="font-medium text-fg">{t.title}</span>}
|
|
234
|
+
<p class="text-fg-muted">{t.host}{t.with && `, with ${t.with}`}</p>
|
|
235
|
+
<p class="font-mono text-xs text-fg-faint">{talkYear(t.date)}</p>
|
|
236
|
+
</div>
|
|
237
|
+
))
|
|
238
|
+
}
|
|
239
|
+
</section>}
|
|
240
|
+
|
|
241
|
+
{person.languages.length > 0 && <section aria-labelledby="cv-lang">
|
|
242
|
+
<h2 id="cv-lang" class="cv-h">Languages</h2>
|
|
243
|
+
<ul class="space-y-1 text-fg-muted">
|
|
244
|
+
{person.languages.map((l) => <li>{l}</li>)}
|
|
245
|
+
</ul>
|
|
246
|
+
</section>}
|
|
247
|
+
</aside>
|
|
248
|
+
</div>
|
|
249
|
+
</article>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<slot name="footer" slot="footer"><Footer cast={cast} /></slot>
|
|
253
|
+
</Base>
|
|
254
|
+
|
|
255
|
+
<style>
|
|
256
|
+
.cv {
|
|
257
|
+
backdrop-filter: none;
|
|
258
|
+
-webkit-backdrop-filter: none;
|
|
259
|
+
}
|
|
260
|
+
.cv-h {
|
|
261
|
+
margin-bottom: 1.25rem;
|
|
262
|
+
font-family: var(--font-mono);
|
|
263
|
+
font-size: 0.75rem;
|
|
264
|
+
font-weight: 500;
|
|
265
|
+
letter-spacing: 0.1em;
|
|
266
|
+
text-transform: uppercase;
|
|
267
|
+
color: var(--color-event);
|
|
268
|
+
}
|
|
269
|
+
.cv h3 {
|
|
270
|
+
font-family: var(--font-display);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@media print {
|
|
274
|
+
@page {
|
|
275
|
+
size: A4;
|
|
276
|
+
margin: 13mm 14mm;
|
|
277
|
+
}
|
|
278
|
+
:global(.grain::after),
|
|
279
|
+
:global(header[data-nav]),
|
|
280
|
+
:global(footer),
|
|
281
|
+
:global(dialog),
|
|
282
|
+
:global(.sr-only-focusable),
|
|
283
|
+
.no-print {
|
|
284
|
+
display: none !important;
|
|
285
|
+
}
|
|
286
|
+
.cv {
|
|
287
|
+
padding: 0 !important;
|
|
288
|
+
border: 0 !important;
|
|
289
|
+
background: none !important;
|
|
290
|
+
box-shadow: none !important;
|
|
291
|
+
backdrop-filter: none !important;
|
|
292
|
+
color: #111;
|
|
293
|
+
}
|
|
294
|
+
.cv :global(*) {
|
|
295
|
+
color: #111 !important;
|
|
296
|
+
border-color: #d4d4d4 !important;
|
|
297
|
+
}
|
|
298
|
+
.cv :global(.text-fg-muted),
|
|
299
|
+
.cv :global(.text-fg-faint) {
|
|
300
|
+
color: #444 !important;
|
|
301
|
+
}
|
|
302
|
+
.cv-h {
|
|
303
|
+
color: #b4520d !important;
|
|
304
|
+
margin-bottom: 0.6rem;
|
|
305
|
+
font-size: 7.5pt;
|
|
306
|
+
}
|
|
307
|
+
.cv-head {
|
|
308
|
+
padding-bottom: 1rem;
|
|
309
|
+
}
|
|
310
|
+
.cv-head h1 {
|
|
311
|
+
font-size: 24pt;
|
|
312
|
+
}
|
|
313
|
+
.cv-grid {
|
|
314
|
+
display: grid !important;
|
|
315
|
+
grid-template-columns: 1fr 13.5rem !important;
|
|
316
|
+
gap: 1.75rem !important;
|
|
317
|
+
margin-top: 1.25rem !important;
|
|
318
|
+
}
|
|
319
|
+
.cv-main {
|
|
320
|
+
row-gap: 0;
|
|
321
|
+
}
|
|
322
|
+
.cv-main > :global(* + *) {
|
|
323
|
+
margin-top: 1.4rem !important;
|
|
324
|
+
}
|
|
325
|
+
.cv-main :global(ol.space-y-8 > * + *) {
|
|
326
|
+
margin-top: 0.9rem !important;
|
|
327
|
+
}
|
|
328
|
+
.cv-side > :global(* + *) {
|
|
329
|
+
margin-top: 1.25rem !important;
|
|
330
|
+
}
|
|
331
|
+
.cv-item,
|
|
332
|
+
.cv-side > section {
|
|
333
|
+
break-inside: avoid;
|
|
334
|
+
}
|
|
335
|
+
.cv-h {
|
|
336
|
+
break-after: avoid;
|
|
337
|
+
}
|
|
338
|
+
.cv :global(a) {
|
|
339
|
+
text-decoration: none !important;
|
|
340
|
+
}
|
|
341
|
+
.cv-main :global(a[data-print-url])::after {
|
|
342
|
+
content: ' ' attr(data-print-url);
|
|
343
|
+
font-family: var(--font-mono);
|
|
344
|
+
font-size: 7pt;
|
|
345
|
+
color: #555;
|
|
346
|
+
word-break: break-all;
|
|
347
|
+
}
|
|
348
|
+
.cv :global(.text-lg) {
|
|
349
|
+
font-size: 10pt;
|
|
350
|
+
}
|
|
351
|
+
.cv :global(.leading-relaxed) {
|
|
352
|
+
line-height: 1.42;
|
|
353
|
+
}
|
|
354
|
+
.cv-projects :global(a[data-print-url])::after {
|
|
355
|
+
display: block;
|
|
356
|
+
word-break: normal;
|
|
357
|
+
}
|
|
358
|
+
.cv-projects {
|
|
359
|
+
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
|
360
|
+
column-gap: 1rem !important;
|
|
361
|
+
row-gap: 0.6rem !important;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
</style>
|
|
365
|
+
|
|
366
|
+
<script>
|
|
367
|
+
import { onPageLoad } from '../lib/lifecycle';
|
|
368
|
+
onPageLoad(() => {
|
|
369
|
+
if (!document.querySelector('.cv-page')) return;
|
|
370
|
+
document.querySelector('[data-print]')?.addEventListener('click', () => window.print());
|
|
371
|
+
const url = new URL(location.href);
|
|
372
|
+
if (url.searchParams.has('print')) {
|
|
373
|
+
history.replaceState(history.state, '', url.pathname);
|
|
374
|
+
const print = () => window.setTimeout(() => window.print(), 400);
|
|
375
|
+
if (document.readyState === 'complete') print();
|
|
376
|
+
else window.addEventListener('load', print, { once: true });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
</script>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
3
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
4
|
+
|
|
5
|
+
// The home page of one profile. A Cast site serves it at /; the directory at /<login>/.
|
|
6
|
+
import Base from '../layouts/Base.astro';
|
|
7
|
+
import Nav from '../components/Nav.astro';
|
|
8
|
+
import Hero from '../components/Hero.astro';
|
|
9
|
+
import Now from '../components/Now.astro';
|
|
10
|
+
import Stats from '../components/Stats.astro';
|
|
11
|
+
import CareerReplay from '../components/CareerReplay.astro';
|
|
12
|
+
import EventModel from '../components/EventModel.astro';
|
|
13
|
+
import Cratis from '../components/Cratis.astro';
|
|
14
|
+
import Activity from '../components/Activity.astro';
|
|
15
|
+
import Projects from '../components/Projects.astro';
|
|
16
|
+
import Agentic from '../components/Agentic.astro';
|
|
17
|
+
import Writing from '../components/Writing.astro';
|
|
18
|
+
import Talks from '../components/Talks.astro';
|
|
19
|
+
import Skills from '../components/Skills.astro';
|
|
20
|
+
import Contact from '../components/Contact.astro';
|
|
21
|
+
import Footer from '../components/Footer.astro';
|
|
22
|
+
import type { CastView } from '../profile/CastView';
|
|
23
|
+
import { plainText } from '../lib/inline';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
cast: CastView;
|
|
27
|
+
/** Page title; defaults to "<name> · <title>". */
|
|
28
|
+
title?: string;
|
|
29
|
+
/** Meta description; defaults to the site description, else the tagline and intro. */
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
const { cast } = Astro.props;
|
|
33
|
+
const { config, person, sections } = cast;
|
|
34
|
+
|
|
35
|
+
const components = {
|
|
36
|
+
hero: Hero,
|
|
37
|
+
now: Now,
|
|
38
|
+
stats: Stats,
|
|
39
|
+
career: CareerReplay,
|
|
40
|
+
model: EventModel,
|
|
41
|
+
cratis: Cratis,
|
|
42
|
+
activity: Activity,
|
|
43
|
+
agentic: Agentic,
|
|
44
|
+
projects: Projects,
|
|
45
|
+
writing: Writing,
|
|
46
|
+
talks: Talks,
|
|
47
|
+
skills: Skills,
|
|
48
|
+
contact: Contact,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const title = Astro.props.title ?? `${person.name} · ${person.title}`;
|
|
52
|
+
const description = Astro.props.description ?? config.site.description ?? `${plainText(person.tagline)} ${plainText(person.intro)}`;
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<Base cast={cast} title={title} description={description}>
|
|
56
|
+
<Nav slot="nav" cast={cast}><slot name="lead" slot="lead" /></Nav>
|
|
57
|
+
{!sections.some((section) => section.id === 'hero') && <slot name="after-hero" />}
|
|
58
|
+
{
|
|
59
|
+
sections.map((section, position) => {
|
|
60
|
+
const Section = components[section.id];
|
|
61
|
+
const previous = sections[position - 1];
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<Section
|
|
65
|
+
cast={cast}
|
|
66
|
+
index={section.index}
|
|
67
|
+
{...(section.title ? { title: section.title } : {})}
|
|
68
|
+
{...(section.label ? { label: section.label } : {})}
|
|
69
|
+
{...(section.intro ? { intro: section.intro } : {})}
|
|
70
|
+
compact={section.id === 'skills' && previous?.id === 'talks'}
|
|
71
|
+
/>
|
|
72
|
+
{section.id === 'hero' && <slot name="after-hero" />}
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
<slot name="footer" slot="footer"><Footer cast={cast} /></slot>
|
|
78
|
+
</Base>
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
/** Where a profile is rendered: on the member's own Cast site, or by the Cast directory. */
|
|
5
|
+
export type CastHost = {
|
|
6
|
+
/** Path of the profile's home page, ending with '/': '/' on a Cast site, '/<login>/' in the directory. */
|
|
7
|
+
base: string;
|
|
8
|
+
|
|
9
|
+
/** True when the directory renders the profile. The tier is then verified there, not claimed by the site. */
|
|
10
|
+
verified: boolean;
|
|
11
|
+
|
|
12
|
+
/** The rel attribute for links that come from the member's data. */
|
|
13
|
+
rel: string;
|
|
14
|
+
|
|
15
|
+
/** True when the site's own files (og.png, cast.json, llms.txt, favicon, sitemap) are served next to the pages. */
|
|
16
|
+
siteFiles: boolean;
|
|
17
|
+
|
|
18
|
+
/** Origin that relative links in inline text ('/posts/x') resolve against. Kept relative when undefined. */
|
|
19
|
+
linkOrigin?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A member's own Cast site. */
|
|
23
|
+
export const siteHost: CastHost = { base: '/', verified: false, rel: 'noopener', siteFiles: true };
|
|
24
|
+
|
|
25
|
+
/** A profile rendered by the Cast directory at `/<login>/`: links from member data are user content. */
|
|
26
|
+
export const directoryHost = (login: string, site?: string): CastHost => ({
|
|
27
|
+
base: `/${login.toLowerCase()}/`,
|
|
28
|
+
verified: true,
|
|
29
|
+
rel: 'noopener nofollow ugc',
|
|
30
|
+
siteFiles: false,
|
|
31
|
+
...(site ? { linkOrigin: site } : {}),
|
|
32
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { ImageMetadata } from 'astro';
|
|
5
|
+
import { verificationUrl } from '../../badges/constants';
|
|
6
|
+
import type { CastData } from '../../integration/CastData';
|
|
7
|
+
import { writingView, type WritingView } from '../lib/blog';
|
|
8
|
+
import { gitHubView, type GitHubView } from '../lib/github';
|
|
9
|
+
import type { CastHost } from './CastHost';
|
|
10
|
+
|
|
11
|
+
/** Everything one profile renders from: the resolved data, plus the imported local portrait on a Cast site. */
|
|
12
|
+
export type CastProfile = CastData & { photo?: ImageMetadata };
|
|
13
|
+
|
|
14
|
+
/** A profile link with the label it reads as. */
|
|
15
|
+
export type ProfileLink = { key: string; label: string; url: string };
|
|
16
|
+
|
|
17
|
+
/** Known link names and how they read. Anything else is shown with its key. */
|
|
18
|
+
const linkLabels: Record<string, string> = {
|
|
19
|
+
github: 'GitHub',
|
|
20
|
+
linkedin: 'LinkedIn',
|
|
21
|
+
mastodon: 'Mastodon',
|
|
22
|
+
bluesky: 'Bluesky',
|
|
23
|
+
x: 'X',
|
|
24
|
+
twitter: 'X',
|
|
25
|
+
youtube: 'YouTube',
|
|
26
|
+
website: 'Website',
|
|
27
|
+
blog: 'Blog',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One profile, ready to render: the data, the shortcuts the components read and where it is rendered. Every
|
|
32
|
+
* page and component of a Cast profile takes this as its `cast` prop, so the same components render a Cast
|
|
33
|
+
* site and the directory's member pages.
|
|
34
|
+
*/
|
|
35
|
+
export type CastView = CastProfile & GitHubView & WritingView & {
|
|
36
|
+
host: CastHost;
|
|
37
|
+
person: CastData['config']['person'];
|
|
38
|
+
now: CastData['config']['now'];
|
|
39
|
+
careerEvents: CastData['config']['career']['events'];
|
|
40
|
+
cratis: CastData['config']['cratis'];
|
|
41
|
+
projects: CastData['config']['projects'];
|
|
42
|
+
talks: CastData['config']['talks'];
|
|
43
|
+
community: CastData['config']['community'];
|
|
44
|
+
education: CastData['config']['education'];
|
|
45
|
+
certifications: CastData['config']['certifications'];
|
|
46
|
+
skills: CastData['config']['skills'];
|
|
47
|
+
agentic: CastData['config']['agentic'];
|
|
48
|
+
eventModel: CastData['config']['eventModel'];
|
|
49
|
+
stats: CastData['config']['stats'];
|
|
50
|
+
/** First word of the display name: projections are named `<FirstName>.Now`, `<FirstName>.Career`. */
|
|
51
|
+
firstName: string;
|
|
52
|
+
/** Profile links with labels, GitHub first. */
|
|
53
|
+
profileLinks: ProfileLink[];
|
|
54
|
+
/** The GitHub profile URL: from links, else from the login. */
|
|
55
|
+
githubUrl: string;
|
|
56
|
+
/** The organization whose merged pull requests feed the live feed. */
|
|
57
|
+
feedOrg: string;
|
|
58
|
+
/** The directory page that verifies this profile's tier. */
|
|
59
|
+
verificationUrl: string;
|
|
60
|
+
/** A path under the profile: `href('cv')` is `/cv` on a site and `/<login>/cv` in the directory. */
|
|
61
|
+
href: (path?: string) => string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Builds the view of one profile for one host. */
|
|
65
|
+
export function castView(profile: CastProfile, host: CastHost): CastView {
|
|
66
|
+
const { config } = profile;
|
|
67
|
+
const person = config.person;
|
|
68
|
+
const profileLinks = Object.entries(person.links)
|
|
69
|
+
.map(([key, url]) => ({ key, label: linkLabels[key.toLowerCase()] ?? key, url }))
|
|
70
|
+
.sort((left, right) => Number(right.key === 'github') - Number(left.key === 'github'));
|
|
71
|
+
return {
|
|
72
|
+
...profile,
|
|
73
|
+
...gitHubView(config, profile.github),
|
|
74
|
+
...writingView(config, profile.writing),
|
|
75
|
+
host,
|
|
76
|
+
person,
|
|
77
|
+
now: config.now,
|
|
78
|
+
careerEvents: config.career.events,
|
|
79
|
+
cratis: config.cratis,
|
|
80
|
+
projects: config.projects,
|
|
81
|
+
talks: config.talks,
|
|
82
|
+
community: config.community,
|
|
83
|
+
education: config.education,
|
|
84
|
+
certifications: config.certifications,
|
|
85
|
+
skills: config.skills,
|
|
86
|
+
agentic: config.agentic,
|
|
87
|
+
eventModel: config.eventModel,
|
|
88
|
+
stats: config.stats,
|
|
89
|
+
firstName: person.name.split(/\s+/)[0] ?? person.handle,
|
|
90
|
+
profileLinks,
|
|
91
|
+
githubUrl: person.links['github'] ?? `https://github.com/${config.github.login}`,
|
|
92
|
+
feedOrg: config.github.orgs[0] ?? 'Cratis',
|
|
93
|
+
verificationUrl: verificationUrl(config.github.login),
|
|
94
|
+
href: (path = '') => `${host.base}${path.replace(/^\//, '')}`,
|
|
95
|
+
};
|
|
96
|
+
}
|