@cratis/cast 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +13 -1
  2. package/badges/RegistryMember.ts +18 -0
  3. package/badges/ResolvedTier.ts +17 -0
  4. package/badges/Tier.ts +14 -0
  5. package/badges/TierSource.ts +14 -0
  6. package/badges/badgeSvg.ts +137 -0
  7. package/badges/constants.ts +17 -0
  8. package/badges/cratisMark.ts +15 -0
  9. package/badges/index.ts +12 -0
  10. package/badges/registry.ts +68 -0
  11. package/badges/resolveTier.ts +24 -0
  12. package/cast-json.schema.json +1496 -0
  13. package/cast.schema.json +1113 -0
  14. package/config/CastConfig.ts +17 -0
  15. package/config/CastConfigError.ts +10 -0
  16. package/config/LiveStat.ts +17 -0
  17. package/config/ResolvedSection.ts +13 -0
  18. package/config/SectionId.ts +21 -0
  19. package/config/ThemeMode.ts +9 -0
  20. package/config/calendar.ts +34 -0
  21. package/config/castSchema.ts +263 -0
  22. package/config/index.ts +12 -0
  23. package/config/parseCast.ts +58 -0
  24. package/config/resolveSections.ts +48 -0
  25. package/github/GitHubSnapshot.ts +66 -0
  26. package/github/fetchGitHub.ts +235 -0
  27. package/github/gitHubSnapshotSchema.ts +58 -0
  28. package/github/parse.ts +63 -0
  29. package/index.ts +88 -0
  30. package/integration/CastData.ts +20 -0
  31. package/integration/CastOptions.ts +17 -0
  32. package/integration/Snapshot.ts +12 -0
  33. package/integration/loadRegistry.ts +37 -0
  34. package/integration/resolveData.ts +138 -0
  35. package/integration/snapshotFile.ts +23 -0
  36. package/integration/virtualModule.ts +43 -0
  37. package/machine/castJson.ts +77 -0
  38. package/machine/castJsonSchema.ts +35 -0
  39. package/machine/index.ts +6 -0
  40. package/machine/llmsText.ts +69 -0
  41. package/machine/ogImageSvg.ts +79 -0
  42. package/machine/profileFromCastJson.ts +214 -0
  43. package/machine/resumeJson.ts +87 -0
  44. package/package.json +80 -8
  45. package/registry/members.yaml +16 -0
  46. package/routes/404.astro +42 -0
  47. package/routes/apple-touch-icon.png.ts +17 -0
  48. package/routes/badge-card.svg.ts +13 -0
  49. package/routes/badge.svg.ts +11 -0
  50. package/routes/cast.json.ts +17 -0
  51. package/routes/cv.astro +9 -0
  52. package/routes/favicon.svg.ts +16 -0
  53. package/routes/index.astro +9 -0
  54. package/routes/llms.txt.ts +10 -0
  55. package/routes/og.png.ts +23 -0
  56. package/routes/resume.json.ts +13 -0
  57. package/routes/robots.txt.ts +10 -0
  58. package/site/components/Activity.astro +196 -0
  59. package/site/components/Agentic.astro +144 -0
  60. package/site/components/CareerReplay.astro +415 -0
  61. package/site/components/CastBadge.astro +132 -0
  62. package/site/components/CommandPalette.astro +122 -0
  63. package/site/components/Contact.astro +166 -0
  64. package/site/components/Cratis.astro +235 -0
  65. package/site/components/CratisMark.astro +18 -0
  66. package/site/components/EventModel.astro +265 -0
  67. package/site/components/Footer.astro +33 -0
  68. package/site/components/Hero.astro +211 -0
  69. package/site/components/Inline.astro +45 -0
  70. package/site/components/Monogram.astro +28 -0
  71. package/site/components/Nav.astro +127 -0
  72. package/site/components/Now.astro +116 -0
  73. package/site/components/Projects.astro +185 -0
  74. package/site/components/PullRequestFeed.astro +88 -0
  75. package/site/components/SectionHeader.astro +30 -0
  76. package/site/components/Skills.astro +44 -0
  77. package/site/components/Stats.astro +106 -0
  78. package/site/components/Talks.astro +76 -0
  79. package/site/components/Terminal.astro +164 -0
  80. package/site/components/Writing.astro +128 -0
  81. package/site/data.ts +11 -0
  82. package/site/layouts/Base.astro +156 -0
  83. package/site/lib/accent.ts +52 -0
  84. package/site/lib/blog.ts +58 -0
  85. package/site/lib/career.ts +148 -0
  86. package/site/lib/clipboard.ts +12 -0
  87. package/site/lib/contributions.ts +63 -0
  88. package/site/lib/github.ts +72 -0
  89. package/site/lib/inline.ts +79 -0
  90. package/site/lib/lifecycle.ts +44 -0
  91. package/site/lib/motion.ts +43 -0
  92. package/site/lib/nav.ts +49 -0
  93. package/site/lib/reltime.ts +33 -0
  94. package/site/lib/safeJson.ts +15 -0
  95. package/site/lib/sections.ts +41 -0
  96. package/site/lib/theme.ts +28 -0
  97. package/site/pages/CvPage.astro +379 -0
  98. package/site/pages/ProfilePage.astro +78 -0
  99. package/site/profile/CastHost.ts +32 -0
  100. package/site/profile/CastView.ts +96 -0
  101. package/site/profile/assertRenderable.ts +151 -0
  102. package/site/profile/clientCast.ts +117 -0
  103. package/site/profile/index.ts +8 -0
  104. package/site/scripts/ambient.ts +93 -0
  105. package/site/scripts/event-board.ts +403 -0
  106. package/site/scripts/event-model.ts +155 -0
  107. package/site/scripts/heatmap.ts +94 -0
  108. package/site/scripts/live.ts +155 -0
  109. package/site/scripts/palette.ts +313 -0
  110. package/site/scripts/replay.ts +252 -0
  111. package/site/scripts/terminal.ts +559 -0
  112. package/site/styles/global.css +508 -0
  113. package/virtual.d.ts +15 -0
  114. package/writing/WritingSnapshot.ts +20 -0
  115. package/writing/fetchWriting.ts +69 -0
  116. package/writing/parse.ts +72 -0
  117. package/writing/writingSnapshotSchema.ts +27 -0
@@ -0,0 +1,214 @@
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 { z } from 'zod';
5
+ import { TierSource } from '../badges/TierSource';
6
+ import type { CastConfig } from '../config/CastConfig';
7
+ import { CastConfigError } from '../config/CastConfigError';
8
+ import { parseCastObject } from '../config/parseCast';
9
+ import { resolveSections } from '../config/resolveSections';
10
+ import { emptySnapshot, type GitHubSnapshot } from '../github/GitHubSnapshot';
11
+ import { assertRenderable } from '../site/profile/assertRenderable';
12
+ import { directoryHost } from '../site/profile/CastHost';
13
+ import { castView } from '../site/profile/CastView';
14
+ import type { WritingSnapshot } from '../writing/WritingSnapshot';
15
+ import { castJsonSchema, castJsonVersion } from './castJsonSchema';
16
+
17
+ /** The largest cast.json the directory reads, in bytes. */
18
+ export const maximumCastJsonBytes = 1_000_000;
19
+
20
+ /** A profile read from a cast.json, ready to render. It never carries a tier: tiers come from the directory. */
21
+ export type CastJsonProfile = {
22
+ handle: string;
23
+ generator?: string;
24
+ builtAt?: string;
25
+ config: CastConfig;
26
+ /** Present only when it was captured for the profile handle. */
27
+ github?: GitHubSnapshot;
28
+ writing?: WritingSnapshot;
29
+ /** The portrait, when `person.photo` is an https URL. */
30
+ photoUrl?: string;
31
+ };
32
+
33
+ /** The outcome of reading a cast.json: the profile and what was dropped from it, or why it was refused. */
34
+ export type CastJsonResult =
35
+ | { profile: CastJsonProfile; dropped: string[] }
36
+ | { problems: string[] };
37
+
38
+ type Path = PropertyKey[];
39
+ type Container = Record<PropertyKey, unknown> | unknown[];
40
+
41
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ const sameLogin = (left: string, right: string) => left.toLowerCase() === right.toLowerCase();
43
+ const describe = (path: Path) => path.map(String).join('.');
44
+
45
+ function at(root: unknown, path: Path): unknown {
46
+ return path.reduce<unknown>((value, key) => (value && typeof value === 'object' ? (value as Record<PropertyKey, unknown>)[key] : undefined), root);
47
+ }
48
+
49
+ function remove(root: unknown, path: Path): void {
50
+ const parent = at(root, path.slice(0, -1)) as Container | undefined;
51
+ const key = path[path.length - 1]!;
52
+ if (Array.isArray(parent) && typeof key === 'number') parent.splice(key, 1);
53
+ else if (parent && typeof parent === 'object') delete (parent as Record<PropertyKey, unknown>)[key];
54
+ }
55
+
56
+ /** A link or address that failed validation (javascript:, data:, relative and so on): dropped, not fatal. */
57
+ const isBadLink = (issue: z.core.$ZodIssue): boolean =>
58
+ issue.code === 'invalid_format' && (issue.format === 'url' || issue.format === 'email');
59
+
60
+ /** How large any one part of a cast.json may be. The file itself is at most {@link maximumCastJsonBytes}. */
61
+ const limits = { depth: 24, string: 20_000, items: 2_000 };
62
+
63
+ /** Where the document exceeds {@link limits}, if anywhere. */
64
+ function oversized(value: unknown, path: Path = []): string | undefined {
65
+ if (path.length > limits.depth) return `${describe(path)}: nested deeper than ${limits.depth} levels`;
66
+ if (typeof value === 'string') return value.length > limits.string ? `${describe(path)}: longer than ${limits.string} characters` : undefined;
67
+ if (!value || typeof value !== 'object') return undefined;
68
+ const entries = Array.isArray(value) ? value.map((item, index) => [index, item] as const) : Object.entries(value);
69
+ if (entries.length > limits.items) return `${describe(path) || '(root)'}: more than ${limits.items} entries`;
70
+ for (const [key, item] of entries) {
71
+ const problem = (typeof key === 'string' && key.length > limits.string ? `${describe(path)}: a key longer than ${limits.string} characters` : undefined) ?? oversized(item, [...path, key]);
72
+ if (problem) return problem;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /** A portrait the directory can show: an absolute https URL that parses. */
78
+ function isHttpsUrl(value: unknown): value is string {
79
+ if (typeof value !== 'string' || value.length > 2_000 || !URL.canParse(value)) return false;
80
+ return new URL(value).protocol === 'https:';
81
+ }
82
+
83
+ /** The optional build-time captures: when one does not validate, it is dropped and the directory uses its own data. */
84
+ const captures: Path[] = [['profile', 'github'], ['profile', 'writing']];
85
+ const captureOf = (path: Path): Path | undefined => captures.find(capture => capture.every((key, index) => path[index] === key));
86
+
87
+ /** The path of the array element closest to `path`, when there is one. */
88
+ function enclosingElement(path: Path): Path | undefined {
89
+ for (let index = path.length - 1; index > 0; index--) {
90
+ if (typeof path[index] === 'number') return path.slice(0, index + 1);
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * Validates a cast.json document (format version 2) for rendering by someone other than its owner. Every
97
+ * link must be http(s) and every address an email: other links are dropped, and an array item whose
98
+ * required link was dropped goes with it. The identity must equal `expectedLogin` when given, case-insensitively.
99
+ * A tier anywhere in the file is ignored. A GitHub or writing capture that does not validate, or that the profile
100
+ * pages cannot render, is dropped. The rest is checked the way the profile pages use it (real dates, URLs that
101
+ * parse, a career that replays); unknown versions, oversized parts and anything else that does not validate or
102
+ * render are refused, so one member's file can never stop the directory build.
103
+ */
104
+ export function profileFromCastJson(json: unknown, expectedLogin?: string): CastJsonResult {
105
+ if (!isObject(json)) return { problems: ['cast.json is not a JSON object'] };
106
+ if (json['castJsonVersion'] !== castJsonVersion) return { problems: [`castJsonVersion ${JSON.stringify(json['castJsonVersion'])} is not supported; expected ${castJsonVersion}`] };
107
+
108
+ const tooLarge = oversized(json);
109
+ if (tooLarge) return { problems: [tooLarge] };
110
+
111
+ const document = JSON.parse(JSON.stringify(json)) as Record<string, unknown>;
112
+ const dropped: string[] = [];
113
+ const cast = at(document, ['profile', 'cast']);
114
+ if (isObject(cast)) {
115
+ // A tier is never read from a profile; the directory decides it.
116
+ if ('tier' in cast) {
117
+ delete cast['tier'];
118
+ dropped.push('profile.cast.tier');
119
+ }
120
+ const person = cast['person'];
121
+ if (isObject(person) && person['photo'] !== undefined && !isHttpsUrl(person['photo'])) {
122
+ delete person['photo'];
123
+ dropped.push('profile.cast.person.photo');
124
+ }
125
+ }
126
+
127
+ const removed = new Set<string>();
128
+ for (let attempt = 0; attempt < 200; attempt++) {
129
+ const result = castJsonSchema.safeParse(document);
130
+ if (result.success) return finish(result.data, document, expectedLogin, dropped);
131
+
132
+ const issue = result.error.issues.find(candidate => isBadLink(candidate) || removed.has(describe(candidate.path)));
133
+ if (!issue) {
134
+ const capture = result.error.issues.map(candidate => captureOf(candidate.path)).find(Boolean);
135
+ if (!capture) return { problems: result.error.issues.map(problem => `${describe(problem.path) || '(root)'}: ${problem.message}`) };
136
+ remove(document, capture);
137
+ dropped.push(describe(capture));
138
+ removed.clear();
139
+ continue;
140
+ }
141
+ const path = issue.path;
142
+ if (isBadLink(issue) && !removed.has(describe(path))) {
143
+ remove(document, path);
144
+ removed.add(describe(path));
145
+ dropped.push(describe(path));
146
+ continue;
147
+ }
148
+ // A required link was dropped: drop the item that needs it.
149
+ const element = enclosingElement(path);
150
+ if (!element) return { problems: [`${describe(path)}: required, and its value was not a valid link`] };
151
+ remove(document, element);
152
+ dropped.push(describe(element));
153
+ removed.clear();
154
+ }
155
+ return { problems: ['cast.json has too many invalid links'] };
156
+ }
157
+
158
+ function finish(data: z.output<typeof castJsonSchema>, document: Record<string, unknown>, expectedLogin: string | undefined, dropped: string[]): CastJsonResult {
159
+ if (expectedLogin && !sameLogin(data.handle, expectedLogin)) return { problems: [`handle '${data.handle}' is not '${expectedLogin}'`] };
160
+
161
+ let config: CastConfig;
162
+ try {
163
+ // Parsed from the document as written, so defaults apply the same way as for cast.yaml.
164
+ config = parseCastObject(at(document, ['profile', 'cast']), 'cast.json profile.cast');
165
+ } catch (error) {
166
+ return { problems: error instanceof CastConfigError ? error.problems : [String(error)] };
167
+ }
168
+ if (!sameLogin(config.person.handle, data.handle)) return { problems: [`profile.cast.person.handle '${config.person.handle}' is not the handle '${data.handle}'`] };
169
+
170
+ let github: GitHubSnapshot | undefined = data.profile.github as GitHubSnapshot | undefined;
171
+ if (github && !sameLogin(github.login, data.handle)) {
172
+ dropped.push('profile.github');
173
+ github = undefined;
174
+ }
175
+ const photo = config.person.photo;
176
+ const profile: CastJsonProfile = {
177
+ handle: data.handle,
178
+ ...(data.generator ? { generator: data.generator } : {}),
179
+ ...(data.builtAt ? { builtAt: data.builtAt } : {}),
180
+ config,
181
+ ...(github ? { github } : {}),
182
+ ...(data.profile.writing ? { writing: data.profile.writing as WritingSnapshot } : {}),
183
+ ...(photo ? { photoUrl: photo } : {}),
184
+ };
185
+
186
+ // Rendered without the captures when only they cannot be: the directory then uses its own GitHub data.
187
+ const problem = renderProblem(profile);
188
+ if (!problem) return { dropped, profile };
189
+ const { github: _github, writing: _writing, ...withoutCaptures } = profile;
190
+ if ((profile.github || profile.writing) && !renderProblem(withoutCaptures)) {
191
+ dropped.push(...(profile.github ? ['profile.github'] : []), ...(profile.writing ? ['profile.writing'] : []));
192
+ return { dropped, profile: withoutCaptures };
193
+ }
194
+ return { problems: [problem] };
195
+ }
196
+
197
+ /** Why the profile pages could not render this profile, when they could not. */
198
+ function renderProblem(profile: CastJsonProfile): string | undefined {
199
+ const { config, handle } = profile;
200
+ try {
201
+ assertRenderable(castView({
202
+ config,
203
+ sections: resolveSections(config),
204
+ github: profile.github ?? emptySnapshot(handle, config.github.orgs),
205
+ writing: profile.writing ?? { sources: [], posts: [] },
206
+ tier: { handle, source: TierSource.None },
207
+ ...(profile.photoUrl ? { photoUrl: profile.photoUrl } : {}),
208
+ build: { builtAt: profile.builtAt ?? new Date(0).toISOString(), castVersion: profile.generator ?? 'cast.json', githubSource: 'none', registrySource: 'live' },
209
+ }, directoryHost(handle, config.site.url)));
210
+ return undefined;
211
+ } catch (error) {
212
+ return `does not render: ${error instanceof Error ? error.message : String(error)}`;
213
+ }
214
+ }
@@ -0,0 +1,87 @@
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 { CastConfig } from '../config/CastConfig';
5
+ import { project } from '../site/lib/career';
6
+ import { plainText } from '../site/lib/inline';
7
+
8
+ const network: Record<string, string> = { github: 'GitHub', linkedin: 'LinkedIn', mastodon: 'Mastodon', bluesky: 'Bluesky', x: 'X', twitter: 'X', youtube: 'YouTube' };
9
+
10
+ /** 'YYYY-MM' stays; 'YYYY' stays: both are valid JSON Resume ISO 8601 partial dates. */
11
+ const isoDate = (date: string): string => date;
12
+
13
+ const username = (url: string): string => url.replace(/\/+$/, '').split('/').pop() ?? '';
14
+
15
+ /** Splits "Norwegian (native)" into language and fluency. */
16
+ const language = (entry: string) => {
17
+ const match = entry.match(/^(.*?)\s*\((.+)\)\s*$/);
18
+ return match ? { language: match[1]!, fluency: match[2]! } : { language: entry };
19
+ };
20
+
21
+ /** Maps cast.yaml to a JSON Resume document (https://jsonresume.org/schema). */
22
+ export function resumeJson(config: CastConfig, imageUrl?: string) {
23
+ const person = config.person;
24
+ const projection = project(config.career.events);
25
+ const volunteerTypes = new Set(['VolunteerTeamJoined']);
26
+ const [city] = (person.location ?? '').split(',').map(part => part.trim());
27
+
28
+ return {
29
+ $schema: 'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
30
+ basics: {
31
+ name: person.fullName ?? person.name,
32
+ label: person.title,
33
+ ...(imageUrl ? { image: imageUrl } : {}),
34
+ ...(person.email ? { email: person.email } : {}),
35
+ url: config.site.url,
36
+ summary: plainText(person.about.length ? person.about.join('\n\n') : person.intro),
37
+ ...(person.location ? { location: { city, ...(person.country ? { countryCode: person.country } : {}) } } : {}),
38
+ profiles: [
39
+ { network: 'GitHub', username: config.github.login, url: `https://github.com/${config.github.login}` },
40
+ ...Object.entries(person.links)
41
+ .filter(([key]) => key !== 'github')
42
+ .map(([key, url]) => ({ network: network[key.toLowerCase()] ?? key, username: username(url), url })),
43
+ ],
44
+ },
45
+ work: [...projection.roles].reverse().map(role => ({
46
+ name: role.org,
47
+ position: role.title,
48
+ ...(role.orgUrl ? { url: role.orgUrl } : {}),
49
+ ...(role.location ? { location: role.location } : {}),
50
+ startDate: isoDate(role.from),
51
+ ...(role.to ? { endDate: isoDate(role.to) } : {}),
52
+ summary: plainText(role.summary),
53
+ ...(role.skills.length ? { highlights: role.skills } : {}),
54
+ })),
55
+ volunteer: config.career.events.filter(event => volunteerTypes.has(event.type)).map(event => ({
56
+ organization: event.org ?? '',
57
+ position: event.title,
58
+ ...(event.orgUrl ? { url: event.orgUrl } : {}),
59
+ startDate: isoDate(event.date),
60
+ ...(event.endDate ? { endDate: isoDate(event.endDate) } : {}),
61
+ summary: plainText(event.summary),
62
+ })),
63
+ education: config.education.map(entry => ({
64
+ institution: entry.school,
65
+ studyType: entry.degree,
66
+ startDate: entry.from,
67
+ ...(entry.to ? { endDate: entry.to } : {}),
68
+ })),
69
+ certificates: config.certifications.map(certificate => ({
70
+ name: certificate.title,
71
+ issuer: certificate.issuer,
72
+ date: certificate.year,
73
+ ...(certificate.url ? { url: certificate.url } : {}),
74
+ })),
75
+ skills: Object.entries(config.skills).map(([name, keywords]) => ({ name, keywords })),
76
+ languages: person.languages.map(language),
77
+ interests: person.interests.map(name => ({ name })),
78
+ projects: config.projects.map(entry => ({
79
+ name: entry.name,
80
+ url: entry.url,
81
+ description: plainText(entry.pitch),
82
+ ...(entry.features.length ? { highlights: entry.features } : {}),
83
+ ...(entry.stack.length ? { keywords: entry.stack } : {}),
84
+ })),
85
+ meta: { canonical: `${config.site.url.replace(/\/$/, '')}/resume.json`, version: 'v1.0.0' },
86
+ };
87
+ }
package/package.json CHANGED
@@ -1,9 +1,81 @@
1
1
  {
2
- "name": "@cratis/cast",
3
- "version": "0.0.1",
4
- "description": "Placeholder for Cratis Cast. The first release is coming; see https://github.com/Cratis/Cast",
5
- "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/Cratis/Cast.git" },
7
- "homepage": "https://github.com/Cratis/Cast",
8
- "files": ["README.md"]
9
- }
2
+ "name": "@cratis/cast",
3
+ "version": "0.2.0",
4
+ "description": "Cast: the Cratis profile-site engine. One cast.yaml becomes a verified, event-sourced personal site with a printable CV, live GitHub numbers and a Cast badge.",
5
+ "keywords": [
6
+ "cratis",
7
+ "cast",
8
+ "astro",
9
+ "astro-integration",
10
+ "portfolio",
11
+ "cv",
12
+ "event-sourcing",
13
+ "event-modeling"
14
+ ],
15
+ "author": "Cratis",
16
+ "license": "MIT",
17
+ "homepage": "https://cast.cratis.io",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Cratis/Cast.git",
21
+ "directory": "Source/Cast"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=23.0.0"
29
+ },
30
+ "files": [
31
+ "**/*.ts",
32
+ "**/*.astro",
33
+ "**/*.css",
34
+ "**/*.svg",
35
+ "cast.schema.json",
36
+ "cast-json.schema.json",
37
+ "registry/members.yaml",
38
+ "!**/for_*/**",
39
+ "!vitest.config.ts",
40
+ "!scripts/**",
41
+ "README.md"
42
+ ],
43
+ "exports": {
44
+ ".": "./index.ts",
45
+ "./schema": "./config/index.ts",
46
+ "./config": "./config/index.ts",
47
+ "./badges": "./badges/index.ts",
48
+ "./github/*": "./github/*.ts",
49
+ "./cast.schema.json": "./cast.schema.json",
50
+ "./cast-json.schema.json": "./cast-json.schema.json",
51
+ "./machine": "./machine/index.ts",
52
+ "./profile": "./site/profile/index.ts",
53
+ "./package.json": "./package.json",
54
+ "./*": "./*"
55
+ },
56
+ "scripts": {
57
+ "clean": "yarn g:clean",
58
+ "build": "yarn g:tsx scripts/build.ts && yarn g:typecheck",
59
+ "lint": "yarn g:lint",
60
+ "lint:ci": "yarn g:lint:ci",
61
+ "test": "yarn g:test",
62
+ "ci": "yarn g:ci"
63
+ },
64
+ "peerDependencies": {
65
+ "astro": "^7.2.0"
66
+ },
67
+ "dependencies": {
68
+ "@astrojs/sitemap": "^3.7.4",
69
+ "@fontsource-variable/fraunces": "^5.3.0",
70
+ "@fontsource-variable/inter": "^5.3.0",
71
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
72
+ "@tailwindcss/vite": "^4.3.3",
73
+ "sharp": "^0.35.5",
74
+ "tailwindcss": "^4.3.3",
75
+ "yaml": "^2.9.1",
76
+ "zod": "^4.6.7"
77
+ },
78
+ "devDependencies": {
79
+ "astro": "^7.2.0"
80
+ }
81
+ }
@@ -0,0 +1,16 @@
1
+ # yaml-language-server: $schema=./members.schema.json
2
+ #
3
+ # The Cratis Cast registry. Every line here is reviewed by Cratis in a pull request.
4
+ # Core and Partner tiers come only from this file. Contributor is never listed here:
5
+ # it is computed from merged public pull requests in the Cratis organization.
6
+ # See README.md in this folder for what each tier means and how to join.
7
+
8
+ - github: woksin
9
+ tier: core
10
+ since: 2024-06
11
+ role: Co-founder & Chief Architect
12
+ site: https://www.woksin.com
13
+
14
+ - github: einari
15
+ tier: core
16
+ role: Co-founder & CTO
@@ -0,0 +1,42 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import Base from '../site/layouts/Base.astro';
6
+ import Nav from '../site/components/Nav.astro';
7
+ import Footer from '../site/components/Footer.astro';
8
+ import { cast } from '../site/data';
9
+
10
+ const { person } = cast;
11
+ ---
12
+
13
+ <Base cast={cast} title={`EventNotFound · ${person.name}`} description="This page is not in the event stream." noindex>
14
+ <Nav slot="nav" cast={cast} home={false} />
15
+ <section class="relative isolate flex min-h-[100svh] items-center overflow-hidden pt-[var(--nav-h)]">
16
+ <div class="pointer-events-none absolute inset-0 -z-10" aria-hidden="true">
17
+ <div class="absolute top-1/2 left-1/2 size-[40rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent/10 blur-[120px]"></div>
18
+ </div>
19
+ <div class="container-x py-24">
20
+ <div class="panel max-w-2xl overflow-hidden">
21
+ <div class="flex items-center justify-between border-b border-line px-6 py-3.5 font-mono text-xs">
22
+ <span class="note">Event</span>
23
+ <span class="text-fg-faint">stream position <span class="text-event">-1</span></span>
24
+ </div>
25
+ <div class="p-6 md:p-10">
26
+ <p class="font-mono text-sm text-fg-faint">HTTP 404</p>
27
+ <h1 class="mt-3 text-[clamp(2.5rem,7vw,4.75rem)] leading-[0.95] font-[800] tracking-[-0.045em] text-event-soft">EventNotFound</h1>
28
+ <p class="mt-5 max-w-[46ch] text-lg text-fg-muted">
29
+ Nothing was ever appended at this address, so there is nothing to project. The stream itself is fine.
30
+ </p>
31
+ <pre class="mt-8 overflow-x-auto rounded-xl border border-line bg-ink-950/80 p-4 font-mono text-[0.8125rem] leading-6 text-fg-muted"><span class="text-command">replay</span>(stream: <span class="text-view">"{person.handle}"</span>, from: <span class="text-event">-1</span>)
32
+ <span class="text-fg-faint">// → no events at position -1</span></pre>
33
+ <div class="mt-8 flex flex-wrap gap-3">
34
+ <a href="/" class="btn btn-primary">Back to the start of the stream</a>
35
+ <a href="/cv" class="btn">View CV</a>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </section>
41
+ <Footer slot="footer" cast={cast} />
42
+ </Base>
@@ -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
+ import type { APIRoute } from 'astro';
5
+ import sharp from 'sharp';
6
+ import { config } from 'virtual:cast';
7
+ import { faviconSvg } from '../machine/ogImageSvg';
8
+ import { accentTokens } from '../site/lib/accent';
9
+
10
+ export const prerender = true;
11
+
12
+ export const GET: APIRoute = async () => {
13
+ const initial = (config.person.name.trim()[0] ?? 'C').toUpperCase();
14
+ const svg = faviconSvg(initial, config.theme.accent, accentTokens(config.theme.accent).ink).replace('rx="14"', 'rx="0"');
15
+ const png = await sharp(Buffer.from(svg), { density: 300 }).resize(180, 180).png().toBuffer();
16
+ return new Response(new Uint8Array(png), { headers: { 'content-type': 'image/png' } });
17
+ };
@@ -0,0 +1,13 @@
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 { APIRoute } from 'astro';
5
+ import { config, tier } from 'virtual:cast';
6
+ import { badgeCardSvg } from '../badges/badgeSvg';
7
+
8
+ export const prerender = true;
9
+
10
+ export const GET: APIRoute = () =>
11
+ new Response(badgeCardSvg(tier.tier, { handle: config.person.handle, name: config.person.name, since: tier.member?.since }), {
12
+ headers: { 'content-type': 'image/svg+xml; charset=utf-8' },
13
+ });
@@ -0,0 +1,11 @@
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 { APIRoute } from 'astro';
5
+ import { tier } from 'virtual:cast';
6
+ import { badgeSvg } from '../badges/badgeSvg';
7
+
8
+ export const prerender = true;
9
+
10
+ /** This site's badge, as verified when it was built. The directory serves the authoritative one. */
11
+ export const GET: APIRoute = () => new Response(badgeSvg(tier.tier), { headers: { 'content-type': 'image/svg+xml; charset=utf-8' } });
@@ -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
+ import type { APIRoute } from 'astro';
5
+ import * as data from 'virtual:cast';
6
+ import { castJson } from '../machine/castJson';
7
+
8
+ export const prerender = true;
9
+
10
+ /** The portrait as an absolute https URL: the built local image, else the remote one. */
11
+ const portrait = (): string | undefined => {
12
+ if (data.photo) return new URL(data.photo.src, data.config.site.url).toString();
13
+ return data.photoUrl;
14
+ };
15
+
16
+ export const GET: APIRoute = () =>
17
+ new Response(JSON.stringify(castJson(data, portrait()), null, 2), { headers: { 'content-type': 'application/json; charset=utf-8' } });
@@ -0,0 +1,9 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import CvPage from '../site/pages/CvPage.astro';
6
+ import { cast } from '../site/data';
7
+ ---
8
+
9
+ <CvPage cast={cast} />
@@ -0,0 +1,16 @@
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 { APIRoute } from 'astro';
5
+ import { config } from 'virtual:cast';
6
+ import { faviconSvg } from '../machine/ogImageSvg';
7
+ import { accentTokens } from '../site/lib/accent';
8
+
9
+ export const prerender = true;
10
+
11
+ export const GET: APIRoute = () => {
12
+ const initial = (config.person.name.trim()[0] ?? 'C').toUpperCase();
13
+ return new Response(faviconSvg(initial, config.theme.accent, accentTokens(config.theme.accent).ink), {
14
+ headers: { 'content-type': 'image/svg+xml; charset=utf-8' },
15
+ });
16
+ };
@@ -0,0 +1,9 @@
1
+ ---
2
+ // Copyright (c) Cratis. All rights reserved.
3
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
+
5
+ import ProfilePage from '../site/pages/ProfilePage.astro';
6
+ import { cast } from '../site/data';
7
+ ---
8
+
9
+ <ProfilePage cast={cast} />
@@ -0,0 +1,10 @@
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 { APIRoute } from 'astro';
5
+ import * as data from 'virtual:cast';
6
+ import { llmsText } from '../machine/llmsText';
7
+
8
+ export const prerender = true;
9
+
10
+ export const GET: APIRoute = () => new Response(llmsText(data), { headers: { 'content-type': 'text/plain; charset=utf-8' } });
@@ -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 type { APIRoute } from 'astro';
5
+ import sharp from 'sharp';
6
+ import { config, tier } from 'virtual:cast';
7
+ import { ogImageSvg } from '../machine/ogImageSvg';
8
+ import { accentTokens } from '../site/lib/accent';
9
+
10
+ export const prerender = true;
11
+
12
+ export const GET: APIRoute = async () => {
13
+ const svg = ogImageSvg({
14
+ name: config.person.name,
15
+ title: config.person.title,
16
+ tagline: config.person.tagline,
17
+ host: new URL(config.site.url).host,
18
+ accent: accentTokens(config.theme.accent).darkSoft,
19
+ tier: tier.tier,
20
+ });
21
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
22
+ return new Response(new Uint8Array(png), { headers: { 'content-type': 'image/png' } });
23
+ };
@@ -0,0 +1,13 @@
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 { APIRoute } from 'astro';
5
+ import { config } from 'virtual:cast';
6
+ import { resumeJson } from '../machine/resumeJson';
7
+
8
+ export const prerender = true;
9
+
10
+ export const GET: APIRoute = ({ site }) =>
11
+ new Response(JSON.stringify(resumeJson(config, new URL('/apple-touch-icon.png', site ?? config.site.url).toString()), null, 2), {
12
+ headers: { 'content-type': 'application/json; charset=utf-8' },
13
+ });
@@ -0,0 +1,10 @@
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 { APIRoute } from 'astro';
5
+ import { config } from 'virtual:cast';
6
+
7
+ export const prerender = true;
8
+
9
+ export const GET: APIRoute = () =>
10
+ new Response(`User-agent: *\nAllow: /\n\nSitemap: ${new URL('/sitemap-index.xml', config.site.url)}\n`, { headers: { 'content-type': 'text/plain; charset=utf-8' } });