@cratis/cast 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/badges/RegistryMember.ts +18 -0
- package/badges/ResolvedTier.ts +17 -0
- package/badges/Tier.ts +14 -0
- package/badges/TierSource.ts +14 -0
- package/badges/badgeSvg.ts +137 -0
- package/badges/constants.ts +17 -0
- package/badges/cratisMark.ts +15 -0
- package/badges/index.ts +12 -0
- package/badges/registry.ts +68 -0
- package/badges/resolveTier.ts +24 -0
- package/cast-json.schema.json +1496 -0
- package/cast.schema.json +1113 -0
- package/config/CastConfig.ts +17 -0
- package/config/CastConfigError.ts +10 -0
- package/config/LiveStat.ts +17 -0
- package/config/ResolvedSection.ts +13 -0
- package/config/SectionId.ts +21 -0
- package/config/ThemeMode.ts +9 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +263 -0
- package/config/index.ts +12 -0
- package/config/parseCast.ts +58 -0
- package/config/resolveSections.ts +48 -0
- package/github/GitHubSnapshot.ts +66 -0
- package/github/fetchGitHub.ts +235 -0
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/github/parse.ts +63 -0
- package/index.ts +88 -0
- package/integration/CastData.ts +20 -0
- package/integration/CastOptions.ts +17 -0
- package/integration/Snapshot.ts +12 -0
- package/integration/loadRegistry.ts +37 -0
- package/integration/resolveData.ts +138 -0
- package/integration/snapshotFile.ts +23 -0
- package/integration/virtualModule.ts +43 -0
- package/machine/castJson.ts +77 -0
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/profileFromCastJson.ts +214 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +80 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +42 -0
- package/routes/apple-touch-icon.png.ts +17 -0
- package/routes/badge-card.svg.ts +13 -0
- package/routes/badge.svg.ts +11 -0
- package/routes/cast.json.ts +17 -0
- package/routes/cv.astro +9 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +9 -0
- package/routes/llms.txt.ts +10 -0
- package/routes/og.png.ts +23 -0
- package/routes/resume.json.ts +13 -0
- package/routes/robots.txt.ts +10 -0
- package/site/components/Activity.astro +196 -0
- package/site/components/Agentic.astro +144 -0
- package/site/components/CareerReplay.astro +415 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +122 -0
- package/site/components/Contact.astro +166 -0
- package/site/components/Cratis.astro +235 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +265 -0
- package/site/components/Footer.astro +33 -0
- package/site/components/Hero.astro +211 -0
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +28 -0
- package/site/components/Nav.astro +127 -0
- package/site/components/Now.astro +116 -0
- package/site/components/Projects.astro +185 -0
- package/site/components/PullRequestFeed.astro +88 -0
- package/site/components/SectionHeader.astro +30 -0
- package/site/components/Skills.astro +44 -0
- package/site/components/Stats.astro +106 -0
- package/site/components/Talks.astro +76 -0
- package/site/components/Terminal.astro +164 -0
- package/site/components/Writing.astro +128 -0
- package/site/data.ts +11 -0
- package/site/layouts/Base.astro +156 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +58 -0
- package/site/lib/career.ts +148 -0
- package/site/lib/clipboard.ts +12 -0
- package/site/lib/contributions.ts +63 -0
- package/site/lib/github.ts +72 -0
- package/site/lib/inline.ts +79 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +49 -0
- package/site/lib/reltime.ts +33 -0
- package/site/lib/safeJson.ts +15 -0
- package/site/lib/sections.ts +41 -0
- package/site/lib/theme.ts +28 -0
- package/site/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +117 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/ambient.ts +93 -0
- package/site/scripts/event-board.ts +403 -0
- package/site/scripts/event-model.ts +155 -0
- package/site/scripts/heatmap.ts +94 -0
- package/site/scripts/live.ts +155 -0
- package/site/scripts/palette.ts +313 -0
- package/site/scripts/replay.ts +252 -0
- package/site/scripts/terminal.ts +559 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +69 -0
- package/writing/parse.ts +72 -0
- package/writing/writingSnapshotSchema.ts +27 -0
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
type Rgb = [number, number, number];
|
|
5
|
+
|
|
6
|
+
const darkGround: Rgb = [8, 8, 14];
|
|
7
|
+
const lightGround: Rgb = [250, 249, 246];
|
|
8
|
+
|
|
9
|
+
const parse = (hex: string): Rgb => {
|
|
10
|
+
const value = Number.parseInt(hex.replace('#', ''), 16);
|
|
11
|
+
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const format = (color: Rgb): string => `#${color.map(channel => Math.round(channel).toString(16).padStart(2, '0')).join('')}`;
|
|
15
|
+
|
|
16
|
+
const linear = (channel: number): number => {
|
|
17
|
+
const value = channel / 255;
|
|
18
|
+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** WCAG relative luminance. */
|
|
22
|
+
export const luminance = (color: Rgb): number => 0.2126 * linear(color[0]) + 0.7152 * linear(color[1]) + 0.0722 * linear(color[2]);
|
|
23
|
+
|
|
24
|
+
/** WCAG contrast ratio between two colors. */
|
|
25
|
+
export const contrast = (first: Rgb, second: Rgb): number => {
|
|
26
|
+
const [high, low] = [luminance(first), luminance(second)].sort((left, right) => right - left) as [number, number];
|
|
27
|
+
return (high + 0.05) / (low + 0.05);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const mix = (color: Rgb, target: Rgb, amount: number): Rgb =>
|
|
31
|
+
color.map((channel, index) => channel + (target[index]! - channel) * amount) as Rgb;
|
|
32
|
+
|
|
33
|
+
/** Moves `color` toward `target` in small steps until it reaches `ratio` against `ground`. */
|
|
34
|
+
const reach = (color: Rgb, target: Rgb, ground: Rgb, ratio: number): Rgb => {
|
|
35
|
+
let current = color;
|
|
36
|
+
for (let step = 0; step < 40 && contrast(current, ground) < ratio; step++) current = mix(current, target, 0.05);
|
|
37
|
+
return current;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Accent tokens for both themes, each at least AA (4.5:1) for text on its ground. */
|
|
41
|
+
export type AccentTokens = { dark: string; darkSoft: string; light: string; lightSoft: string; ink: string };
|
|
42
|
+
|
|
43
|
+
/** Derives the person's accent tokens from one hex color. */
|
|
44
|
+
export function accentTokens(hex: string): AccentTokens {
|
|
45
|
+
const base = parse(hex);
|
|
46
|
+
const dark = reach(base, [255, 255, 255], darkGround, 4.5);
|
|
47
|
+
const darkSoft = reach(mix(dark, [255, 255, 255], 0.35), [255, 255, 255], darkGround, 7);
|
|
48
|
+
const light = reach(base, [0, 0, 0], lightGround, 4.5);
|
|
49
|
+
const lightSoft = reach(mix(light, [0, 0, 0], 0.2), [0, 0, 0], lightGround, 7);
|
|
50
|
+
const ink = contrast(base, [255, 255, 255]) >= contrast(base, darkGround) ? '#ffffff' : '#0b0a12';
|
|
51
|
+
return { dark: format(dark), darkSoft: format(darkSoft), light: format(light), lightSoft: format(lightSoft), ink };
|
|
52
|
+
}
|
package/site/lib/blog.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
// Typed views over the writing captured at build time.
|
|
5
|
+
import type { CastConfig } from '../../config/CastConfig';
|
|
6
|
+
import type { Post as RawPost, WritingSnapshot } from '../../writing/WritingSnapshot';
|
|
7
|
+
|
|
8
|
+
export type ThemeId = string;
|
|
9
|
+
export type Post = RawPost & { headline: string; series?: string; part?: number; themes: ThemeId[] };
|
|
10
|
+
export type WritingTheme = { id: ThemeId; label: string; tags: string[] };
|
|
11
|
+
|
|
12
|
+
/** Splits "Title · Series, Part 3" into its parts. */
|
|
13
|
+
function split(title: string): Pick<Post, 'headline' | 'series' | 'part'> {
|
|
14
|
+
const parts = title.match(/^(.*?)\s+·\s+(.+?),\s+Part\s+(\d+)$/);
|
|
15
|
+
if (parts) return { headline: parts[1]!, series: parts[2]!, part: Number(parts[3]) };
|
|
16
|
+
const series = title.match(/^(.*?)\s+·\s+(.+)$/);
|
|
17
|
+
return series ? { headline: series[1]!, series: series[2]! } : { headline: title };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hostOf = (url: string): string => {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url).host;
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** What the writing sections read. */
|
|
29
|
+
export type WritingView = {
|
|
30
|
+
/** Reading themes for the filter, each a set of tags, from cast.yaml. */
|
|
31
|
+
themes: WritingTheme[];
|
|
32
|
+
/** Newest first; posts published together keep their series order. */
|
|
33
|
+
posts: Post[];
|
|
34
|
+
/** The first source, for "on blog.example.com" links. */
|
|
35
|
+
blogSource: string;
|
|
36
|
+
blogHost: string;
|
|
37
|
+
blogFetchedAt?: string;
|
|
38
|
+
readingMinutes: number;
|
|
39
|
+
postsFor: (theme: ThemeId | undefined) => Post[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Builds the writing view for one profile. */
|
|
43
|
+
export function writingView(config: CastConfig, writing: WritingSnapshot): WritingView {
|
|
44
|
+
const themes = config.writing.themes;
|
|
45
|
+
const posts: Post[] = writing.posts
|
|
46
|
+
.map(post => ({ ...post, ...split(post.title), themes: themes.filter(theme => post.tags.some(tag => theme.tags.includes(tag))).map(theme => theme.id) }))
|
|
47
|
+
.sort((left, right) => right.date.slice(0, 10).localeCompare(left.date.slice(0, 10)) || (left.part ?? 0) - (right.part ?? 0));
|
|
48
|
+
const blogSource = writing.sources[0] ?? '';
|
|
49
|
+
return {
|
|
50
|
+
themes,
|
|
51
|
+
posts,
|
|
52
|
+
blogSource,
|
|
53
|
+
blogHost: blogSource ? hostOf(blogSource) : '',
|
|
54
|
+
...(writing.fetchedAt ? { blogFetchedAt: writing.fetchedAt } : {}),
|
|
55
|
+
readingMinutes: posts.reduce((total, post) => total + (post.minutes ?? 0), 0),
|
|
56
|
+
postsFor: theme => (theme ? posts.filter(post => post.themes.includes(theme)) : []),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The career projection. Every field is derived by folding career events in
|
|
8
|
+
* stream order; nothing here is hard-coded per position.
|
|
9
|
+
*/
|
|
10
|
+
export type Role = {
|
|
11
|
+
title: string;
|
|
12
|
+
org: string;
|
|
13
|
+
orgUrl?: string | undefined;
|
|
14
|
+
location?: string | undefined;
|
|
15
|
+
from: string;
|
|
16
|
+
to?: string | undefined;
|
|
17
|
+
summary: string;
|
|
18
|
+
skills: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CareerProjection = {
|
|
22
|
+
/** Number of events applied (stream position). */
|
|
23
|
+
position: number;
|
|
24
|
+
lastEvent: CareerEvent | undefined;
|
|
25
|
+
/** Latest event date seen; monotonic, so year-only dates never move time backwards. */
|
|
26
|
+
clock: string | undefined;
|
|
27
|
+
/** Date of the first event that is not education. */
|
|
28
|
+
shippingSince: string | undefined;
|
|
29
|
+
currentRole: Role | undefined;
|
|
30
|
+
/** Every role still open, newest first. */
|
|
31
|
+
currentRoles: Role[];
|
|
32
|
+
roles: Role[];
|
|
33
|
+
affiliations: string[];
|
|
34
|
+
skills: string[];
|
|
35
|
+
/** Skills introduced by the last applied event. */
|
|
36
|
+
newSkills: string[];
|
|
37
|
+
eventTypes: Record<string, number>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Event types that mean "joined an organization". Presentation rule only. */
|
|
41
|
+
const AFFILIATION_EVENTS = new Set([
|
|
42
|
+
'StudiesStarted',
|
|
43
|
+
'VolunteerTeamJoined',
|
|
44
|
+
'PartTimeRoleStarted',
|
|
45
|
+
'ContractStarted',
|
|
46
|
+
'EmploymentStarted',
|
|
47
|
+
'CompanyCoFounded',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const EDUCATION_EVENTS = new Set(['StudiesStarted', 'DegreeCompleted', 'CertificationsEarned']);
|
|
51
|
+
|
|
52
|
+
export const initialProjection: CareerProjection = {
|
|
53
|
+
position: 0,
|
|
54
|
+
lastEvent: undefined,
|
|
55
|
+
clock: undefined,
|
|
56
|
+
shippingSince: undefined,
|
|
57
|
+
currentRole: undefined,
|
|
58
|
+
currentRoles: [],
|
|
59
|
+
roles: [],
|
|
60
|
+
affiliations: [],
|
|
61
|
+
skills: [],
|
|
62
|
+
newSkills: [],
|
|
63
|
+
eventTypes: {},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Normalizes 'YYYY' and 'YYYY-MM' to a sortable 'YYYY-MM'. */
|
|
67
|
+
export const normalizeDate = (date: string): string => (date.length === 4 ? `${date}-01` : date);
|
|
68
|
+
|
|
69
|
+
const later = (a: string | undefined, b: string): string => (!a || b > a ? b : a);
|
|
70
|
+
|
|
71
|
+
/** Pure reducer: applies one event to a projection. */
|
|
72
|
+
export function apply(state: CareerProjection, event: CareerEvent): CareerProjection {
|
|
73
|
+
const date = normalizeDate(event.date);
|
|
74
|
+
|
|
75
|
+
let roles = state.roles;
|
|
76
|
+
if (event.endsRole) {
|
|
77
|
+
roles = roles.map((r) => (r.org === event.endsRole && !r.to ? { ...r, to: event.date } : r));
|
|
78
|
+
}
|
|
79
|
+
if (event.role && event.org) {
|
|
80
|
+
roles = [
|
|
81
|
+
...roles,
|
|
82
|
+
{
|
|
83
|
+
title: event.role,
|
|
84
|
+
org: event.org,
|
|
85
|
+
orgUrl: event.orgUrl,
|
|
86
|
+
location: event.location,
|
|
87
|
+
from: event.date,
|
|
88
|
+
summary: event.summary,
|
|
89
|
+
skills: event.skills ?? [],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const currentRoles = [...roles].reverse().filter((r) => !r.to);
|
|
95
|
+
const currentRole = currentRoles[0];
|
|
96
|
+
|
|
97
|
+
const affiliations =
|
|
98
|
+
event.org && AFFILIATION_EVENTS.has(event.type) && !state.affiliations.includes(event.org)
|
|
99
|
+
? [...state.affiliations, event.org]
|
|
100
|
+
: state.affiliations;
|
|
101
|
+
|
|
102
|
+
const newSkills = (event.skills ?? []).filter((s) => !state.skills.includes(s));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
position: state.position + 1,
|
|
106
|
+
lastEvent: event,
|
|
107
|
+
clock: later(state.clock, date),
|
|
108
|
+
shippingSince: state.shippingSince ?? (EDUCATION_EVENTS.has(event.type) ? undefined : date),
|
|
109
|
+
currentRole,
|
|
110
|
+
currentRoles,
|
|
111
|
+
roles,
|
|
112
|
+
affiliations,
|
|
113
|
+
skills: [...state.skills, ...newSkills],
|
|
114
|
+
newSkills,
|
|
115
|
+
eventTypes: { ...state.eventTypes, [event.type]: (state.eventTypes[event.type] ?? 0) + 1 },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Replays the first `position` events of a stream. */
|
|
120
|
+
export const project = (events: readonly CareerEvent[], position = events.length): CareerProjection =>
|
|
121
|
+
events.slice(0, position).reduce(apply, initialProjection);
|
|
122
|
+
|
|
123
|
+
const monthIndex = (date: string): number => {
|
|
124
|
+
const [y, m] = normalizeDate(date).split('-').map(Number);
|
|
125
|
+
return (y ?? 0) * 12 + ((m ?? 1) - 1);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Years between two YYYY-MM dates, one decimal. */
|
|
129
|
+
export const yearsBetween = (from: string | undefined, to: string | undefined): number =>
|
|
130
|
+
from && to ? Math.max(0, Math.round(((monthIndex(to) - monthIndex(from)) / 12) * 10) / 10) : 0;
|
|
131
|
+
|
|
132
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
133
|
+
|
|
134
|
+
/** 'YYYY-MM' -> 'Jun 2025', 'YYYY' -> '2025'. */
|
|
135
|
+
export function formatMonth(date: string): string {
|
|
136
|
+
if (date.length === 4) return date;
|
|
137
|
+
const [y, m] = date.split('-');
|
|
138
|
+
return `${MONTHS[Number(m) - 1] ?? ''} ${y}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "also Co-founder & Chief Architect · Cratis" for the open roles beyond the newest one. */
|
|
142
|
+
export const alsoText = (state: CareerProjection): string =>
|
|
143
|
+
state.currentRoles.length > 1
|
|
144
|
+
? `also ${state.currentRoles
|
|
145
|
+
.slice(1)
|
|
146
|
+
.map((r) => `${r.title} · ${r.org}`)
|
|
147
|
+
.join(', ')}`
|
|
148
|
+
: '';
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
/** Copies text with the async Clipboard API. Returns false when the browser refuses. */
|
|
5
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(text);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
// Folds the contribution calendar from the GitHub snapshot into what the heatmap shows.
|
|
5
|
+
import type { Week } from './github';
|
|
6
|
+
|
|
7
|
+
export type Day = { date: string; count: number; level: 0 | 1 | 2 | 3 | 4; week: number; weekday: number };
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const iso = (t: number) => new Date(t).toISOString().slice(0, 10);
|
|
11
|
+
|
|
12
|
+
export function foldCalendar(weeks: Week[]) {
|
|
13
|
+
const days: Day[] = [];
|
|
14
|
+
weeks.forEach((w, wi) => {
|
|
15
|
+
const start = Date.parse(`${w.start}T00:00:00Z`);
|
|
16
|
+
const offset = new Date(start).getUTCDay();
|
|
17
|
+
w.counts.forEach((count, i) =>
|
|
18
|
+
days.push({ date: iso(start + i * DAY_MS), count, level: 0, week: wi, weekday: (offset + i) % 7 }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Intensity levels from quartiles of active days, so one huge day does not wash out the rest.
|
|
23
|
+
const active = days.map((d) => d.count).filter((n) => n > 0).sort((a, b) => a - b);
|
|
24
|
+
const q = (p: number) => active[Math.min(active.length - 1, Math.floor(active.length * p))] ?? 1;
|
|
25
|
+
const [q1, q2, q3] = [q(0.25), q(0.5), q(0.75)];
|
|
26
|
+
for (const d of days) d.level = d.count === 0 ? 0 : d.count <= q1 ? 1 : d.count <= q2 ? 2 : d.count <= q3 ? 3 : 4;
|
|
27
|
+
|
|
28
|
+
let longest = 0;
|
|
29
|
+
let run = 0;
|
|
30
|
+
for (const d of days) {
|
|
31
|
+
run = d.count > 0 ? run + 1 : 0;
|
|
32
|
+
longest = Math.max(longest, run);
|
|
33
|
+
}
|
|
34
|
+
// Current streak: today may not have contributions yet, so it does not break the streak.
|
|
35
|
+
let current = 0;
|
|
36
|
+
for (let i = days.length - 1; i >= 0; i--) {
|
|
37
|
+
const d = days[i]!;
|
|
38
|
+
if (d.count > 0) current++;
|
|
39
|
+
else if (i !== days.length - 1) break;
|
|
40
|
+
}
|
|
41
|
+
const busiest = days.reduce((best, d) => (d.count > best.count ? d : best), days[0] ?? { date: '', count: 0 } as Day);
|
|
42
|
+
|
|
43
|
+
const months = new Map<string, number>();
|
|
44
|
+
for (const d of days) months.set(d.date.slice(0, 7), (months.get(d.date.slice(0, 7)) ?? 0) + d.count);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
days,
|
|
48
|
+
weekCount: weeks.length,
|
|
49
|
+
activeDays: active.length,
|
|
50
|
+
longest,
|
|
51
|
+
current,
|
|
52
|
+
busiest,
|
|
53
|
+
months: [...months.entries()].map(([month, total]) => ({ month, total })),
|
|
54
|
+
first: days[0]?.date ?? '',
|
|
55
|
+
last: days[days.length - 1]?.date ?? '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const formatDay = (date: string): string =>
|
|
60
|
+
new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
|
61
|
+
|
|
62
|
+
export const formatMonthLong = (month: string): string =>
|
|
63
|
+
new Date(`${month}-01T00:00:00Z`).toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
// Typed views over the GitHub data captured at build time.
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
import type { CastConfig } from '../../config/CastConfig';
|
|
7
|
+
import type { cratisRepositorySchema, projectSchema } from '../../config/castSchema';
|
|
8
|
+
import type { GitHubSnapshot } from '../../github/GitHubSnapshot';
|
|
9
|
+
|
|
10
|
+
export type { PullRequest, ContributionWeek as Week } from '../../github/GitHubSnapshot';
|
|
11
|
+
export type CratisRepo = z.output<typeof cratisRepositorySchema> & { role: string };
|
|
12
|
+
export type Project = z.output<typeof projectSchema>;
|
|
13
|
+
|
|
14
|
+
const roleFor = (repo: z.output<typeof cratisRepositorySchema>, rank: number): string =>
|
|
15
|
+
repo.founded
|
|
16
|
+
? (rank === 1 ? 'Founder & lead' : 'Founder')
|
|
17
|
+
: rank === 1
|
|
18
|
+
? (repo.role?.startsWith('#') ? '#1 committer' : 'Lead developer')
|
|
19
|
+
: rank === 2
|
|
20
|
+
? (repo.group === 'client' ? '#2 contributor' : '#2 all-time')
|
|
21
|
+
: 'Contributor';
|
|
22
|
+
|
|
23
|
+
/** owner/name of a github.com URL. */
|
|
24
|
+
export const repositoryOf = (url: string): string | undefined => url.match(/^https:\/\/github\.com\/([^/]+\/[^/#?]+)/i)?.[1];
|
|
25
|
+
|
|
26
|
+
/** Formats a capture date as "Oct 16, 2026", or "the last build" when unknown. */
|
|
27
|
+
export const formatSnapshotDate = (date: string | undefined): string =>
|
|
28
|
+
date ? new Date(date).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : 'the last build';
|
|
29
|
+
|
|
30
|
+
/** What the sections read from the GitHub snapshot, with cast.yaml values as fallbacks. */
|
|
31
|
+
export type GitHubView = {
|
|
32
|
+
github: GitHubSnapshot;
|
|
33
|
+
/** Merged pull requests across the configured organizations, falling back to a stat written in cast.yaml. */
|
|
34
|
+
mergedPrTotal: number;
|
|
35
|
+
/** Floors to hundreds for an honest "3,100+" style figure. */
|
|
36
|
+
mergedPrFloor: number;
|
|
37
|
+
/** Per-repository figures prefer the snapshot; values written in cast.yaml are fallbacks only. */
|
|
38
|
+
cratisRepos: CratisRepo[];
|
|
39
|
+
/** Projects with versions taken from the latest GitHub release when one was captured. */
|
|
40
|
+
liveProjects: Project[];
|
|
41
|
+
/** Stars for an owner/name, if tracked. */
|
|
42
|
+
starsFor: (repository: string) => number | undefined;
|
|
43
|
+
/** The capture date of a snapshot, by default this one. */
|
|
44
|
+
snapshotDate: (date?: string) => string;
|
|
45
|
+
/** Repository names in the live feed, most recent first, without duplicates. */
|
|
46
|
+
liveRepos: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Builds the GitHub view for one profile. */
|
|
50
|
+
export function gitHubView(config: CastConfig, github: GitHubSnapshot): GitHubView {
|
|
51
|
+
const mergedPrTotal = github.mergedPullRequests.total || (config.stats.find(stat => stat.live === 'mergedPullRequests')?.value ?? 0);
|
|
52
|
+
const cratisRepos = (config.cratis?.repos ?? []).map(repo => {
|
|
53
|
+
const counts = repo.url ? github.repositories[`Cratis/${repo.name}`] : undefined;
|
|
54
|
+
if (!counts) return { ...repo, role: repo.role ?? roleFor(repo, repo.rank) };
|
|
55
|
+
return { ...repo, ...counts, role: roleFor(repo, counts.rank) };
|
|
56
|
+
});
|
|
57
|
+
const liveProjects = config.projects.map(project => {
|
|
58
|
+
const repository = repositoryOf(project.url);
|
|
59
|
+
const release = repository ? Object.entries(github.releases).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1] : undefined;
|
|
60
|
+
return release ? { ...project, version: release.tag } : project;
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
github,
|
|
64
|
+
mergedPrTotal,
|
|
65
|
+
mergedPrFloor: mergedPrTotal >= 100 ? Math.floor(mergedPrTotal / 100) * 100 : mergedPrTotal,
|
|
66
|
+
cratisRepos,
|
|
67
|
+
liveProjects,
|
|
68
|
+
starsFor: repository => Object.entries(github.stars).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1],
|
|
69
|
+
snapshotDate: (date = github.fetchedAt) => formatSnapshotDate(date),
|
|
70
|
+
liveRepos: [...new Set(github.mergedPullRequests.latest.map(pullRequest => pullRequest.repository.split('/')[1] ?? pullRequest.repository))],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
/** One piece of inline text: plain text, or markup with its own pieces inside. */
|
|
5
|
+
export type InlineToken =
|
|
6
|
+
| { kind: 'text'; text: string }
|
|
7
|
+
| { kind: 'code'; text: string }
|
|
8
|
+
| { kind: 'em' | 'strong'; children: InlineToken[] }
|
|
9
|
+
| { kind: 'link'; href: string; children: InlineToken[] };
|
|
10
|
+
|
|
11
|
+
type Pattern = { kind: 'code' | 'link' | 'strong' | 'em'; expression: RegExp };
|
|
12
|
+
|
|
13
|
+
/** In order of precedence when two start at the same position. */
|
|
14
|
+
const patterns: Pattern[] = [
|
|
15
|
+
{ kind: 'code', expression: /`([^`]+)`/g },
|
|
16
|
+
{ kind: 'link', expression: /\[([^\]]+)\]\(([^)\s]+)\)/g },
|
|
17
|
+
{ kind: 'strong', expression: /\*\*([^*]+)\*\*/g },
|
|
18
|
+
{ kind: 'em', expression: /\*([^*]+)\*/g },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Link targets inline text may use: http(s), mailto, a path or an anchor. Anything else stays plain text. */
|
|
22
|
+
const allowedHref = /^(https?:\/\/[^\s]+|mailto:[^\s]+|\/[^\s]*|#[^\s]*)$/i;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses the inline markup cast.yaml text may use into tokens: `*emphasis*` (the italic serif accent),
|
|
26
|
+
* `**strong**`, `` `code` `` and `[links](https://…)`. Tokens are rendered as elements with escaped text,
|
|
27
|
+
* never as HTML, so markup in member text cannot inject anything. Links to other schemes stay plain text.
|
|
28
|
+
*/
|
|
29
|
+
export function parseInline(text: string): InlineToken[] {
|
|
30
|
+
const tokens: InlineToken[] = [];
|
|
31
|
+
let position = 0;
|
|
32
|
+
while (position < text.length) {
|
|
33
|
+
let best: { pattern: Pattern; match: RegExpExecArray } | undefined;
|
|
34
|
+
for (const pattern of patterns) {
|
|
35
|
+
pattern.expression.lastIndex = position;
|
|
36
|
+
const match = pattern.expression.exec(text);
|
|
37
|
+
if (match && (!best || match.index < best.match.index)) best = { pattern, match };
|
|
38
|
+
}
|
|
39
|
+
if (!best) break;
|
|
40
|
+
const { pattern, match } = best;
|
|
41
|
+
if (match.index > position) tokens.push({ kind: 'text', text: text.slice(position, match.index) });
|
|
42
|
+
const inner = match[1]!;
|
|
43
|
+
if (pattern.kind === 'code') tokens.push({ kind: 'code', text: inner });
|
|
44
|
+
else if (pattern.kind === 'link') {
|
|
45
|
+
const href = match[2]!;
|
|
46
|
+
if (allowedHref.test(href)) tokens.push({ kind: 'link', href, children: parseInline(inner) });
|
|
47
|
+
else tokens.push({ kind: 'text', text: match[0] });
|
|
48
|
+
} else tokens.push({ kind: pattern.kind, children: parseInline(inner) });
|
|
49
|
+
position = match.index + match[0].length;
|
|
50
|
+
}
|
|
51
|
+
if (position < text.length) tokens.push({ kind: 'text', text: text.slice(position) });
|
|
52
|
+
return tokens;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const escapeHtml = (value: string): string =>
|
|
56
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
57
|
+
|
|
58
|
+
const linkClass = 'text-fg underline decoration-line-strong hover:decoration-fg';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renders inline markup to an HTML string with everything escaped, for outputs that are not Astro components.
|
|
62
|
+
* Components render `Inline.astro`, which builds elements from the same tokens.
|
|
63
|
+
*/
|
|
64
|
+
export function renderInline(text: string, emphasisClass = 'serif'): string {
|
|
65
|
+
const render = (tokens: InlineToken[]): string => tokens.map(token => {
|
|
66
|
+
switch (token.kind) {
|
|
67
|
+
case 'text': return escapeHtml(token.text);
|
|
68
|
+
case 'code': return `<code class="inline-code">${escapeHtml(token.text)}</code>`;
|
|
69
|
+
case 'strong': return `<strong class="text-fg font-semibold">${render(token.children)}</strong>`;
|
|
70
|
+
case 'em': return `<em class="${escapeHtml(emphasisClass)}">${render(token.children)}</em>`;
|
|
71
|
+
case 'link': return `<a href="${escapeHtml(token.href)}" class="${linkClass}"${/^https?:/i.test(token.href) ? ' rel="noopener"' : ''}>${render(token.children)}</a>`;
|
|
72
|
+
}
|
|
73
|
+
}).join('');
|
|
74
|
+
return render(parseInline(text));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Removes inline markup, for meta tags, JSON and plain-text outputs. */
|
|
78
|
+
export const plainText = (text: string): string =>
|
|
79
|
+
text.replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1');
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
// Page lifecycle that works with and without Astro's <ClientRouter />.
|
|
5
|
+
//
|
|
6
|
+
// With the router, module scripts run once per full load while the DOM is swapped on
|
|
7
|
+
// every navigation. `onPageLoad` runs `init` for every page view (initial and after each
|
|
8
|
+
// navigation) and runs the returned cleanup before the next swap, so nothing double-binds
|
|
9
|
+
// and observers on detached elements are released.
|
|
10
|
+
type Cleanup = (() => void) | void;
|
|
11
|
+
|
|
12
|
+
const routerActive = () => !!document.querySelector('meta[name="astro-view-transitions-enabled"]');
|
|
13
|
+
|
|
14
|
+
export function onPageLoad(init: () => Cleanup): void {
|
|
15
|
+
let cleanup: Cleanup;
|
|
16
|
+
const run = () => {
|
|
17
|
+
cleanup?.();
|
|
18
|
+
cleanup = init();
|
|
19
|
+
};
|
|
20
|
+
document.addEventListener('astro:page-load', run);
|
|
21
|
+
document.addEventListener('astro:before-swap', () => {
|
|
22
|
+
cleanup?.();
|
|
23
|
+
cleanup = undefined;
|
|
24
|
+
});
|
|
25
|
+
if (!routerActive()) run();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Collects teardown callbacks for one page view. */
|
|
29
|
+
export function disposer() {
|
|
30
|
+
const fns: (() => void)[] = [];
|
|
31
|
+
return {
|
|
32
|
+
add<T extends () => void>(fn: T): T {
|
|
33
|
+
fns.push(fn);
|
|
34
|
+
return fn;
|
|
35
|
+
},
|
|
36
|
+
/** addEventListener that is removed on dispose. */
|
|
37
|
+
on<E extends Event = Event>(target: EventTarget, type: string, fn: (e: E) => void, opts?: AddEventListenerOptions): void {
|
|
38
|
+
const listener = fn as unknown as EventListener;
|
|
39
|
+
target.addEventListener(type, listener, opts);
|
|
40
|
+
fns.push(() => target.removeEventListener(type, listener, opts));
|
|
41
|
+
},
|
|
42
|
+
dispose: () => fns.splice(0).forEach((fn) => fn()),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
const KEY = 'cast:motion';
|
|
5
|
+
const query = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)') : undefined;
|
|
6
|
+
|
|
7
|
+
/** True when the OS asks for reduced motion or the visitor turned motion off. */
|
|
8
|
+
export function motionReduced(): boolean {
|
|
9
|
+
if (typeof document === 'undefined') return true;
|
|
10
|
+
const pref = document.documentElement.dataset['motion'];
|
|
11
|
+
if (pref === 'reduced') return true;
|
|
12
|
+
if (pref === 'full') return false;
|
|
13
|
+
return query?.matches ?? false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Listener = (reduced: boolean) => void;
|
|
17
|
+
const listeners = new Set<Listener>();
|
|
18
|
+
|
|
19
|
+
export function onMotionChange(listener: Listener): () => void {
|
|
20
|
+
listeners.add(listener);
|
|
21
|
+
return () => listeners.delete(listener);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const notify = () => {
|
|
25
|
+
const reduced = motionReduced();
|
|
26
|
+
document.documentElement.classList.toggle('js-motion', !reduced);
|
|
27
|
+
listeners.forEach((l) => l(reduced));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
query?.addEventListener('change', notify);
|
|
31
|
+
|
|
32
|
+
/** Toggles the visitor's motion preference and persists it. */
|
|
33
|
+
export function toggleMotion(): boolean {
|
|
34
|
+
const next = motionReduced() ? 'full' : 'reduced';
|
|
35
|
+
document.documentElement.dataset['motion'] = next;
|
|
36
|
+
try {
|
|
37
|
+
localStorage.setItem(KEY, next);
|
|
38
|
+
} catch {
|
|
39
|
+
/* storage may be unavailable */
|
|
40
|
+
}
|
|
41
|
+
notify();
|
|
42
|
+
return next === 'reduced';
|
|
43
|
+
}
|
package/site/lib/nav.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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 { readClientCast } from '../profile/clientCast';
|
|
5
|
+
import { motionReduced } from './motion';
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Navigates to another page, through the view-transition router when it is active. */
|
|
20
|
+
export async function go(href: string): Promise<void> {
|
|
21
|
+
if (document.querySelector('meta[name="astro-view-transitions-enabled"]')) {
|
|
22
|
+
const { navigate } = await import('astro:transitions/client');
|
|
23
|
+
await navigate(href);
|
|
24
|
+
} else {
|
|
25
|
+
location.href = href;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scrolls to a home-page section and moves focus to its heading. */
|
|
30
|
+
export function goSection(id: string): void {
|
|
31
|
+
const el = document.getElementById(id);
|
|
32
|
+
if (onHome() && el) {
|
|
33
|
+
el.scrollIntoView({ behavior: motionReduced() ? 'auto' : 'smooth', block: 'start' });
|
|
34
|
+
history.replaceState(history.state, '', `#${id}`);
|
|
35
|
+
const focusTarget = el.querySelector<HTMLElement>('h2') ?? el;
|
|
36
|
+
focusTarget.setAttribute('tabindex', '-1');
|
|
37
|
+
focusTarget.focus({ preventScroll: true });
|
|
38
|
+
} else {
|
|
39
|
+
void go(profilePath(`#${id}`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Scrolls to the career section and starts the replay, from any page. */
|
|
44
|
+
export function startReplay(): void {
|
|
45
|
+
if (onHome() && document.querySelector('[data-replay]')) document.dispatchEvent(new CustomEvent('replay:start'));
|
|
46
|
+
else void go(profilePath('?replay#career'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const openExternal = (url: string): void => void window.open(url, '_blank', 'noopener');
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
const rtf = typeof Intl !== 'undefined' ? new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) : undefined;
|
|
5
|
+
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
6
|
+
['year', 31_536_000_000],
|
|
7
|
+
['month', 2_592_000_000],
|
|
8
|
+
['week', 604_800_000],
|
|
9
|
+
['day', 86_400_000],
|
|
10
|
+
['hour', 3_600_000],
|
|
11
|
+
['minute', 60_000],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** "3 days ago" style text for an ISO timestamp, relative to `now`. */
|
|
15
|
+
export function relativeTime(iso: string, now = Date.now()): string {
|
|
16
|
+
const diff = Date.parse(iso) - now;
|
|
17
|
+
for (const [unit, ms] of UNITS) {
|
|
18
|
+
if (Math.abs(diff) >= ms || unit === 'minute') {
|
|
19
|
+
return rtf?.format(Math.round(diff / ms), unit) ?? new Date(iso).toDateString();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return iso;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Replaces the build-time text of every <time data-rel> with a live relative time. */
|
|
26
|
+
export function hydrateRelativeTimes(root: ParentNode = document): void {
|
|
27
|
+
root.querySelectorAll<HTMLTimeElement>('time[data-rel]').forEach((el) => {
|
|
28
|
+
const iso = el.dateTime;
|
|
29
|
+
if (!iso) return;
|
|
30
|
+
el.title ||= new Date(iso).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
31
|
+
el.textContent = relativeTime(iso);
|
|
32
|
+
});
|
|
33
|
+
}
|