@cratis/cast 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +13 -1
  2. package/badges/RegistryMember.ts +18 -0
  3. package/badges/ResolvedTier.ts +17 -0
  4. package/badges/Tier.ts +14 -0
  5. package/badges/TierSource.ts +14 -0
  6. package/badges/badgeSvg.ts +137 -0
  7. package/badges/constants.ts +17 -0
  8. package/badges/cratisMark.ts +15 -0
  9. package/badges/index.ts +12 -0
  10. package/badges/registry.ts +68 -0
  11. package/badges/resolveTier.ts +24 -0
  12. package/cast-json.schema.json +1496 -0
  13. package/cast.schema.json +1113 -0
  14. package/config/CastConfig.ts +17 -0
  15. package/config/CastConfigError.ts +10 -0
  16. package/config/LiveStat.ts +17 -0
  17. package/config/ResolvedSection.ts +13 -0
  18. package/config/SectionId.ts +21 -0
  19. package/config/ThemeMode.ts +9 -0
  20. package/config/calendar.ts +34 -0
  21. package/config/castSchema.ts +263 -0
  22. package/config/index.ts +12 -0
  23. package/config/parseCast.ts +58 -0
  24. package/config/resolveSections.ts +48 -0
  25. package/github/GitHubSnapshot.ts +66 -0
  26. package/github/fetchGitHub.ts +235 -0
  27. package/github/gitHubSnapshotSchema.ts +58 -0
  28. package/github/parse.ts +63 -0
  29. package/index.ts +88 -0
  30. package/integration/CastData.ts +20 -0
  31. package/integration/CastOptions.ts +17 -0
  32. package/integration/Snapshot.ts +12 -0
  33. package/integration/loadRegistry.ts +37 -0
  34. package/integration/resolveData.ts +138 -0
  35. package/integration/snapshotFile.ts +23 -0
  36. package/integration/virtualModule.ts +43 -0
  37. package/machine/castJson.ts +77 -0
  38. package/machine/castJsonSchema.ts +35 -0
  39. package/machine/index.ts +6 -0
  40. package/machine/llmsText.ts +69 -0
  41. package/machine/ogImageSvg.ts +79 -0
  42. package/machine/profileFromCastJson.ts +214 -0
  43. package/machine/resumeJson.ts +87 -0
  44. package/package.json +80 -8
  45. package/registry/members.yaml +16 -0
  46. package/routes/404.astro +42 -0
  47. package/routes/apple-touch-icon.png.ts +17 -0
  48. package/routes/badge-card.svg.ts +13 -0
  49. package/routes/badge.svg.ts +11 -0
  50. package/routes/cast.json.ts +17 -0
  51. package/routes/cv.astro +9 -0
  52. package/routes/favicon.svg.ts +16 -0
  53. package/routes/index.astro +9 -0
  54. package/routes/llms.txt.ts +10 -0
  55. package/routes/og.png.ts +23 -0
  56. package/routes/resume.json.ts +13 -0
  57. package/routes/robots.txt.ts +10 -0
  58. package/site/components/Activity.astro +196 -0
  59. package/site/components/Agentic.astro +144 -0
  60. package/site/components/CareerReplay.astro +415 -0
  61. package/site/components/CastBadge.astro +132 -0
  62. package/site/components/CommandPalette.astro +122 -0
  63. package/site/components/Contact.astro +166 -0
  64. package/site/components/Cratis.astro +235 -0
  65. package/site/components/CratisMark.astro +18 -0
  66. package/site/components/EventModel.astro +265 -0
  67. package/site/components/Footer.astro +33 -0
  68. package/site/components/Hero.astro +211 -0
  69. package/site/components/Inline.astro +45 -0
  70. package/site/components/Monogram.astro +28 -0
  71. package/site/components/Nav.astro +127 -0
  72. package/site/components/Now.astro +116 -0
  73. package/site/components/Projects.astro +185 -0
  74. package/site/components/PullRequestFeed.astro +88 -0
  75. package/site/components/SectionHeader.astro +30 -0
  76. package/site/components/Skills.astro +44 -0
  77. package/site/components/Stats.astro +106 -0
  78. package/site/components/Talks.astro +76 -0
  79. package/site/components/Terminal.astro +164 -0
  80. package/site/components/Writing.astro +128 -0
  81. package/site/data.ts +11 -0
  82. package/site/layouts/Base.astro +156 -0
  83. package/site/lib/accent.ts +52 -0
  84. package/site/lib/blog.ts +58 -0
  85. package/site/lib/career.ts +148 -0
  86. package/site/lib/clipboard.ts +12 -0
  87. package/site/lib/contributions.ts +63 -0
  88. package/site/lib/github.ts +72 -0
  89. package/site/lib/inline.ts +79 -0
  90. package/site/lib/lifecycle.ts +44 -0
  91. package/site/lib/motion.ts +43 -0
  92. package/site/lib/nav.ts +49 -0
  93. package/site/lib/reltime.ts +33 -0
  94. package/site/lib/safeJson.ts +15 -0
  95. package/site/lib/sections.ts +41 -0
  96. package/site/lib/theme.ts +28 -0
  97. package/site/pages/CvPage.astro +379 -0
  98. package/site/pages/ProfilePage.astro +78 -0
  99. package/site/profile/CastHost.ts +32 -0
  100. package/site/profile/CastView.ts +96 -0
  101. package/site/profile/assertRenderable.ts +151 -0
  102. package/site/profile/clientCast.ts +117 -0
  103. package/site/profile/index.ts +8 -0
  104. package/site/scripts/ambient.ts +93 -0
  105. package/site/scripts/event-board.ts +403 -0
  106. package/site/scripts/event-model.ts +155 -0
  107. package/site/scripts/heatmap.ts +94 -0
  108. package/site/scripts/live.ts +155 -0
  109. package/site/scripts/palette.ts +313 -0
  110. package/site/scripts/replay.ts +252 -0
  111. package/site/scripts/terminal.ts +559 -0
  112. package/site/styles/global.css +508 -0
  113. package/virtual.d.ts +15 -0
  114. package/writing/WritingSnapshot.ts +20 -0
  115. package/writing/fetchWriting.ts +69 -0
  116. package/writing/parse.ts +72 -0
  117. package/writing/writingSnapshotSchema.ts +27 -0
@@ -0,0 +1,15 @@
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
+ /**
5
+ * JSON that is safe to place inside an HTML `<script>` element. `<`, `>` and `&` become Unicode escapes, so text
6
+ * such as `</script>` in cast.yaml cannot close the element, and U+2028/U+2029 are escaped for old parsers.
7
+ * The result parses back to the same value.
8
+ */
9
+ export const safeJson = (value: unknown): string =>
10
+ JSON.stringify(value)
11
+ .replace(/</g, '\\u003c')
12
+ .replace(/>/g, '\\u003e')
13
+ .replace(/&/g, '\\u0026')
14
+ .replace(/\u2028/g, '\\u2028')
15
+ .replace(/\u2029/g, '\\u2029');
@@ -0,0 +1,41 @@
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 { ResolvedSection } from '../../config/ResolvedSection';
5
+ import { SectionId } from '../../config/SectionId';
6
+
7
+ /** The DOM id each section renders with; kept stable so links like /#career keep working. */
8
+ export const anchorFor: Record<SectionId, string> = {
9
+ [SectionId.Hero]: 'top',
10
+ [SectionId.Now]: 'about',
11
+ [SectionId.Stats]: 'stats',
12
+ [SectionId.Career]: 'career',
13
+ [SectionId.Model]: 'model',
14
+ [SectionId.Cratis]: 'cratis',
15
+ [SectionId.Activity]: 'activity',
16
+ [SectionId.Agentic]: 'agentic',
17
+ [SectionId.Projects]: 'projects',
18
+ [SectionId.Writing]: 'writing',
19
+ [SectionId.Talks]: 'talks',
20
+ [SectionId.Skills]: 'skills',
21
+ [SectionId.Contact]: 'contact',
22
+ };
23
+
24
+ const navLabels: Partial<Record<SectionId, string>> = {
25
+ [SectionId.Now]: 'Now',
26
+ [SectionId.Career]: 'Career',
27
+ [SectionId.Cratis]: 'Cratis',
28
+ [SectionId.Projects]: 'Projects',
29
+ [SectionId.Contact]: 'Contact',
30
+ };
31
+
32
+ /** Primary navigation: up to five of the sections that render, in page order. */
33
+ export const navLinks = (sections: readonly ResolvedSection[]): { id: string; label: string }[] =>
34
+ sections
35
+ .filter(section => navLabels[section.id] !== undefined)
36
+ .slice(0, 5)
37
+ .map(section => ({ id: anchorFor[section.id], label: navLabels[section.id]! }));
38
+
39
+ /** The resolved entry for a section, if it renders. */
40
+ export const sectionFor = (sections: readonly ResolvedSection[], id: SectionId): ResolvedSection | undefined =>
41
+ sections.find(section => section.id === id);
@@ -0,0 +1,28 @@
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
+ // Light/dark theme. The inline script in Base.astro applies the stored choice before first
5
+ // paint; this module changes and persists it.
6
+ export type Theme = 'light' | 'dark';
7
+ const KEY = 'cast:theme';
8
+
9
+ export const currentTheme = (): Theme => (document.documentElement.dataset['theme'] === 'light' ? 'light' : 'dark');
10
+
11
+ export function setTheme(theme: Theme): Theme {
12
+ const root = document.documentElement;
13
+ root.classList.add('theme-flip');
14
+ window.setTimeout(() => root.classList.remove('theme-flip'), 450);
15
+ root.dataset['theme'] = theme;
16
+ root.style.colorScheme = theme;
17
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#faf9f6' : '#08080e');
18
+ try {
19
+ localStorage.setItem(KEY, theme);
20
+ } catch {
21
+ /* storage may be unavailable */
22
+ }
23
+ document.dispatchEvent(new CustomEvent('theme:change', { detail: theme }));
24
+ document.querySelectorAll('[data-theme-toggle]').forEach((b) => b.setAttribute('aria-pressed', String(theme === 'light')));
25
+ return theme;
26
+ }
27
+
28
+ export const toggleTheme = (): Theme => setTheme(currentTheme() === 'light' ? 'dark' : 'light');
@@ -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
+ }