@cratis/cast 0.1.0 → 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 (56) hide show
  1. package/README.md +3 -1
  2. package/badges/RegistryMember.ts +2 -0
  3. package/badges/registry.ts +5 -0
  4. package/cast-json.schema.json +1496 -0
  5. package/config/calendar.ts +34 -0
  6. package/config/castSchema.ts +3 -2
  7. package/github/gitHubSnapshotSchema.ts +58 -0
  8. package/machine/castJson.ts +17 -4
  9. package/machine/castJsonSchema.ts +35 -0
  10. package/machine/index.ts +6 -0
  11. package/machine/profileFromCastJson.ts +214 -0
  12. package/package.json +5 -1
  13. package/routes/404.astro +6 -4
  14. package/routes/cast.json.ts +7 -1
  15. package/routes/cv.astro +3 -365
  16. package/routes/index.astro +3 -55
  17. package/site/components/Activity.astro +8 -6
  18. package/site/components/Agentic.astro +11 -10
  19. package/site/components/CareerReplay.astro +11 -8
  20. package/site/components/CommandPalette.astro +10 -4
  21. package/site/components/Contact.astro +8 -5
  22. package/site/components/Cratis.astro +17 -15
  23. package/site/components/EventModel.astro +8 -5
  24. package/site/components/Footer.astro +9 -4
  25. package/site/components/Hero.astro +13 -10
  26. package/site/components/Inline.astro +45 -0
  27. package/site/components/Monogram.astro +4 -1
  28. package/site/components/Nav.astro +12 -9
  29. package/site/components/Now.astro +13 -10
  30. package/site/components/Projects.astro +11 -14
  31. package/site/components/PullRequestFeed.astro +6 -4
  32. package/site/components/SectionHeader.astro +6 -3
  33. package/site/components/Skills.astro +5 -2
  34. package/site/components/Stats.astro +7 -2
  35. package/site/components/Talks.astro +8 -5
  36. package/site/components/Terminal.astro +7 -2
  37. package/site/components/Writing.astro +9 -7
  38. package/site/data.ts +6 -43
  39. package/site/layouts/Base.astro +31 -19
  40. package/site/lib/blog.ts +42 -16
  41. package/site/lib/github.ts +48 -31
  42. package/site/lib/inline.ts +65 -9
  43. package/site/lib/nav.ts +14 -3
  44. package/site/pages/CvPage.astro +379 -0
  45. package/site/pages/ProfilePage.astro +78 -0
  46. package/site/profile/CastHost.ts +32 -0
  47. package/site/profile/CastView.ts +96 -0
  48. package/site/profile/assertRenderable.ts +151 -0
  49. package/site/profile/clientCast.ts +117 -0
  50. package/site/profile/index.ts +8 -0
  51. package/site/scripts/live.ts +21 -11
  52. package/site/scripts/palette.ts +30 -13
  53. package/site/scripts/replay.ts +2 -1
  54. package/site/scripts/terminal.ts +28 -25
  55. package/writing/fetchWriting.ts +17 -2
  56. package/writing/writingSnapshotSchema.ts +27 -0
@@ -0,0 +1,34 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ const dayPattern = /^(\d{4})-(\d{2})-(\d{2})/;
5
+
6
+ /** True when `year`, `month` and `day` name a real day: '2026-02-30' and '2026-99-99' do not. */
7
+ function isRealDay(year: number, month: number, day: number): boolean {
8
+ if (year < 1000 || month < 1 || month > 12 || day < 1) return false;
9
+ const date = new Date(Date.UTC(year, month - 1, day));
10
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
11
+ }
12
+
13
+ /** True for a real calendar day written as 'YYYY-MM-DD'. */
14
+ export function isCalendarDate(value: string): boolean {
15
+ const match = dayPattern.exec(value);
16
+ return match !== null && match[0].length === value.length && isRealDay(Number(match[1]), Number(match[2]), Number(match[3]));
17
+ }
18
+
19
+ /** True for 'YYYY' or 'YYYY-MM' with a real month. */
20
+ export function isCalendarMonth(value: string): boolean {
21
+ const match = /^(\d{4})(?:-(\d{2}))?$/.exec(value);
22
+ return match !== null && isRealDay(Number(match[1]), Number(match[2] ?? 1), 1);
23
+ }
24
+
25
+ /** True for 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY', each a real day or month. */
26
+ export function isPartialDate(value: string): boolean {
27
+ return value.length === 10 ? isCalendarDate(value) : isCalendarMonth(value);
28
+ }
29
+
30
+ /** True for an ISO 8601 date or date and time that starts with a real calendar day and that `Date` can read. */
31
+ export function isInstant(value: string): boolean {
32
+ const match = dayPattern.exec(value);
33
+ return match !== null && isRealDay(Number(match[1]), Number(match[2]), Number(match[3])) && Number.isFinite(Date.parse(value));
34
+ }
@@ -2,15 +2,16 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { z } from 'zod';
5
+ import { isCalendarMonth, isPartialDate } from './calendar';
5
6
  import { LiveStat } from './LiveStat';
6
7
  import { SectionId } from './SectionId';
7
8
  import { ThemeMode } from './ThemeMode';
8
9
 
9
10
  /** 'YYYY-MM' or 'YYYY'. */
10
- const month = z.string().regex(/^\d{4}(-\d{2})?$/, 'Use YYYY-MM or YYYY');
11
+ const month = z.string().regex(/^\d{4}(-\d{2})?$/, 'Use YYYY-MM or YYYY').refine(isCalendarMonth, 'Use a real month: YYYY-MM or YYYY');
11
12
 
12
13
  /** 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY'. */
13
- const date = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/, 'Use YYYY-MM-DD, YYYY-MM or YYYY');
14
+ const date = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/, 'Use YYYY-MM-DD, YYYY-MM or YYYY').refine(isPartialDate, 'Use a real date: YYYY-MM-DD, YYYY-MM or YYYY');
14
15
 
15
16
  const url = z.url({ protocol: /^https?$/ });
16
17
  const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color such as #8b5cf6');
@@ -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
+ });
@@ -4,20 +4,28 @@
4
4
  import { verificationUrl } from '../badges/constants';
5
5
  import type { CastData } from '../integration/CastData';
6
6
  import { plainText } from '../site/lib/inline';
7
+ import { castJsonVersion } from './castJsonSchema';
7
8
 
8
- /** Version of the /cast.json document. Bumped on breaking changes; additions keep the version. */
9
- export const castJsonVersion = 1;
9
+ export { castJsonVersion };
10
+
11
+ /** Where the published JSON Schema of cast.json lives. */
12
+ export const castJsonSchemaUrl = 'https://unpkg.com/@cratis/cast/cast-json.schema.json';
10
13
 
11
14
  /**
12
- * The machine-readable profile at /cast.json. Agents can read it; the directory shows its title and tagline.
15
+ * The machine-readable profile at /cast.json, format version 2. `profile` is the complete, render-ready
16
+ * profile: cast.yaml as JSON plus the build-time GitHub and writing captures. The directory renders a member's
17
+ * full profile from it; agents can read it. The fields before `profile` are the version 1 summary, kept.
13
18
  * `verification.claimedTier` is what this site's build resolved. A site is static and controlled by its owner,
14
19
  * so it is a claim: the directory page at `verification.url` is the source of truth, and the directory never
15
20
  * reads tiers from here.
16
21
  */
17
- export function castJson(data: CastData) {
22
+ export function castJson(data: CastData, photoUrl?: string) {
18
23
  const { config, tier, github, build } = data;
19
24
  const person = config.person;
25
+ const { $schema: _schema, ...cast } = config;
26
+ const { photo: _photo, ...personWithoutPhoto } = person;
20
27
  return {
28
+ $schema: castJsonSchemaUrl,
21
29
  castJsonVersion,
22
30
  generator: `@cratis/cast ${build.castVersion}`,
23
31
  builtAt: build.builtAt,
@@ -60,5 +68,10 @@ export function castJson(data: CastData) {
60
68
  mergedPullRequests: github.mergedPullRequests.total,
61
69
  mergedPullRequestsByOrg: github.mergedPullRequests.byOrg,
62
70
  },
71
+ profile: {
72
+ cast: { ...cast, person: { ...personWithoutPhoto, ...(photoUrl ? { photo: photoUrl } : {}) } },
73
+ github,
74
+ writing: data.writing,
75
+ },
63
76
  };
64
77
  }
@@ -0,0 +1,35 @@
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 { isInstant } from '../config/calendar';
6
+ import { castSchema } from '../config/castSchema';
7
+ import { gitHubSnapshotSchema } from '../github/gitHubSnapshotSchema';
8
+ import { writingSnapshotSchema } from '../writing/writingSnapshotSchema';
9
+
10
+ /** The cast.json format version this engine writes and the directory renders. */
11
+ export const castJsonVersion = 2;
12
+
13
+ const login = z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/, 'Must be a GitHub login');
14
+
15
+ /**
16
+ * cast.json, format version 2: the complete, render-ready profile. `profile.cast` is cast.yaml as JSON
17
+ * (validated against cast.schema.json; `person.photo` must be an https URL). `profile.github` and
18
+ * `profile.writing` are optional build-time captures. Everything outside `profile` is a summary for agents
19
+ * and earlier readers; `verification` is the site's own claim, which the directory never reads.
20
+ */
21
+ export const castJsonSchema = z.object({
22
+ $schema: z.string().optional(),
23
+ castJsonVersion: z.literal(castJsonVersion),
24
+ handle: login.describe('The GitHub login: the profile identity. Must equal profile.cast.person.handle.'),
25
+ generator: z.string().max(200).optional().describe('What wrote the file, for example "@cratis/cast 1.2.0".'),
26
+ builtAt: z.string().max(64).refine(isInstant, 'Use an ISO 8601 date and time on a real day').optional().describe('When the file was written, ISO 8601.'),
27
+ profile: z.object({
28
+ cast: castSchema.describe('cast.yaml as JSON. See https://github.com/Cratis/Cast/blob/main/Documentation/cast-yaml.md'),
29
+ github: gitHubSnapshotSchema.optional().describe('Public GitHub data captured at build time. Without it the directory uses its own.'),
30
+ writing: writingSnapshotSchema.optional().describe('Posts captured from the writing sources.'),
31
+ }),
32
+ }).loose();
33
+
34
+ /** A cast.json document as written. */
35
+ export type CastJsonInput = z.input<typeof castJsonSchema>;
@@ -0,0 +1,6 @@
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
+ export * from './castJson';
5
+ export * from './castJsonSchema';
6
+ export * from './profileFromCastJson';
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/cast",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "cratis",
@@ -33,6 +33,7 @@
33
33
  "**/*.css",
34
34
  "**/*.svg",
35
35
  "cast.schema.json",
36
+ "cast-json.schema.json",
36
37
  "registry/members.yaml",
37
38
  "!**/for_*/**",
38
39
  "!vitest.config.ts",
@@ -46,6 +47,9 @@
46
47
  "./badges": "./badges/index.ts",
47
48
  "./github/*": "./github/*.ts",
48
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",
49
53
  "./package.json": "./package.json",
50
54
  "./*": "./*"
51
55
  },
package/routes/404.astro CHANGED
@@ -5,11 +5,13 @@
5
5
  import Base from '../site/layouts/Base.astro';
6
6
  import Nav from '../site/components/Nav.astro';
7
7
  import Footer from '../site/components/Footer.astro';
8
- import { person } from '../site/data';
8
+ import { cast } from '../site/data';
9
+
10
+ const { person } = cast;
9
11
  ---
10
12
 
11
- <Base title={`EventNotFound · ${person.name}`} description="This page is not in the event stream." noindex>
12
- <Nav slot="nav" home={false} />
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} />
13
15
  <section class="relative isolate flex min-h-[100svh] items-center overflow-hidden pt-[var(--nav-h)]">
14
16
  <div class="pointer-events-none absolute inset-0 -z-10" aria-hidden="true">
15
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>
@@ -36,5 +38,5 @@ import { person } from '../site/data';
36
38
  </div>
37
39
  </div>
38
40
  </section>
39
- <Footer slot="footer" />
41
+ <Footer slot="footer" cast={cast} />
40
42
  </Base>
@@ -7,5 +7,11 @@ import { castJson } from '../machine/castJson';
7
7
 
8
8
  export const prerender = true;
9
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
+
10
16
  export const GET: APIRoute = () =>
11
- new Response(JSON.stringify(castJson(data), null, 2), { headers: { 'content-type': 'application/json; charset=utf-8' } });
17
+ new Response(JSON.stringify(castJson(data, portrait()), null, 2), { headers: { 'content-type': 'application/json; charset=utf-8' } });