@cratis/cast 0.0.0-stage → 0.1.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 +12 -2
- package/badges/RegistryMember.ts +16 -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 +63 -0
- package/badges/resolveTier.ts +24 -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/castSchema.ts +262 -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/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 +64 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +75 -4
- package/registry/members.yaml +16 -0
- package/routes/404.astro +40 -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 +11 -0
- package/routes/cv.astro +371 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +61 -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 +194 -0
- package/site/components/Agentic.astro +143 -0
- package/site/components/CareerReplay.astro +412 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +116 -0
- package/site/components/Contact.astro +163 -0
- package/site/components/Cratis.astro +233 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +262 -0
- package/site/components/Footer.astro +28 -0
- package/site/components/Hero.astro +208 -0
- package/site/components/Monogram.astro +25 -0
- package/site/components/Nav.astro +124 -0
- package/site/components/Now.astro +113 -0
- package/site/components/Projects.astro +188 -0
- package/site/components/PullRequestFeed.astro +86 -0
- package/site/components/SectionHeader.astro +27 -0
- package/site/components/Skills.astro +41 -0
- package/site/components/Stats.astro +101 -0
- package/site/components/Talks.astro +73 -0
- package/site/components/Terminal.astro +159 -0
- package/site/components/Writing.astro +126 -0
- package/site/data.ts +48 -0
- package/site/layouts/Base.astro +144 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +32 -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 +55 -0
- package/site/lib/inline.ts +23 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +38 -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/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 +145 -0
- package/site/scripts/palette.ts +296 -0
- package/site/scripts/replay.ts +251 -0
- package/site/scripts/terminal.ts +556 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +54 -0
- package/writing/parse.ts +72 -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
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { existsSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { cratisOrganization } from '../badges/constants';
|
|
8
|
+
import { resolveTier } from '../badges/resolveTier';
|
|
9
|
+
import type { RegistryMember } from '../badges/RegistryMember';
|
|
10
|
+
import type { ResolvedTier } from '../badges/ResolvedTier';
|
|
11
|
+
import type { GitHubSnapshot } from '../github/GitHubSnapshot';
|
|
12
|
+
import type { CastConfig } from '../config/CastConfig';
|
|
13
|
+
import { parseCast } from '../config/parseCast';
|
|
14
|
+
import { resolveSections } from '../config/resolveSections';
|
|
15
|
+
import { fetchGitHub, gitHubToken, type FreshEvidence } from '../github/fetchGitHub';
|
|
16
|
+
import { emptySnapshot } from '../github/GitHubSnapshot';
|
|
17
|
+
import { fetchWriting } from '../writing/fetchWriting';
|
|
18
|
+
import type { CastData } from './CastData';
|
|
19
|
+
import { loadRegistry } from './loadRegistry';
|
|
20
|
+
import { readSnapshot, writeSnapshot } from './snapshotFile';
|
|
21
|
+
|
|
22
|
+
/** Inputs for one resolution. */
|
|
23
|
+
export type ResolveDataOptions = {
|
|
24
|
+
configFile: string;
|
|
25
|
+
snapshotFile: string;
|
|
26
|
+
fetch: boolean;
|
|
27
|
+
castVersion: string;
|
|
28
|
+
log: { info(message: string): void; warn(message: string): void };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Result of a resolution: the data plus the absolute path of a local portrait, if any. */
|
|
32
|
+
export type ResolvedCast = { data: CastData; photoFile?: string };
|
|
33
|
+
|
|
34
|
+
/** owner/name of every public Cratis repository the cratis section lists. */
|
|
35
|
+
export const trackedCratisRepositories = (config: CastConfig): string[] =>
|
|
36
|
+
(config.cratis?.repos ?? [])
|
|
37
|
+
.filter(repository => repository.url?.toLowerCase().startsWith(`https://github.com/${cratisOrganization.toLowerCase()}/`))
|
|
38
|
+
.map(repository => `${cratisOrganization}/${repository.name}`);
|
|
39
|
+
|
|
40
|
+
const sameLogin = (left: string, right: string): boolean => left.toLowerCase() === right.toLowerCase();
|
|
41
|
+
|
|
42
|
+
/** The snapshot's GitHub data, only when it was captured for this identity. */
|
|
43
|
+
export const usableSnapshot = (github: GitHubSnapshot | undefined, identity: string): GitHubSnapshot | undefined =>
|
|
44
|
+
github && typeof github.login === 'string' && sameLogin(github.login, identity) ? github : undefined;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The tier a site shows: a claim, linked to the directory, which is the authority. Core and Partner come from
|
|
48
|
+
* the registry by the GitHub login. Contributor comes only from `freshCratisMergedPullRequests`: the count a
|
|
49
|
+
* search returned in this build for that login. It is never read from a snapshot and never persisted.
|
|
50
|
+
*/
|
|
51
|
+
export function siteTier(identity: string, registry: readonly RegistryMember[], freshCratisMergedPullRequests: number | undefined): ResolvedTier {
|
|
52
|
+
return resolveTier(identity, registry, freshCratisMergedPullRequests);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reads cast.yaml, captures build-time data (never failing the build on network problems) and resolves the
|
|
57
|
+
* verified tier. Order of truth for live data: a fresh fetch, then `.cast/snapshot.json`, then cast.yaml.
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveData(options: ResolveDataOptions): Promise<ResolvedCast> {
|
|
60
|
+
const configText = await readFile(options.configFile, 'utf8');
|
|
61
|
+
const config = parseCast(configText, path.basename(options.configFile));
|
|
62
|
+
const warn = (message: string) => options.log.warn(`${message}`);
|
|
63
|
+
|
|
64
|
+
const identity = config.github.login;
|
|
65
|
+
const snapshot = await readSnapshot(options.snapshotFile);
|
|
66
|
+
const snapshotGitHub = usableSnapshot(snapshot.github, identity);
|
|
67
|
+
if (snapshot.github && !snapshotGitHub) {
|
|
68
|
+
warn(`.cast/snapshot.json holds GitHub data for '${snapshot.github.login}', not '${identity}'; ignoring it`);
|
|
69
|
+
}
|
|
70
|
+
let github = snapshotGitHub;
|
|
71
|
+
let githubSource: CastData['build']['githubSource'] = github ? 'snapshot' : 'none';
|
|
72
|
+
let writing = snapshot.writing;
|
|
73
|
+
let changed = false;
|
|
74
|
+
let freshEvidence: FreshEvidence = {};
|
|
75
|
+
|
|
76
|
+
if (options.fetch) {
|
|
77
|
+
const token = gitHubToken();
|
|
78
|
+
if (!token) {
|
|
79
|
+
warn('no GITHUB_TOKEN or GH_TOKEN in the environment; using the snapshot for GitHub data');
|
|
80
|
+
} else {
|
|
81
|
+
const result = await fetchGitHub({
|
|
82
|
+
login: config.github.login,
|
|
83
|
+
orgs: config.github.orgs,
|
|
84
|
+
repositories: trackedCratisRepositories(config),
|
|
85
|
+
releases: config.github.releases,
|
|
86
|
+
stars: config.github.stars,
|
|
87
|
+
}, token, github, warn);
|
|
88
|
+
const fresh = result?.snapshot;
|
|
89
|
+
if (fresh && !sameLogin(fresh.login, identity)) {
|
|
90
|
+
warn(`GitHub answered for '${fresh.login}', not '${identity}'; ignoring it`);
|
|
91
|
+
} else if (result && fresh) {
|
|
92
|
+
github = fresh;
|
|
93
|
+
freshEvidence = result.fresh;
|
|
94
|
+
githubSource = 'live';
|
|
95
|
+
changed = true;
|
|
96
|
+
options.log.info(`GitHub: ${fresh.contributions.total} contributions, ${fresh.mergedPullRequests.total} merged pull requests, ${Object.keys(fresh.repositories).length} repositories`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (config.writing.sources.length > 0) {
|
|
100
|
+
const freshWriting = await fetchWriting(config.writing.sources, warn);
|
|
101
|
+
if (freshWriting) {
|
|
102
|
+
writing = freshWriting;
|
|
103
|
+
changed = true;
|
|
104
|
+
options.log.info(`writing: ${freshWriting.posts.length} posts`);
|
|
105
|
+
} else {
|
|
106
|
+
warn('no posts captured; using the snapshot for writing');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (changed) {
|
|
112
|
+
await writeSnapshot(options.snapshotFile, { version: 1, github, writing });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const registry = await loadRegistry(options.fetch, warn);
|
|
116
|
+
const githubData = github ?? emptySnapshot(identity, config.github.orgs);
|
|
117
|
+
const tier = siteTier(identity, registry.members, freshEvidence.cratisMergedPullRequests);
|
|
118
|
+
|
|
119
|
+
const photo = config.person.photo;
|
|
120
|
+
const photoUrl = photo && /^https:\/\//.test(photo) ? photo : undefined;
|
|
121
|
+
const localPhoto = photo && !photoUrl ? path.resolve(path.dirname(options.configFile), photo) : undefined;
|
|
122
|
+
if (localPhoto && !existsSync(localPhoto)) {
|
|
123
|
+
warn(`person.photo: ${localPhoto} does not exist; the portrait is left out`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
data: {
|
|
128
|
+
config,
|
|
129
|
+
sections: resolveSections(config),
|
|
130
|
+
github: githubData,
|
|
131
|
+
writing: writing ?? { sources: [], posts: [] },
|
|
132
|
+
tier,
|
|
133
|
+
photoUrl: photoUrl ?? (localPhoto ? undefined : githubData.profile?.avatarUrl),
|
|
134
|
+
build: { builtAt: new Date().toISOString(), castVersion: options.castVersion, githubSource, registrySource: registry.source },
|
|
135
|
+
},
|
|
136
|
+
photoFile: localPhoto && existsSync(localPhoto) ? localPhoto : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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 { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { Snapshot } from './Snapshot';
|
|
7
|
+
|
|
8
|
+
/** Reads the snapshot; a missing or unreadable file is an empty snapshot. */
|
|
9
|
+
export async function readSnapshot(file: string): Promise<Snapshot> {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(await readFile(file, 'utf8')) as Snapshot;
|
|
12
|
+
return parsed && typeof parsed === 'object' ? { ...parsed, version: 1 } : { version: 1 };
|
|
13
|
+
} catch {
|
|
14
|
+
return { version: 1 };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Writes the snapshot with per-week count arrays on one line so diffs stay readable. */
|
|
19
|
+
export async function writeSnapshot(file: string, snapshot: Snapshot): Promise<void> {
|
|
20
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
21
|
+
const json = JSON.stringify(snapshot, null, 2).replace(/\[\s*(\d[\d,\s]*?)\s*\]/g, (_, numbers: string) => `[${numbers.split(/[\s,]+/).join(', ')}]`);
|
|
22
|
+
await writeFile(file, `${json}\n`);
|
|
23
|
+
}
|