@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,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
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { CastData } from './CastData';
5
+
6
+ const moduleId = 'virtual:cast';
7
+ const resolvedModuleId = `\0${moduleId}`;
8
+
9
+ /** A minimal Vite plugin shape, so the engine does not depend on Vite's types directly. */
10
+ export type VirtualModulePlugin = {
11
+ name: string;
12
+ resolveId(id: string): string | undefined;
13
+ load(id: string): string | undefined;
14
+ };
15
+
16
+ /**
17
+ * Serves `virtual:cast`: the resolved data as named exports, plus `photo`, which is the imported local
18
+ * portrait (an Astro image) when cast.yaml points at a file.
19
+ */
20
+ export function castVirtualModule(data: () => CastData, photoFile: () => string | undefined): VirtualModulePlugin {
21
+ return {
22
+ name: 'cratis-cast:virtual',
23
+ resolveId(id) {
24
+ return id === moduleId ? resolvedModuleId : undefined;
25
+ },
26
+ load(id) {
27
+ if (id !== resolvedModuleId) return undefined;
28
+ const current = data();
29
+ const photo = photoFile();
30
+ return [
31
+ photo ? `import photo from ${JSON.stringify(photo)};` : 'const photo = undefined;',
32
+ `export const config = ${JSON.stringify(current.config)};`,
33
+ `export const sections = ${JSON.stringify(current.sections)};`,
34
+ `export const github = ${JSON.stringify(current.github)};`,
35
+ `export const writing = ${JSON.stringify(current.writing)};`,
36
+ `export const tier = ${JSON.stringify(current.tier)};`,
37
+ `export const photoUrl = ${JSON.stringify(current.photoUrl ?? null)} ?? undefined;`,
38
+ `export const build = ${JSON.stringify(current.build)};`,
39
+ 'export { photo };',
40
+ ].join('\n');
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,77 @@
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 { verificationUrl } from '../badges/constants';
5
+ import type { CastData } from '../integration/CastData';
6
+ import { plainText } from '../site/lib/inline';
7
+ import { castJsonVersion } from './castJsonSchema';
8
+
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';
13
+
14
+ /**
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.
18
+ * `verification.claimedTier` is what this site's build resolved. A site is static and controlled by its owner,
19
+ * so it is a claim: the directory page at `verification.url` is the source of truth, and the directory never
20
+ * reads tiers from here.
21
+ */
22
+ export function castJson(data: CastData, photoUrl?: string) {
23
+ const { config, tier, github, build } = data;
24
+ const person = config.person;
25
+ const { $schema: _schema, ...cast } = config;
26
+ const { photo: _photo, ...personWithoutPhoto } = person;
27
+ return {
28
+ $schema: castJsonSchemaUrl,
29
+ castJsonVersion,
30
+ generator: `@cratis/cast ${build.castVersion}`,
31
+ builtAt: build.builtAt,
32
+ handle: config.github.login,
33
+ name: person.name,
34
+ ...(person.fullName ? { fullName: person.fullName } : {}),
35
+ title: person.title,
36
+ ...(person.location ? { location: person.location } : {}),
37
+ tagline: plainText(person.tagline),
38
+ intro: plainText(person.intro),
39
+ site: config.site.url,
40
+ verification: {
41
+ url: verificationUrl(config.github.login),
42
+ claimedTier: tier.tier ?? null,
43
+ source: tier.source,
44
+ ...(tier.member?.since ? { since: tier.member.since } : {}),
45
+ ...(tier.member?.role ? { role: tier.member.role } : {}),
46
+ claimedAt: build.builtAt,
47
+ registry: build.registrySource,
48
+ note: 'Claimed by this site when it was built. The directory page at url is the source of truth.',
49
+ },
50
+ links: { github: `https://github.com/${config.github.login}`, ...person.links },
51
+ career: config.career.events.map(event => ({
52
+ seq: event.seq,
53
+ date: event.date,
54
+ ...(event.endDate ? { endDate: event.endDate } : {}),
55
+ type: event.type,
56
+ title: event.title,
57
+ ...(event.org ? { org: event.org } : {}),
58
+ ...(event.role ? { role: event.role } : {}),
59
+ ...(event.endsRole ? { endsRole: event.endsRole } : {}),
60
+ summary: plainText(event.summary),
61
+ ...(event.skills?.length ? { skills: event.skills } : {}),
62
+ })),
63
+ projects: config.projects.map(project => ({ name: project.name, url: project.url, pitch: plainText(project.pitch), stack: project.stack })),
64
+ stats: {
65
+ capturedAt: github.fetchedAt ?? null,
66
+ source: build.githubSource,
67
+ contributionsLastYear: github.contributions.total,
68
+ mergedPullRequests: github.mergedPullRequests.total,
69
+ mergedPullRequestsByOrg: github.mergedPullRequests.byOrg,
70
+ },
71
+ profile: {
72
+ cast: { ...cast, person: { ...personWithoutPhoto, ...(photoUrl ? { photo: photoUrl } : {}) } },
73
+ github,
74
+ writing: data.writing,
75
+ },
76
+ };
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,69 @@
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 { tierStyles } from '../badges/badgeSvg';
5
+ import { verificationUrl } from '../badges/constants';
6
+ import type { CastData } from '../integration/CastData';
7
+ import { formatMonth, project } from '../site/lib/career';
8
+ import { plainText } from '../site/lib/inline';
9
+
10
+ /** A short, factual Markdown summary for AI agents, following the llms.txt convention. */
11
+ export function llmsText(data: CastData): string {
12
+ const { config, tier, github } = data;
13
+ const person = config.person;
14
+ const site = config.site.url.replace(/\/$/, '');
15
+ const projection = project(config.career.events);
16
+ const lines: string[] = [];
17
+
18
+ lines.push(`# ${person.name}`, '');
19
+ lines.push(`> ${person.title}${person.location ? `, ${person.location}` : ''}. ${plainText(person.intro)}`, '');
20
+
21
+ if (tier.tier) {
22
+ const style = tierStyles[tier.tier];
23
+ lines.push(`Cratis Cast: ${style.label} (${style.description.toLowerCase()}), as claimed by this site's build from ${tier.source === 'registry' ? 'the Cast registry' : 'merged public pull requests in the Cratis organization'}. The authoritative check is ${verificationUrl(config.github.login)}`, '');
24
+ }
25
+
26
+ if (projection.currentRoles.length > 0) {
27
+ lines.push('## Now', '');
28
+ for (const role of projection.currentRoles) lines.push(`- ${role.title}, ${role.org} (since ${formatMonth(role.from)})`);
29
+ lines.push('');
30
+ }
31
+
32
+ if (person.about.length > 0) {
33
+ lines.push('## About', '', ...person.about.map(paragraph => plainText(paragraph)), '');
34
+ }
35
+
36
+ const roles = [...projection.roles].reverse();
37
+ if (roles.length > 0) {
38
+ lines.push('## Experience', '');
39
+ for (const role of roles) lines.push(`- ${formatMonth(role.from)} – ${role.to ? formatMonth(role.to) : 'present'}: ${role.title}, ${role.org}`);
40
+ lines.push('');
41
+ }
42
+
43
+ if (config.projects.length > 0) {
44
+ lines.push('## Projects', '');
45
+ for (const entry of config.projects) lines.push(`- [${entry.name}](${entry.url}): ${plainText(entry.pitch)}`);
46
+ lines.push('');
47
+ }
48
+
49
+ const skills = Object.entries(config.skills);
50
+ if (skills.length > 0) {
51
+ lines.push('## Skills', '');
52
+ for (const [group, list] of skills) lines.push(`- ${group}: ${list.join(', ')}`);
53
+ lines.push('');
54
+ }
55
+
56
+ if (github.fetchedAt) {
57
+ lines.push('## Public GitHub activity', '');
58
+ lines.push(`- ${github.contributions.total.toLocaleString('en-US')} contributions in the last year (captured ${github.fetchedAt.slice(0, 10)})`);
59
+ for (const [org, count] of Object.entries(github.mergedPullRequests.byOrg)) lines.push(`- ${count.toLocaleString('en-US')} merged public pull requests in ${org}`);
60
+ lines.push('');
61
+ }
62
+
63
+ lines.push('## Machine-readable', '');
64
+ lines.push(`- [Cast profile](${site}/cast.json): identity, verified tier, career events, projects`);
65
+ lines.push(`- [JSON Resume](${site}/resume.json)`);
66
+ lines.push(`- [CV](${site}/cv)`);
67
+ lines.push('');
68
+ return lines.join('\n');
69
+ }
@@ -0,0 +1,79 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { tierStyles } from '../badges/badgeSvg';
5
+ import { cratisMarkPaths } from '../badges/cratisMark';
6
+ import type { Tier } from '../badges/Tier';
7
+ import { plainText } from '../site/lib/inline';
8
+
9
+ /** Inputs for the social preview card. */
10
+ export type OgImageInput = {
11
+ name: string;
12
+ title: string;
13
+ tagline: string;
14
+ host: string;
15
+ accent: string;
16
+ tier?: Tier;
17
+ };
18
+
19
+ const escapeXml = (value: string): string =>
20
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
21
+
22
+ /** Greedy word wrap by an approximate character budget. */
23
+ export function wrap(text: string, maximumCharacters: number, maximumLines: number): string[] {
24
+ const lines: string[] = [];
25
+ let current = '';
26
+ for (const word of text.split(/\s+/).filter(Boolean)) {
27
+ if ((current ? `${current} ${word}` : word).length > maximumCharacters && current) {
28
+ lines.push(current);
29
+ current = word;
30
+ } else {
31
+ current = current ? `${current} ${word}` : word;
32
+ }
33
+ }
34
+ if (current) lines.push(current);
35
+ if (lines.length > maximumLines) {
36
+ const kept = lines.slice(0, maximumLines);
37
+ kept[maximumLines - 1] = `${kept[maximumLines - 1]!.replace(/[.,;:]?$/, '')}…`;
38
+ return kept;
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ const sans = "Inter, 'Helvetica Neue', Helvetica, Arial, 'DejaVu Sans', sans-serif";
44
+ const serif = "Fraunces, Georgia, 'DejaVu Serif', serif";
45
+ const mono = "'JetBrains Mono', Menlo, 'DejaVu Sans Mono', monospace";
46
+
47
+ /** The 1200 × 630 social preview card, rendered to PNG at build time. */
48
+ export function ogImageSvg(input: OgImageInput): string {
49
+ const style = input.tier ? tierStyles[input.tier] : undefined;
50
+ const tagline = wrap(plainText(input.tagline), 34, 2);
51
+ const title = wrap(input.title, 64, 2);
52
+ const mark = `<g transform="translate(80 72) scale(${(40 / 328).toFixed(5)})"><g transform="translate(0 328) scale(.1 -.1)" fill="#f5f3f0">${cratisMarkPaths.map(path => `<path d="${path}"/>`).join('')}</g></g>`;
53
+
54
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
55
+ <defs>
56
+ <radialGradient id="a" cx="0.92" cy="0.05" r="0.75"><stop offset="0" stop-color="${input.accent}" stop-opacity=".42"/><stop offset="1" stop-color="${input.accent}" stop-opacity="0"/></radialGradient>
57
+ <radialGradient id="b" cx="0.1" cy="1.05" r="0.6"><stop offset="0" stop-color="#2dd4bf" stop-opacity=".18"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
58
+ <pattern id="g" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M40 0H0V40" fill="none" stroke="#ffffff" stroke-opacity=".04"/></pattern>
59
+ ${style ? `<linearGradient id="t" x1="0" x2="1"><stop offset="0" stop-color="${style.from}"/><stop offset="1" stop-color="${style.to}"/></linearGradient>` : ''}
60
+ </defs>
61
+ <rect width="1200" height="630" fill="#08080e"/>
62
+ <rect width="1200" height="630" fill="url(#g)"/>
63
+ <rect width="1200" height="630" fill="url(#a)"/>
64
+ <rect width="1200" height="630" fill="url(#b)"/>
65
+ ${mark}
66
+ <text x="136" y="101" font-family="${mono}" font-size="20" letter-spacing="3" fill="#a09fad">CRATIS CAST</text>
67
+ ${style ? `<g><rect x="${1120 - 52 - style.label.length * 15}" y="66" width="${52 + style.label.length * 15}" height="44" rx="12" fill="url(#t)"/><text x="${1120 - 26}" y="96" text-anchor="end" font-family="${sans}" font-weight="700" font-size="22" fill="${style.ink}">${escapeXml(style.label)}</text></g>` : ''}
68
+ <text x="80" y="250" font-family="${sans}" font-weight="800" font-size="84" letter-spacing="-3" fill="#f5f3f0">${escapeXml(input.name)}</text>
69
+ ${title.map((line, index) => `<text x="82" y="${306 + index * 36}" font-family="${sans}" font-size="28" fill="#a09fad">${escapeXml(line)}</text>`).join('\n')}
70
+ ${tagline.map((line, index) => `<text x="80" y="${440 + index * 62}" font-family="${serif}" font-style="italic" font-weight="600" font-size="54" fill="${input.accent}">${escapeXml(line)}</text>`).join('\n')}
71
+ <text x="80" y="582" font-family="${mono}" font-size="22" fill="#7c7b8b">${escapeXml(input.host)}</text>
72
+ <rect x="0" y="626" width="1200" height="4" fill="${input.accent}"/>
73
+ </svg>`;
74
+ }
75
+
76
+ /** The favicon: the person's initial in the italic serif on their accent. */
77
+ export function faviconSvg(initial: string, accent: string, ink: string): string {
78
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${accent}"/><text x="32" y="45" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-style="italic" font-weight="700" font-size="40" fill="${ink}">${escapeXml(initial)}</text></svg>`;
79
+ }