@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,235 @@
|
|
|
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 { cratisOrganization } from '../badges/constants';
|
|
5
|
+
import type { GitHubSnapshot, Release } from './GitHubSnapshot';
|
|
6
|
+
import { mergedPullRequestQuery, parseCalendar, parseMergedPullRequests, standingAmongHumans, type CalendarWeekNode, type ContributorEntry, type PullRequestNode } from './parse';
|
|
7
|
+
|
|
8
|
+
/** What to capture. */
|
|
9
|
+
export type GitHubRequest = {
|
|
10
|
+
login: string;
|
|
11
|
+
orgs: string[];
|
|
12
|
+
/** owner/name repositories to compute commit rank and merged pull requests for. */
|
|
13
|
+
repositories: string[];
|
|
14
|
+
releases: string[];
|
|
15
|
+
stars: string[];
|
|
16
|
+
feedLimit?: number;
|
|
17
|
+
/** Spacing between search requests (search has its own 30-per-minute limit). Default 2100 ms. */
|
|
18
|
+
searchDelayMilliseconds?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Evidence fetched in this call only, for granting tiers. Never persisted: a snapshot is owner-editable, so
|
|
23
|
+
* nothing read from one may grant a tier.
|
|
24
|
+
*/
|
|
25
|
+
export type FreshEvidence = {
|
|
26
|
+
/** Merged public pull requests in the Cratis organization, when the search succeeded in this call. */
|
|
27
|
+
cratisMergedPullRequests?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A fetch result: the snapshot to render and persist, and the evidence that is used for tiers and then dropped. */
|
|
31
|
+
export type GitHubFetchResult = { snapshot: GitHubSnapshot; fresh: FreshEvidence };
|
|
32
|
+
|
|
33
|
+
/** Receives warnings; the fetch itself never throws. */
|
|
34
|
+
export type Warn = (message: string) => void;
|
|
35
|
+
|
|
36
|
+
const userAgent = 'cratis-cast-build';
|
|
37
|
+
const sleep = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
38
|
+
|
|
39
|
+
/** Reads the token the build should use, if any. */
|
|
40
|
+
export const gitHubToken = (): string | undefined => process.env.GITHUB_TOKEN || process.env.GH_TOKEN || undefined;
|
|
41
|
+
|
|
42
|
+
async function rest(path: string, token: string): Promise<Response> {
|
|
43
|
+
const response = await fetch(`https://api.github.com${path}`, {
|
|
44
|
+
headers: { authorization: `bearer ${token}`, accept: 'application/vnd.github+json', 'user-agent': userAgent },
|
|
45
|
+
signal: AbortSignal.timeout(20_000),
|
|
46
|
+
});
|
|
47
|
+
if (response.status !== 200) throw new Error(`${path}: GitHub answered ${response.status}`);
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs a GraphQL query; throws on transport errors and on GraphQL errors. With `allowPartial`, GraphQL errors
|
|
53
|
+
* (such as one unknown login in a batch) are reported through `onErrors` and whatever data came back is returned.
|
|
54
|
+
*/
|
|
55
|
+
export async function graphql<T>(query: string, variables: Record<string, unknown>, token: string, partial?: { allowPartial: true; onErrors: (messages: string[]) => void }): Promise<T> {
|
|
56
|
+
const response = await fetch('https://api.github.com/graphql', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { authorization: `bearer ${token}`, 'content-type': 'application/json', 'user-agent': userAgent },
|
|
59
|
+
body: JSON.stringify({ query, variables }),
|
|
60
|
+
signal: AbortSignal.timeout(25_000),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) throw new Error(`GitHub GraphQL answered ${response.status} ${response.statusText}`);
|
|
63
|
+
const body = await response.json() as { data?: T; errors?: { message: string }[] };
|
|
64
|
+
if (body.errors?.length) {
|
|
65
|
+
const messages = body.errors.map(error => error.message);
|
|
66
|
+
if (!partial || !body.data) throw new Error(`GraphQL errors: ${messages.join('; ')}`);
|
|
67
|
+
partial.onErrors(messages);
|
|
68
|
+
}
|
|
69
|
+
if (!body.data) throw new Error('GraphQL returned no data');
|
|
70
|
+
return body.data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Counts search results. Search has its own 30-per-minute limit, so calls are spaced out. */
|
|
74
|
+
export async function searchCount(query: string, token: string, delayMilliseconds = 2100): Promise<number> {
|
|
75
|
+
await sleep(delayMilliseconds);
|
|
76
|
+
const body = await (await rest(`/search/issues?q=${encodeURIComponent(query)}&per_page=1`, token)).json() as { total_count?: number; incomplete_results?: boolean };
|
|
77
|
+
if (!Number.isInteger(body.total_count) || body.incomplete_results) throw new Error('incomplete search response');
|
|
78
|
+
return body.total_count!;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function contributors(repository: string, token: string): Promise<ContributorEntry[]> {
|
|
82
|
+
const all: ContributorEntry[] = [];
|
|
83
|
+
for (let page = 1; page <= 10; page++) {
|
|
84
|
+
const path = `/repos/${repository}/contributors?per_page=100&page=${page}`;
|
|
85
|
+
let response: Response;
|
|
86
|
+
try {
|
|
87
|
+
response = await rest(path, token);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// 202 means GitHub is still computing the statistics; one retry is enough for a build.
|
|
90
|
+
if (!String(error).includes('answered 202')) throw error;
|
|
91
|
+
await sleep(1000);
|
|
92
|
+
response = await rest(path, token);
|
|
93
|
+
}
|
|
94
|
+
const items = await response.json() as ContributorEntry[];
|
|
95
|
+
all.push(...items);
|
|
96
|
+
if (!response.headers.get('link')?.includes('rel="next"')) break;
|
|
97
|
+
}
|
|
98
|
+
if (all.length === 0) throw new Error(`${repository}: no contributors returned`);
|
|
99
|
+
return all;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const alias = (index: number) => `r${index}`;
|
|
103
|
+
const split = (repository: string): [string, string] => {
|
|
104
|
+
const [owner, name] = repository.split('/');
|
|
105
|
+
return [owner ?? '', name ?? ''];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Captures public GitHub data. Returns undefined when nothing could be captured; per-repository failures keep
|
|
110
|
+
* that repository's previous values. Never throws.
|
|
111
|
+
*/
|
|
112
|
+
export async function fetchGitHub(request: GitHubRequest, token: string, previous: GitHubSnapshot | undefined, warn: Warn): Promise<GitHubFetchResult | undefined> {
|
|
113
|
+
const delay = request.searchDelayMilliseconds ?? 2100;
|
|
114
|
+
const tracked = [...new Set([...request.releases, ...request.stars, ...request.repositories])];
|
|
115
|
+
const repositoryFields = tracked.map((repository, index) => {
|
|
116
|
+
const [owner, name] = split(repository);
|
|
117
|
+
return `${alias(index)}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { nameWithOwner isPrivate stargazerCount latestRelease { tagName publishedAt url } }`;
|
|
118
|
+
}).join('\n ');
|
|
119
|
+
|
|
120
|
+
const query = `query($login: String!, $prs: String!) {
|
|
121
|
+
user(login: $login) {
|
|
122
|
+
login name bio location websiteUrl avatarUrl
|
|
123
|
+
followers { totalCount }
|
|
124
|
+
repositories(privacy: PUBLIC) { totalCount }
|
|
125
|
+
contributionsCollection {
|
|
126
|
+
totalCommitContributions
|
|
127
|
+
contributionCalendar { totalContributions weeks { contributionDays { date contributionCount } } }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
${repositoryFields}
|
|
131
|
+
merged: search(query: $prs, type: ISSUE, first: 60) {
|
|
132
|
+
issueCount
|
|
133
|
+
nodes { ... on PullRequest { title number url mergedAt repository { nameWithOwner isPrivate } } }
|
|
134
|
+
}
|
|
135
|
+
}`;
|
|
136
|
+
|
|
137
|
+
type RepositoryNode = { nameWithOwner: string; isPrivate: boolean; stargazerCount: number; latestRelease: { tagName: string; publishedAt: string; url: string } | null } | null;
|
|
138
|
+
type Data = {
|
|
139
|
+
user: {
|
|
140
|
+
login: string; name: string | null; bio: string | null; location: string | null; websiteUrl: string | null; avatarUrl: string | null;
|
|
141
|
+
followers: { totalCount: number }; repositories: { totalCount: number };
|
|
142
|
+
contributionsCollection: { totalCommitContributions: number; contributionCalendar: { totalContributions: number; weeks: CalendarWeekNode[] } };
|
|
143
|
+
} | null;
|
|
144
|
+
merged: { issueCount: number; nodes: PullRequestNode[] };
|
|
145
|
+
} & Record<string, RepositoryNode>;
|
|
146
|
+
|
|
147
|
+
let data: Data;
|
|
148
|
+
try {
|
|
149
|
+
data = await graphql<Data>(query, { login: request.login, prs: `${mergedPullRequestQuery(request.login, request.orgs)} sort:updated-desc` }, token);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
if (!data.user) {
|
|
155
|
+
warn(`GitHub has no user '${request.login}'`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const publicRepository = (repository: string): RepositoryNode | undefined => {
|
|
160
|
+
const node = data[alias(tracked.indexOf(repository))];
|
|
161
|
+
return node && node.isPrivate === false ? node : undefined;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const releases: Record<string, Release> = {};
|
|
165
|
+
for (const repository of request.releases) {
|
|
166
|
+
const release = publicRepository(repository)?.latestRelease;
|
|
167
|
+
if (release) releases[repository] = { tag: release.tagName, publishedAt: release.publishedAt, url: release.url };
|
|
168
|
+
}
|
|
169
|
+
const stars: Record<string, number> = {};
|
|
170
|
+
for (const repository of request.stars) {
|
|
171
|
+
const node = publicRepository(repository);
|
|
172
|
+
if (node) stars[repository] = node.stargazerCount;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const repositories: GitHubSnapshot['repositories'] = {};
|
|
176
|
+
for (const repository of request.repositories) {
|
|
177
|
+
// Never keep even an older snapshot for a repository that is not public now.
|
|
178
|
+
if (!publicRepository(repository)) continue;
|
|
179
|
+
const kept = previous?.repositories[repository];
|
|
180
|
+
if (kept) repositories[repository] = kept;
|
|
181
|
+
try {
|
|
182
|
+
const standing = standingAmongHumans(await contributors(repository, token), request.login);
|
|
183
|
+
const prs = await searchCount(`author:${request.login} repo:${repository} is:public is:pr is:merged`, token, delay);
|
|
184
|
+
repositories[repository] = { ...standing, prs };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
warn(`${repository}: ${error instanceof Error ? error.message : String(error)} (keeping previous values)`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const byOrg: Record<string, number> = {};
|
|
191
|
+
const fresh: FreshEvidence = {};
|
|
192
|
+
for (const org of new Set([...request.orgs, cratisOrganization])) {
|
|
193
|
+
try {
|
|
194
|
+
byOrg[org] = await searchCount(mergedPullRequestQuery(request.login, [org]), token, delay);
|
|
195
|
+
if (org === cratisOrganization && data.user.login.toLowerCase() === request.login.toLowerCase()) fresh.cratisMergedPullRequests = byOrg[org];
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// The previous count is kept for display only; it never becomes evidence.
|
|
198
|
+
const kept = previous?.mergedPullRequests.byOrg[org];
|
|
199
|
+
if (kept !== undefined) byOrg[org] = kept;
|
|
200
|
+
warn(`${org} merged pull requests: ${error instanceof Error ? error.message : String(error)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const user = data.user;
|
|
205
|
+
const calendar = user.contributionsCollection.contributionCalendar;
|
|
206
|
+
const snapshot: GitHubSnapshot = {
|
|
207
|
+
fetchedAt: new Date().toISOString(),
|
|
208
|
+
login: user.login,
|
|
209
|
+
orgs: request.orgs,
|
|
210
|
+
profile: {
|
|
211
|
+
login: user.login,
|
|
212
|
+
name: user.name ?? undefined,
|
|
213
|
+
bio: user.bio ?? undefined,
|
|
214
|
+
location: user.location ?? undefined,
|
|
215
|
+
blog: user.websiteUrl ?? undefined,
|
|
216
|
+
avatarUrl: user.avatarUrl ?? undefined,
|
|
217
|
+
followers: user.followers.totalCount,
|
|
218
|
+
publicRepositories: user.repositories.totalCount,
|
|
219
|
+
},
|
|
220
|
+
contributions: {
|
|
221
|
+
total: calendar.totalContributions,
|
|
222
|
+
commits: user.contributionsCollection.totalCommitContributions,
|
|
223
|
+
weeks: parseCalendar(calendar.weeks),
|
|
224
|
+
},
|
|
225
|
+
mergedPullRequests: {
|
|
226
|
+
total: data.merged.issueCount,
|
|
227
|
+
byOrg,
|
|
228
|
+
latest: parseMergedPullRequests(data.merged.nodes, request.feedLimit ?? 12),
|
|
229
|
+
},
|
|
230
|
+
repositories,
|
|
231
|
+
releases,
|
|
232
|
+
stars,
|
|
233
|
+
};
|
|
234
|
+
return { snapshot, fresh };
|
|
235
|
+
}
|
|
@@ -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
|
+
import { z } from 'zod';
|
|
5
|
+
import { isCalendarDate, isInstant } from '../config/calendar';
|
|
6
|
+
|
|
7
|
+
/** A whole, finite, non-negative number. */
|
|
8
|
+
const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
9
|
+
const repository = z.string().max(200).regex(/^[\w.-]+\/[\w.-]+$/, 'Use owner/name');
|
|
10
|
+
const maximumEntries = 500;
|
|
11
|
+
const fewEntries = (value: object) => Object.keys(value).length <= maximumEntries;
|
|
12
|
+
const tooMany = `At most ${maximumEntries} entries`;
|
|
13
|
+
const instant = z.string().max(40).refine(isInstant, 'Use an ISO 8601 date and time on a real day');
|
|
14
|
+
const https = z.url({ protocol: /^https$/ });
|
|
15
|
+
|
|
16
|
+
/** A merged pull request; the URL must be the pull request on github.com. */
|
|
17
|
+
export const pullRequestSchema = z.object({
|
|
18
|
+
title: z.string().min(1).max(300),
|
|
19
|
+
repository,
|
|
20
|
+
number: z.number().int().positive(),
|
|
21
|
+
url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
|
|
22
|
+
mergedAt: instant,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const contributionWeekSchema = z.object({
|
|
26
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD').refine(isCalendarDate, 'Use a real day: YYYY-MM-DD'),
|
|
27
|
+
counts: z.array(count).max(7),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Public GitHub data a profile renders: the build-time snapshot a Cast site captures, as it appears in cast.json. */
|
|
31
|
+
export const gitHubSnapshotSchema = z.object({
|
|
32
|
+
fetchedAt: instant.optional(),
|
|
33
|
+
login: z.string().min(1).max(39).describe('GitHub login the data was captured for. Must be the profile handle.'),
|
|
34
|
+
orgs: z.array(z.string().min(1).max(100)).max(20).default([]),
|
|
35
|
+
profile: z.object({
|
|
36
|
+
login: z.string().min(1).max(39),
|
|
37
|
+
name: z.string().max(200).optional(),
|
|
38
|
+
bio: z.string().max(500).optional(),
|
|
39
|
+
location: z.string().max(200).optional(),
|
|
40
|
+
blog: z.string().max(500).optional(),
|
|
41
|
+
avatarUrl: https.optional(),
|
|
42
|
+
followers: count.optional(),
|
|
43
|
+
publicRepositories: count.optional(),
|
|
44
|
+
}).optional(),
|
|
45
|
+
contributions: z.object({
|
|
46
|
+
total: count,
|
|
47
|
+
commits: count.optional(),
|
|
48
|
+
weeks: z.array(contributionWeekSchema).max(60).default([]),
|
|
49
|
+
}).default({ total: 0, weeks: [] }).describe('The contribution calendar of the last year, one entry per week starting on Sunday.'),
|
|
50
|
+
mergedPullRequests: z.object({
|
|
51
|
+
total: count,
|
|
52
|
+
byOrg: z.record(z.string().max(100), count).refine(fewEntries, tooMany).default({}),
|
|
53
|
+
latest: z.array(pullRequestSchema).max(30).default([]),
|
|
54
|
+
}).default({ total: 0, byOrg: {}, latest: [] }),
|
|
55
|
+
repositories: z.record(repository, z.object({ commits: count, rank: count, prs: count })).refine(fewEntries, tooMany).default({}).describe('Standing in each tracked repository, by owner/name.'),
|
|
56
|
+
releases: z.record(repository, z.object({ tag: z.string().min(1).max(100), publishedAt: instant, url: https })).refine(fewEntries, tooMany).default({}),
|
|
57
|
+
stars: z.record(repository, count).refine(fewEntries, tooMany).default({}),
|
|
58
|
+
});
|
package/github/parse.ts
ADDED
|
@@ -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
|
+
import type { ContributionWeek, PullRequest } from './GitHubSnapshot';
|
|
5
|
+
|
|
6
|
+
/** A contributor as the REST contributors endpoint returns it. */
|
|
7
|
+
export type ContributorEntry = { login: string; type?: string; contributions: number };
|
|
8
|
+
|
|
9
|
+
/** A pull request node as the GraphQL search returns it. */
|
|
10
|
+
export type PullRequestNode = {
|
|
11
|
+
title?: string;
|
|
12
|
+
number?: number;
|
|
13
|
+
url?: string;
|
|
14
|
+
mergedAt?: string | null;
|
|
15
|
+
repository?: { nameWithOwner?: string; name?: string; owner?: { login?: string }; isPrivate?: boolean };
|
|
16
|
+
} | null;
|
|
17
|
+
|
|
18
|
+
const botLogins = new Set(['cratis-bot', 'dependabot', 'github-actions', 'renovate', 'copilot', 'copilot-swe-agent']);
|
|
19
|
+
|
|
20
|
+
/** True for bots: GitHub's Bot type, `[bot]` and `-bot` logins, and well-known automation accounts. */
|
|
21
|
+
export function isBot(entry: { login: string; type?: string }): boolean {
|
|
22
|
+
const login = entry.login.toLowerCase();
|
|
23
|
+
return entry.type === 'Bot' || login.endsWith('[bot]') || login.endsWith('-bot') || botLogins.has(login);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Commits and 1-based rank of `login` among human contributors; rank 0 when absent. */
|
|
27
|
+
export function standingAmongHumans(contributors: readonly ContributorEntry[], login: string): { commits: number; rank: number } {
|
|
28
|
+
const humans = contributors.filter(contributor => !isBot(contributor)).sort((left, right) => right.contributions - left.contributions);
|
|
29
|
+
const index = humans.findIndex(contributor => contributor.login.toLowerCase() === login.toLowerCase());
|
|
30
|
+
return index < 0 ? { commits: 0, rank: 0 } : { commits: humans[index]!.contributions, rank: index + 1 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Turns search nodes into the feed: merged, public only (the token may see private work, the site never
|
|
35
|
+
* shows it), newest first, capped at `limit`.
|
|
36
|
+
*/
|
|
37
|
+
export function parseMergedPullRequests(nodes: readonly PullRequestNode[], limit: number): PullRequest[] {
|
|
38
|
+
return nodes
|
|
39
|
+
.filter((node): node is NonNullable<PullRequestNode> => !!node?.mergedAt && node.repository?.isPrivate === false && !!node.url && node.number !== undefined)
|
|
40
|
+
.sort((left, right) => right.mergedAt!.localeCompare(left.mergedAt!))
|
|
41
|
+
.slice(0, limit)
|
|
42
|
+
.map(node => ({
|
|
43
|
+
title: node.title ?? '',
|
|
44
|
+
repository: node.repository!.nameWithOwner ?? `${node.repository!.owner?.login ?? ''}/${node.repository!.name ?? ''}`,
|
|
45
|
+
number: node.number!,
|
|
46
|
+
url: node.url!,
|
|
47
|
+
mergedAt: node.mergedAt!,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A contribution calendar as the GraphQL API returns it. */
|
|
52
|
+
export type CalendarWeekNode = { contributionDays: { date: string; contributionCount: number }[] };
|
|
53
|
+
|
|
54
|
+
/** Compacts the calendar into week starts and daily counts. */
|
|
55
|
+
export const parseCalendar = (weeks: readonly CalendarWeekNode[]): ContributionWeek[] =>
|
|
56
|
+
weeks.filter(week => week.contributionDays.length > 0).map(week => ({
|
|
57
|
+
start: week.contributionDays[0]!.date,
|
|
58
|
+
counts: week.contributionDays.map(day => day.contributionCount),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
/** The search query for merged public pull requests by `login` in any of `orgs`. */
|
|
62
|
+
export const mergedPullRequestQuery = (login: string, orgs: readonly string[]): string =>
|
|
63
|
+
`author:${login} ${orgs.map(org => `org:${org}`).join(' ')} is:public is:pr is:merged`;
|
package/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import sitemap from '@astrojs/sitemap';
|
|
8
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
9
|
+
import type { AstroIntegration } from 'astro';
|
|
10
|
+
import type { CastData } from './integration/CastData';
|
|
11
|
+
import type { CastOptions } from './integration/CastOptions';
|
|
12
|
+
import { resolveData } from './integration/resolveData';
|
|
13
|
+
import { castVirtualModule } from './integration/virtualModule';
|
|
14
|
+
|
|
15
|
+
export type { CastOptions } from './integration/CastOptions';
|
|
16
|
+
export type { CastData } from './integration/CastData';
|
|
17
|
+
export * from './config/index';
|
|
18
|
+
export * from './badges/index';
|
|
19
|
+
|
|
20
|
+
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8')) as { version: string };
|
|
21
|
+
|
|
22
|
+
/** The pages and files every Cast site serves. */
|
|
23
|
+
const routes: [pattern: string, entrypoint: string][] = [
|
|
24
|
+
['/', 'index.astro'],
|
|
25
|
+
['/cv', 'cv.astro'],
|
|
26
|
+
['/404', '404.astro'],
|
|
27
|
+
['/cast.json', 'cast.json.ts'],
|
|
28
|
+
['/resume.json', 'resume.json.ts'],
|
|
29
|
+
['/llms.txt', 'llms.txt.ts'],
|
|
30
|
+
['/badge.svg', 'badge.svg.ts'],
|
|
31
|
+
['/badge-card.svg', 'badge-card.svg.ts'],
|
|
32
|
+
['/og.png', 'og.png.ts'],
|
|
33
|
+
['/favicon.svg', 'favicon.svg.ts'],
|
|
34
|
+
['/apple-touch-icon.png', 'apple-touch-icon.png.ts'],
|
|
35
|
+
['/robots.txt', 'robots.txt.ts'],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The Cast integration. Reads cast.yaml, captures public GitHub and writing data at build time, verifies the
|
|
40
|
+
* tier against the Cast registry and injects every page. Tailwind, fonts and the sitemap are configured here,
|
|
41
|
+
* so a site needs nothing but `integrations: [cast()]`.
|
|
42
|
+
*/
|
|
43
|
+
export default function cast(options: CastOptions = {}): AstroIntegration {
|
|
44
|
+
let data: CastData | undefined;
|
|
45
|
+
let photoFile: string | undefined;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: '@cratis/cast',
|
|
49
|
+
hooks: {
|
|
50
|
+
'astro:config:setup': async ({ config, command, updateConfig, injectRoute, addWatchFile, logger }) => {
|
|
51
|
+
const root = fileURLToPath(config.root);
|
|
52
|
+
const configFile = path.resolve(root, options.config ?? 'cast.yaml');
|
|
53
|
+
const snapshotFile = path.resolve(root, options.snapshot ?? '.cast/snapshot.json');
|
|
54
|
+
const fetchMode = options.fetch ?? 'build';
|
|
55
|
+
addWatchFile(configFile);
|
|
56
|
+
|
|
57
|
+
const resolved = await resolveData({
|
|
58
|
+
configFile,
|
|
59
|
+
snapshotFile,
|
|
60
|
+
fetch: fetchMode === 'always' || (fetchMode === 'build' && command === 'build'),
|
|
61
|
+
castVersion: packageJson.version,
|
|
62
|
+
log: logger,
|
|
63
|
+
});
|
|
64
|
+
data = resolved.data;
|
|
65
|
+
photoFile = resolved.photoFile;
|
|
66
|
+
const site = data.config.site.url;
|
|
67
|
+
logger.info(`${data.config.person.name} (@${data.config.person.handle}): tier ${data.tier.tier ?? 'none'} from ${data.tier.source}`);
|
|
68
|
+
|
|
69
|
+
for (const [pattern, entrypoint] of routes) {
|
|
70
|
+
injectRoute({ pattern, entrypoint: `@cratis/cast/routes/${entrypoint}`, prerender: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
updateConfig({
|
|
74
|
+
site: config.site ?? site,
|
|
75
|
+
trailingSlash: 'ignore',
|
|
76
|
+
redirects: { '/resume': '/cv', ...config.redirects },
|
|
77
|
+
integrations: [sitemap({ filter: page => !page.endsWith('/404/') && !page.endsWith('/404') })],
|
|
78
|
+
vite: {
|
|
79
|
+
plugins: [
|
|
80
|
+
tailwindcss(),
|
|
81
|
+
castVirtualModule(() => data!, () => photoFile),
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
} as Parameters<typeof updateConfig>[0]);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 { ResolvedTier } from '../badges/ResolvedTier';
|
|
5
|
+
import type { CastConfig } from '../config/CastConfig';
|
|
6
|
+
import type { ResolvedSection } from '../config/ResolvedSection';
|
|
7
|
+
import type { GitHubSnapshot } from '../github/GitHubSnapshot';
|
|
8
|
+
import type { WritingSnapshot } from '../writing/WritingSnapshot';
|
|
9
|
+
|
|
10
|
+
/** Everything the site renders from, resolved once per build and served through `virtual:cast`. */
|
|
11
|
+
export type CastData = {
|
|
12
|
+
config: CastConfig;
|
|
13
|
+
sections: ResolvedSection[];
|
|
14
|
+
github: GitHubSnapshot;
|
|
15
|
+
writing: WritingSnapshot;
|
|
16
|
+
tier: ResolvedTier;
|
|
17
|
+
/** Remote portrait URL, when `person.photo` is an https URL. */
|
|
18
|
+
photoUrl?: string;
|
|
19
|
+
build: { builtAt: string; castVersion: string; githubSource: 'live' | 'snapshot' | 'none'; registrySource: 'live' | 'bundled' };
|
|
20
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/** Options for the `cast()` integration. */
|
|
5
|
+
export type CastOptions = {
|
|
6
|
+
/** Path to cast.yaml, relative to the project root. Defaults to `./cast.yaml`. */
|
|
7
|
+
config?: string;
|
|
8
|
+
|
|
9
|
+
/** Where the last good build-time data is kept, relative to the project root. Defaults to `./.cast/snapshot.json`. */
|
|
10
|
+
snapshot?: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* When to fetch GitHub, writing and registry data. `build` (the default) fetches on `astro build` only;
|
|
14
|
+
* `always` also fetches when the dev server starts; `never` uses the snapshot only.
|
|
15
|
+
*/
|
|
16
|
+
fetch?: 'build' | 'always' | 'never';
|
|
17
|
+
};
|
|
@@ -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
|
+
import type { GitHubSnapshot } from '../github/GitHubSnapshot';
|
|
5
|
+
import type { WritingSnapshot } from '../writing/WritingSnapshot';
|
|
6
|
+
|
|
7
|
+
/** The file at `.cast/snapshot.json`: the last data that was captured successfully. */
|
|
8
|
+
export type Snapshot = {
|
|
9
|
+
version: 1;
|
|
10
|
+
github?: GitHubSnapshot;
|
|
11
|
+
writing?: WritingSnapshot;
|
|
12
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { readFile } from 'node:fs/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { registryUrl } from '../badges/constants';
|
|
7
|
+
import { parseRegistry } from '../badges/registry';
|
|
8
|
+
import type { RegistryMember } from '../badges/RegistryMember';
|
|
9
|
+
|
|
10
|
+
/** The registry copy bundled into the package when it is published. */
|
|
11
|
+
export const bundledRegistryFile = fileURLToPath(new URL('../registry/members.yaml', import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** Reads the bundled registry; an absent copy is an empty registry. */
|
|
14
|
+
export async function readBundledRegistry(): Promise<RegistryMember[]> {
|
|
15
|
+
try {
|
|
16
|
+
return parseRegistry(await readFile(bundledRegistryFile, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reads the reviewed registry from the Cast repository, falling back to the bundled copy. A registry that
|
|
24
|
+
* fails validation is never used.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadRegistry(fetchLive: boolean, warn: (message: string) => void): Promise<{ members: RegistryMember[]; source: 'live' | 'bundled' }> {
|
|
27
|
+
if (fetchLive) {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(registryUrl, { headers: { 'user-agent': 'cratis-cast-build' }, signal: AbortSignal.timeout(15_000) });
|
|
30
|
+
if (!response.ok) throw new Error(`answered ${response.status}`);
|
|
31
|
+
return { members: parseRegistry(await response.text()), source: 'live' };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
warn(`registry: ${error instanceof Error ? error.message : String(error)}; using the bundled copy`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { members: await readBundledRegistry(), source: 'bundled' };
|
|
37
|
+
}
|