@cratis/cast 0.0.1 → 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.
Files changed (102) hide show
  1. package/README.md +11 -1
  2. package/badges/RegistryMember.ts +16 -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 +63 -0
  11. package/badges/resolveTier.ts +24 -0
  12. package/cast.schema.json +1113 -0
  13. package/config/CastConfig.ts +17 -0
  14. package/config/CastConfigError.ts +10 -0
  15. package/config/LiveStat.ts +17 -0
  16. package/config/ResolvedSection.ts +13 -0
  17. package/config/SectionId.ts +21 -0
  18. package/config/ThemeMode.ts +9 -0
  19. package/config/castSchema.ts +262 -0
  20. package/config/index.ts +12 -0
  21. package/config/parseCast.ts +58 -0
  22. package/config/resolveSections.ts +48 -0
  23. package/github/GitHubSnapshot.ts +66 -0
  24. package/github/fetchGitHub.ts +235 -0
  25. package/github/parse.ts +63 -0
  26. package/index.ts +88 -0
  27. package/integration/CastData.ts +20 -0
  28. package/integration/CastOptions.ts +17 -0
  29. package/integration/Snapshot.ts +12 -0
  30. package/integration/loadRegistry.ts +37 -0
  31. package/integration/resolveData.ts +138 -0
  32. package/integration/snapshotFile.ts +23 -0
  33. package/integration/virtualModule.ts +43 -0
  34. package/machine/castJson.ts +64 -0
  35. package/machine/llmsText.ts +69 -0
  36. package/machine/ogImageSvg.ts +79 -0
  37. package/machine/resumeJson.ts +87 -0
  38. package/package.json +76 -8
  39. package/registry/members.yaml +16 -0
  40. package/routes/404.astro +40 -0
  41. package/routes/apple-touch-icon.png.ts +17 -0
  42. package/routes/badge-card.svg.ts +13 -0
  43. package/routes/badge.svg.ts +11 -0
  44. package/routes/cast.json.ts +11 -0
  45. package/routes/cv.astro +371 -0
  46. package/routes/favicon.svg.ts +16 -0
  47. package/routes/index.astro +61 -0
  48. package/routes/llms.txt.ts +10 -0
  49. package/routes/og.png.ts +23 -0
  50. package/routes/resume.json.ts +13 -0
  51. package/routes/robots.txt.ts +10 -0
  52. package/site/components/Activity.astro +194 -0
  53. package/site/components/Agentic.astro +143 -0
  54. package/site/components/CareerReplay.astro +412 -0
  55. package/site/components/CastBadge.astro +132 -0
  56. package/site/components/CommandPalette.astro +116 -0
  57. package/site/components/Contact.astro +163 -0
  58. package/site/components/Cratis.astro +233 -0
  59. package/site/components/CratisMark.astro +18 -0
  60. package/site/components/EventModel.astro +262 -0
  61. package/site/components/Footer.astro +28 -0
  62. package/site/components/Hero.astro +208 -0
  63. package/site/components/Monogram.astro +25 -0
  64. package/site/components/Nav.astro +124 -0
  65. package/site/components/Now.astro +113 -0
  66. package/site/components/Projects.astro +188 -0
  67. package/site/components/PullRequestFeed.astro +86 -0
  68. package/site/components/SectionHeader.astro +27 -0
  69. package/site/components/Skills.astro +41 -0
  70. package/site/components/Stats.astro +101 -0
  71. package/site/components/Talks.astro +73 -0
  72. package/site/components/Terminal.astro +159 -0
  73. package/site/components/Writing.astro +126 -0
  74. package/site/data.ts +48 -0
  75. package/site/layouts/Base.astro +144 -0
  76. package/site/lib/accent.ts +52 -0
  77. package/site/lib/blog.ts +32 -0
  78. package/site/lib/career.ts +148 -0
  79. package/site/lib/clipboard.ts +12 -0
  80. package/site/lib/contributions.ts +63 -0
  81. package/site/lib/github.ts +55 -0
  82. package/site/lib/inline.ts +23 -0
  83. package/site/lib/lifecycle.ts +44 -0
  84. package/site/lib/motion.ts +43 -0
  85. package/site/lib/nav.ts +38 -0
  86. package/site/lib/reltime.ts +33 -0
  87. package/site/lib/safeJson.ts +15 -0
  88. package/site/lib/sections.ts +41 -0
  89. package/site/lib/theme.ts +28 -0
  90. package/site/scripts/ambient.ts +93 -0
  91. package/site/scripts/event-board.ts +403 -0
  92. package/site/scripts/event-model.ts +155 -0
  93. package/site/scripts/heatmap.ts +94 -0
  94. package/site/scripts/live.ts +145 -0
  95. package/site/scripts/palette.ts +296 -0
  96. package/site/scripts/replay.ts +251 -0
  97. package/site/scripts/terminal.ts +556 -0
  98. package/site/styles/global.css +508 -0
  99. package/virtual.d.ts +15 -0
  100. package/writing/WritingSnapshot.ts +20 -0
  101. package/writing/fetchWriting.ts +54 -0
  102. package/writing/parse.ts +72 -0
@@ -0,0 +1,159 @@
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
+ // Shell for the interactive terminal. The behavior lives in scripts/terminal.ts and is
6
+ // only downloaded the first time the terminal opens.
7
+ import { person, projects } from '../data';
8
+ const firstProject = projects[0]?.name;
9
+ const chips = ['help', 'whoami', 'now', 'git log --oneline', 'stream tail', 'ls projects', ...(firstProject ? [`cat ${firstProject}`] : []), 'contact', 'replay'];
10
+ ---
11
+
12
+ <dialog class="term" data-terminal aria-label="Terminal" aria-describedby="term-hint" transition:persist="terminal">
13
+ <div class="shell panel flex flex-col overflow-hidden">
14
+ <div class="flex items-center gap-3 border-b border-line px-4 py-2.5">
15
+ <span class="flex gap-1.5" aria-hidden="true">
16
+ <span class="size-2.5 rounded-full bg-[#ff5f57]/80"></span>
17
+ <span class="size-2.5 rounded-full bg-[#febc2e]/80"></span>
18
+ <span class="size-2.5 rounded-full bg-[#28c840]/80"></span>
19
+ </span>
20
+ <p class="flex-1 truncate text-center font-mono text-xs text-fg-muted">{person.handle}@stream: ~</p>
21
+ <button type="button" class="kbd cursor-pointer" data-terminal-close aria-label="Close terminal">esc</button>
22
+ </div>
23
+ <div class="out min-h-0 flex-1 overflow-y-auto px-4 py-3 font-mono text-[0.8125rem] leading-relaxed" role="log" aria-live="polite" aria-label="Terminal output" data-terminal-out></div>
24
+ <form class="flex items-center gap-2 border-t border-line px-4 py-3 font-mono text-[0.8125rem]" data-terminal-form>
25
+ <label for="term-input" class="shrink-0"><span class="text-view">{person.handle}@stream</span><span class="text-fg-faint">:</span><span class="text-command">~</span><span class="text-fg-faint">$</span></label>
26
+ <input
27
+ id="term-input"
28
+ type="text"
29
+ class="min-w-0 flex-1 bg-transparent text-fg focus:outline-none"
30
+ autocomplete="off"
31
+ autocapitalize="off"
32
+ autocorrect="off"
33
+ spellcheck="false"
34
+ enterkeyhint="go"
35
+ aria-describedby="term-hint"
36
+ data-terminal-input
37
+ />
38
+ </form>
39
+ <div class="chips flex gap-2 overflow-x-auto border-t border-line px-4 py-2.5" role="group" aria-label="Suggested commands" data-terminal-chips>
40
+ {chips.map((c) => <button type="button" class="chip" data-cmd={c}>{c}</button>)}
41
+ </div>
42
+ <p id="term-hint" class="sr-only">Type help for a list of commands. Up and down browse history, Tab completes, Escape closes.</p>
43
+ </div>
44
+ </dialog>
45
+
46
+ <style>
47
+ .term {
48
+ position: fixed;
49
+ inset: 0;
50
+ margin: 0;
51
+ padding: 10vh 1rem 1rem;
52
+ width: 100%;
53
+ max-width: none;
54
+ height: 100%;
55
+ max-height: none;
56
+ background: transparent;
57
+ color: inherit;
58
+ border: 0;
59
+ }
60
+ .term:not([open]) {
61
+ display: none;
62
+ }
63
+ .term::backdrop {
64
+ background: rgb(4 4 8 / 0.6);
65
+ backdrop-filter: blur(6px);
66
+ }
67
+ .shell {
68
+ margin-inline: auto;
69
+ max-width: 46rem;
70
+ height: min(34rem, 80dvh);
71
+ background: color-mix(in oklab, var(--color-ink-900) 94%, transparent);
72
+ box-shadow:
73
+ inset 0 1px 0 color-mix(in srgb, var(--color-fg) 6%, transparent),
74
+ 0 50px 120px -20px rgb(0 0 0 / 0.8),
75
+ 0 0 0 1px color-mix(in srgb, var(--color-fg) 4%, transparent);
76
+ }
77
+ @media (max-width: 639px) {
78
+ .term {
79
+ padding: 0.75rem;
80
+ }
81
+ .shell {
82
+ height: min(30rem, 70dvh);
83
+ }
84
+ }
85
+ :global(.js-motion) .term[open] .shell {
86
+ animation: shell-in 260ms var(--ease-out-expo);
87
+ }
88
+ @keyframes shell-in {
89
+ from {
90
+ opacity: 0;
91
+ transform: translateY(10px) scale(0.985);
92
+ }
93
+ }
94
+ #term-input {
95
+ caret-color: var(--color-event);
96
+ caret-shape: block;
97
+ }
98
+ .chip {
99
+ flex-shrink: 0;
100
+ border-radius: 999px;
101
+ border: 1px solid var(--color-line);
102
+ padding: 0.2rem 0.65rem;
103
+ font-family: var(--font-mono);
104
+ font-size: 0.6875rem;
105
+ color: var(--color-fg-muted);
106
+ white-space: nowrap;
107
+ cursor: pointer;
108
+ transition: color 150ms ease, border-color 150ms ease;
109
+ }
110
+ .chip:hover {
111
+ color: var(--color-fg);
112
+ border-color: var(--color-line-strong);
113
+ }
114
+ .chips {
115
+ scrollbar-width: none;
116
+ }
117
+ .out :global(.ln) {
118
+ white-space: pre-wrap;
119
+ overflow-wrap: anywhere;
120
+ }
121
+ .out :global(a) {
122
+ text-decoration: underline;
123
+ text-decoration-color: var(--color-line-strong);
124
+ }
125
+ .out :global(a:hover) {
126
+ color: var(--color-fg);
127
+ }
128
+ </style>
129
+
130
+ <script>
131
+ import { onPageLoad } from '../lib/lifecycle';
132
+
133
+ type Mod = typeof import('../scripts/terminal');
134
+ let mod: Promise<Mod> | null = null;
135
+ const load = () => (mod ??= import('../scripts/terminal'));
136
+ const open = () => void load().then((m) => m.openTerminal());
137
+
138
+ const typing = (el: EventTarget | null) =>
139
+ el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
140
+
141
+ // Bound once per full page load; the router swaps the DOM but keeps these.
142
+ document.addEventListener('keydown', (e) => {
143
+ if ((e.key !== '`' && e.key !== '~') || e.metaKey || e.ctrlKey || e.altKey || typing(e.target)) return;
144
+ if (document.querySelector('dialog[open]')) return;
145
+ e.preventDefault();
146
+ open();
147
+ });
148
+ document.addEventListener('click', (e) => {
149
+ if ((e.target as Element | null)?.closest('[data-terminal-open]')) open();
150
+ });
151
+ document.addEventListener('terminal:open', open);
152
+
153
+ // Warm the chunk when the visitor shows intent, so the first open feels instant.
154
+ onPageLoad(() => {
155
+ const warm = () => void load();
156
+ const triggers = document.querySelectorAll<HTMLElement>('[data-terminal-open]');
157
+ triggers.forEach((t) => t.addEventListener('pointerenter', warm, { once: true }));
158
+ });
159
+ </script>
@@ -0,0 +1,126 @@
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 SectionHeader from './SectionHeader.astro';
6
+ import { posts, themes, blogSource, blogHost, blogFetchedAt, readingMinutes } from '../lib/blog';
7
+ import { snapshotDate } from '../lib/github';
8
+ import { renderInline } from '../lib/inline';
9
+
10
+ interface Props {
11
+ index: string;
12
+ title?: string;
13
+ label?: string;
14
+ intro?: string;
15
+ }
16
+ const { index, title = 'Appended to the *blog.*', label = 'PostPublished', intro } = Astro.props;
17
+ const hours = Math.round(readingMinutes / 60);
18
+ const INITIAL = 8;
19
+ const series = [...new Set(posts.map((p) => p.series).filter(Boolean))];
20
+ const day = (d: string) => new Date(d).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
21
+ ---
22
+
23
+ <section id="writing" class="relative py-24 md:py-36" aria-labelledby="writing-title">
24
+ <div class="container-x">
25
+ <SectionHeader index={index} label={label} title={title} id="writing-title">
26
+ <p class="mt-6 max-w-[62ch] text-lg leading-relaxed text-fg-muted">
27
+ {posts.length} {posts.length === 1 ? 'post' : 'posts'}{blogHost && <> on <a href={blogSource} class="text-fg underline decoration-line-strong hover:decoration-fg" rel="noopener">{blogHost}</a></>}{hours > 0 && <>, about {hours} {hours === 1 ? 'hour' : 'hours'} of reading</>}{series.length > 0 && <>, including the <span class="text-fg">{series[0]}</span> series</>}.
28
+ {intro && <Fragment set:html={' ' + renderInline(intro)} />}
29
+ </p>
30
+ </SectionHeader>
31
+
32
+ {posts.length === 0 && <div class="panel p-8 font-mono text-sm text-fg-faint" data-reveal>EventNotFound: no posts have been captured yet. They appear after the first build that can reach the writing sources.</div>}
33
+ <div class="mb-6 flex flex-wrap gap-2" role="group" aria-label="Filter posts by theme" data-writing-filters hidden>
34
+ <button type="button" class="filter" data-theme-filter="all" aria-pressed="true">All <span class="font-mono text-[0.6875rem] opacity-60">{posts.length}</span></button>
35
+ {
36
+ themes.map((t) => (
37
+ <button type="button" class="filter" data-theme-filter={t.id} aria-pressed="false">
38
+ {t.label} <span class="font-mono text-[0.6875rem] opacity-60">{posts.filter((p) => p.themes.includes(t.id)).length}</span>
39
+ </button>
40
+ ))
41
+ }
42
+ </div>
43
+ <p class="sr-only" aria-live="polite" data-writing-status></p>
44
+
45
+ <ol class="panel divide-y divide-line overflow-hidden" data-writing-list data-reveal>
46
+ {
47
+ posts.map((p, i) => (
48
+ <li class:list={['post', i >= INITIAL && 'extra']} data-themes={p.themes.join(' ')}>
49
+ <a href={p.url} rel="noopener" class="group grid gap-x-6 gap-y-2 px-5 py-5 transition-colors hover:bg-fg/[0.02] md:grid-cols-[7rem_1fr_auto] md:px-7">
50
+ <span class="font-mono text-xs leading-6 text-fg-faint">
51
+ {p.part !== undefined ? <>Part <span class="text-event">{String(p.part).padStart(2, '0')}</span></> : <span class="text-event">Overview</span>}
52
+ </span>
53
+ <span class="min-w-0">
54
+ <span class="block font-display text-lg leading-snug font-[620] tracking-[-0.01em] text-fg transition-colors group-hover:text-event-soft md:text-xl">{p.headline}</span>
55
+ <span class="clamp mt-1.5 block text-[0.9375rem] leading-relaxed text-fg-muted">{p.description}</span>
56
+ <span class="mt-3 flex flex-wrap gap-1.5">
57
+ {p.tags.slice(0, 5).map((t) => <span class="tag px-2 py-0 font-mono text-[0.6875rem]">{t}</span>)}
58
+ </span>
59
+ </span>
60
+ <span class="font-mono text-xs whitespace-nowrap text-fg-faint md:text-right md:leading-6">
61
+ <time datetime={p.date}>{day(p.date)}</time>
62
+ {p.minutes && <span class="md:block"><span class="md:hidden"> · </span>{p.minutes} min read</span>}
63
+ </span>
64
+ </a>
65
+ </li>
66
+ ))
67
+ }
68
+ </ol>
69
+ <div class="mt-5 flex flex-wrap items-center justify-between gap-3">
70
+ {posts.length > INITIAL && <button type="button" class="btn btn-sm" data-writing-more aria-expanded="false">Show all {posts.length} posts</button>}
71
+ <p class="font-mono text-xs text-fg-faint">snapshot taken <time datetime={blogFetchedAt}>{snapshotDate(blogFetchedAt)}</time></p>
72
+ </div>
73
+ </div>
74
+ </section>
75
+
76
+ <style>
77
+ .clamp {
78
+ display: -webkit-box;
79
+ -webkit-line-clamp: 2;
80
+ -webkit-box-orient: vertical;
81
+ overflow: hidden;
82
+ }
83
+ /* Without JS everything shows; with JS the list starts collapsed. */
84
+ :global(.js) [data-writing-list]:not([data-expanded]):not([data-filtered]) .extra {
85
+ display: none;
86
+ }
87
+ </style>
88
+
89
+ <script>
90
+ import { onPageLoad, disposer } from '../lib/lifecycle';
91
+
92
+ onPageLoad(() => {
93
+ const bar = document.querySelector<HTMLElement>('[data-writing-filters]');
94
+ const list = document.querySelector<HTMLElement>('[data-writing-list]');
95
+ const more = document.querySelector<HTMLButtonElement>('[data-writing-more]');
96
+ const status = document.querySelector<HTMLElement>('[data-writing-status]');
97
+ if (!bar || !list) return;
98
+ const d = disposer();
99
+ bar.hidden = false;
100
+ const items = [...list.querySelectorAll<HTMLElement>('.post')];
101
+
102
+ d.on<MouseEvent>(bar, 'click', (e) => {
103
+ const btn = (e.target as Element).closest<HTMLButtonElement>('[data-theme-filter]');
104
+ if (!btn) return;
105
+ const id = btn.dataset['themeFilter'] ?? 'all';
106
+ bar.querySelectorAll('[data-theme-filter]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
107
+ list.toggleAttribute('data-filtered', id !== 'all');
108
+ let shown = 0;
109
+ for (const li of items) {
110
+ const match = id === 'all' || (li.dataset['themes'] ?? '').split(' ').includes(id);
111
+ li.hidden = !match;
112
+ if (match) shown++;
113
+ }
114
+ if (more) more.hidden = id !== 'all';
115
+ if (status) status.textContent = `Showing ${shown} post${shown === 1 ? '' : 's'}`;
116
+ });
117
+ if (more)
118
+ d.on(more, 'click', () => {
119
+ const open = !list.hasAttribute('data-expanded');
120
+ list.toggleAttribute('data-expanded', open);
121
+ more.setAttribute('aria-expanded', String(open));
122
+ more.textContent = open ? 'Show fewer' : `Show all ${items.length} posts`;
123
+ });
124
+ return d.dispose;
125
+ });
126
+ </script>
package/site/data.ts ADDED
@@ -0,0 +1,48 @@
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
+ // The one place components read cast.yaml and build-time data from.
5
+ import { config, github, writing, tier, sections, build, photo, photoUrl } from 'virtual:cast';
6
+
7
+ export { config, github, writing, tier, sections, build, photo, photoUrl };
8
+
9
+ export const person = config.person;
10
+ export const now = config.now;
11
+ export const careerEvents = config.career.events;
12
+ export const cratis = config.cratis;
13
+ export const projects = config.projects;
14
+ export const talks = config.talks;
15
+ export const community = config.community;
16
+ export const education = config.education;
17
+ export const certifications = config.certifications;
18
+ export const skills = config.skills;
19
+ export const agentic = config.agentic;
20
+ export const eventModel = config.eventModel;
21
+ export const stats = config.stats;
22
+
23
+ /** First word of the display name: projections are named `<FirstName>.Now`, `<FirstName>.Career`. */
24
+ export const firstName = person.name.split(/\s+/)[0] ?? person.handle;
25
+
26
+ /** Known link names and how they read. Anything else is shown with its key. */
27
+ const linkLabels: Record<string, string> = {
28
+ github: 'GitHub',
29
+ linkedin: 'LinkedIn',
30
+ mastodon: 'Mastodon',
31
+ bluesky: 'Bluesky',
32
+ x: 'X',
33
+ twitter: 'X',
34
+ youtube: 'YouTube',
35
+ website: 'Website',
36
+ blog: 'Blog',
37
+ };
38
+
39
+ /** Profile links with labels, GitHub first. */
40
+ export const profileLinks: { key: string; label: string; url: string }[] = Object.entries(person.links)
41
+ .map(([key, url]) => ({ key, label: linkLabels[key.toLowerCase()] ?? key, url }))
42
+ .sort((left, right) => Number(right.key === 'github') - Number(left.key === 'github'));
43
+
44
+ /** The GitHub profile URL: from links, else from the handle. */
45
+ export const githubUrl = person.links['github'] ?? `https://github.com/${config.github.login}`;
46
+
47
+ /** The organization whose merged pull requests feed the live feed. */
48
+ export const feedOrg = config.github.orgs[0] ?? 'Cratis';
@@ -0,0 +1,144 @@
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 '@fontsource-variable/inter/wght.css';
6
+ import '@fontsource-variable/jetbrains-mono/wght.css';
7
+ import '@fontsource-variable/fraunces/opsz-italic.css';
8
+ import '../styles/global.css';
9
+ import interUrl from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url';
10
+ import monoUrl from '@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2?url';
11
+ import { ClientRouter } from 'astro:transitions';
12
+ import { config, person, githubUrl, profileLinks } from '../data';
13
+ import { plainText } from '../lib/inline';
14
+ import { accentTokens } from '../lib/accent';
15
+ import { safeJson } from '../lib/safeJson';
16
+ import CommandPalette from '../components/CommandPalette.astro';
17
+ import Terminal from '../components/Terminal.astro';
18
+
19
+ interface Props {
20
+ title: string;
21
+ description: string;
22
+ /** Path of the OG image. */
23
+ image?: string;
24
+ noindex?: boolean;
25
+ bodyClass?: string;
26
+ }
27
+
28
+ const { title, description, image = '/og.png', noindex = false, bodyClass = '' } = Astro.props;
29
+ const canonical = new URL(Astro.url.pathname, Astro.site);
30
+ const ogImage = new URL(image, Astro.site);
31
+ const [firstName, ...rest] = (person.fullName ?? person.name).split(/\s+/);
32
+ const themeMode = config.theme.mode;
33
+ const accent = accentTokens(config.theme.accent);
34
+
35
+ const jsonLd = {
36
+ '@context': 'https://schema.org',
37
+ '@type': 'Person',
38
+ name: person.name,
39
+ alternateName: [person.fullName, person.handle].filter(Boolean),
40
+ jobTitle: person.title,
41
+ ...(person.email ? { email: `mailto:${person.email}` } : {}),
42
+ url: Astro.site?.toString(),
43
+ image: new URL('/apple-touch-icon.png', Astro.site).toString(),
44
+ ...(person.location ? { address: { '@type': 'PostalAddress', addressLocality: person.location.split(',')[0]?.trim(), ...(person.country ? { addressCountry: person.country } : {}) } } : {}),
45
+ knowsAbout: person.interests,
46
+ sameAs: [githubUrl, ...profileLinks.filter((link) => link.key !== 'github').map((link) => link.url)],
47
+ };
48
+ ---
49
+
50
+ <!doctype html>
51
+ <html lang={config.site.language} data-theme-default={themeMode}>
52
+ <head>
53
+ <meta charset="utf-8" />
54
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
55
+ <title>{title}</title>
56
+ <meta name="description" content={plainText(description)} />
57
+ <link rel="canonical" href={canonical} />
58
+ {noindex && <meta name="robots" content="noindex" />}
59
+ <meta name="theme-color" content="#08080e" />
60
+ <meta name="color-scheme" content="dark light" />
61
+ <meta name="author" content={person.name} />
62
+ <meta name="generator" content={Astro.generator} />
63
+ <meta name="cast:handle" content={person.handle} />
64
+
65
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
66
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
67
+ <link rel="sitemap" href="/sitemap-index.xml" />
68
+ <link rel="alternate" type="application/json" href="/cast.json" title="Cast profile" />
69
+ <link rel="alternate" type="text/plain" href="/llms.txt" title="Profile for AI agents" />
70
+
71
+ <link rel="preload" href={interUrl} as="font" type="font/woff2" crossorigin />
72
+ <link rel="preload" href={monoUrl} as="font" type="font/woff2" crossorigin />
73
+
74
+ <meta property="og:type" content="profile" />
75
+ <meta property="og:site_name" content={person.name} />
76
+ <meta property="og:title" content={title} />
77
+ <meta property="og:description" content={plainText(description)} />
78
+ <meta property="og:url" content={canonical} />
79
+ <meta property="og:image" content={ogImage} />
80
+ <meta property="og:image:width" content="1200" />
81
+ <meta property="og:image:height" content="630" />
82
+ <meta property="og:image:alt" content={`${person.name}: ${plainText(person.tagline)}`} />
83
+ {firstName && <meta property="profile:first_name" content={firstName} />}
84
+ {rest.length > 0 && <meta property="profile:last_name" content={rest[rest.length - 1]} />}
85
+ <meta property="profile:username" content={person.handle} />
86
+ <meta name="twitter:card" content="summary_large_image" />
87
+ <meta name="twitter:title" content={title} />
88
+ <meta name="twitter:description" content={plainText(description)} />
89
+ <meta name="twitter:image" content={ogImage} />
90
+
91
+ <script type="application/ld+json" set:html={safeJson(jsonLd)} />
92
+ <style set:html={`:root{--color-accent:${accent.dark};--color-accent-soft:${accent.darkSoft};--color-accent-ink:${accent.ink}}html[data-theme='light']{--color-accent:${accent.light};--color-accent-soft:${accent.lightSoft}}`}></style>
93
+
94
+ <script is:inline>
95
+ /* eslint-disable no-var, no-empty, @typescript-eslint/no-unused-vars -- runs before anything else loads, so it stays ES5 */
96
+ // Apply the stored theme and motion preference before first paint, and carry them
97
+ // over to every page the view-transition router swaps in.
98
+ (function () {
99
+ function apply(d) {
100
+ var preference = document.documentElement.getAttribute('data-theme-default') || 'dark';
101
+ var t = preference === 'light' ? 'light' : 'dark';
102
+ if (preference === 'system') t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
103
+ try {
104
+ var m = localStorage.getItem('cast:motion');
105
+ if (m === 'reduced' || m === 'full') d.dataset.motion = m;
106
+ var stored = localStorage.getItem('cast:theme');
107
+ if (stored === 'light' || stored === 'dark') t = stored;
108
+ } catch (e) {}
109
+ d.dataset.theme = t;
110
+ d.style.colorScheme = t;
111
+ var reduced =
112
+ d.dataset.motion === 'reduced' ||
113
+ (d.dataset.motion !== 'full' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
114
+ if (!reduced) d.classList.add('js-motion');
115
+ d.classList.add('js');
116
+ var meta = d.querySelector('meta[name="theme-color"]');
117
+ if (meta) meta.setAttribute('content', t === 'light' ? '#faf9f6' : '#08080e');
118
+ }
119
+ apply(document.documentElement);
120
+ document.addEventListener('astro:before-swap', function (e) {
121
+ apply(e.newDocument.documentElement);
122
+ });
123
+ })();
124
+ </script>
125
+ <ClientRouter fallback="swap" />
126
+ </head>
127
+ <body class:list={['grain', bodyClass]}>
128
+ <a
129
+ href="#main"
130
+ class="sr-only-focusable fixed top-3 left-3 z-[100] rounded-lg bg-accent px-4 py-2 font-medium text-accent-ink"
131
+ >Skip to content</a
132
+ >
133
+ <slot name="nav" />
134
+ <main id="main" tabindex="-1" class="outline-none">
135
+ <slot />
136
+ </main>
137
+ <slot name="footer" />
138
+ <CommandPalette />
139
+ <Terminal />
140
+ <script>
141
+ import '../scripts/ambient';
142
+ </script>
143
+ </body>
144
+ </html>
@@ -0,0 +1,52 @@
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
+ type Rgb = [number, number, number];
5
+
6
+ const darkGround: Rgb = [8, 8, 14];
7
+ const lightGround: Rgb = [250, 249, 246];
8
+
9
+ const parse = (hex: string): Rgb => {
10
+ const value = Number.parseInt(hex.replace('#', ''), 16);
11
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
12
+ };
13
+
14
+ const format = (color: Rgb): string => `#${color.map(channel => Math.round(channel).toString(16).padStart(2, '0')).join('')}`;
15
+
16
+ const linear = (channel: number): number => {
17
+ const value = channel / 255;
18
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
19
+ };
20
+
21
+ /** WCAG relative luminance. */
22
+ export const luminance = (color: Rgb): number => 0.2126 * linear(color[0]) + 0.7152 * linear(color[1]) + 0.0722 * linear(color[2]);
23
+
24
+ /** WCAG contrast ratio between two colors. */
25
+ export const contrast = (first: Rgb, second: Rgb): number => {
26
+ const [high, low] = [luminance(first), luminance(second)].sort((left, right) => right - left) as [number, number];
27
+ return (high + 0.05) / (low + 0.05);
28
+ };
29
+
30
+ const mix = (color: Rgb, target: Rgb, amount: number): Rgb =>
31
+ color.map((channel, index) => channel + (target[index]! - channel) * amount) as Rgb;
32
+
33
+ /** Moves `color` toward `target` in small steps until it reaches `ratio` against `ground`. */
34
+ const reach = (color: Rgb, target: Rgb, ground: Rgb, ratio: number): Rgb => {
35
+ let current = color;
36
+ for (let step = 0; step < 40 && contrast(current, ground) < ratio; step++) current = mix(current, target, 0.05);
37
+ return current;
38
+ };
39
+
40
+ /** Accent tokens for both themes, each at least AA (4.5:1) for text on its ground. */
41
+ export type AccentTokens = { dark: string; darkSoft: string; light: string; lightSoft: string; ink: string };
42
+
43
+ /** Derives the person's accent tokens from one hex color. */
44
+ export function accentTokens(hex: string): AccentTokens {
45
+ const base = parse(hex);
46
+ const dark = reach(base, [255, 255, 255], darkGround, 4.5);
47
+ const darkSoft = reach(mix(dark, [255, 255, 255], 0.35), [255, 255, 255], darkGround, 7);
48
+ const light = reach(base, [0, 0, 0], lightGround, 4.5);
49
+ const lightSoft = reach(mix(light, [0, 0, 0], 0.2), [0, 0, 0], lightGround, 7);
50
+ const ink = contrast(base, [255, 255, 255]) >= contrast(base, darkGround) ? '#ffffff' : '#0b0a12';
51
+ return { dark: format(dark), darkSoft: format(darkSoft), light: format(light), lightSoft: format(lightSoft), ink };
52
+ }
@@ -0,0 +1,32 @@
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
+ // Typed access to the writing captured at build time.
5
+ import type { Post as RawPost } from '../../writing/WritingSnapshot';
6
+ import { config, writing } from '../data';
7
+
8
+ export type ThemeId = string;
9
+ export type Post = RawPost & { headline: string; series?: string; part?: number; themes: ThemeId[] };
10
+
11
+ /** Reading themes for the filter, each a set of tags, from cast.yaml. */
12
+ export const themes: { id: ThemeId; label: string; tags: string[] }[] = config.writing.themes;
13
+
14
+ /** Splits "Title · Series, Part 3" into its parts. */
15
+ function split(title: string): Pick<Post, 'headline' | 'series' | 'part'> {
16
+ const parts = title.match(/^(.*?)\s+·\s+(.+?),\s+Part\s+(\d+)$/);
17
+ if (parts) return { headline: parts[1]!, series: parts[2]!, part: Number(parts[3]) };
18
+ const series = title.match(/^(.*?)\s+·\s+(.+)$/);
19
+ return series ? { headline: series[1]!, series: series[2]! } : { headline: title };
20
+ }
21
+
22
+ /** Newest first; posts published together keep their series order. */
23
+ export const posts: Post[] = writing.posts
24
+ .map(post => ({ ...post, ...split(post.title), themes: themes.filter(theme => post.tags.some(tag => theme.tags.includes(tag))).map(theme => theme.id) }))
25
+ .sort((left, right) => right.date.slice(0, 10).localeCompare(left.date.slice(0, 10)) || (left.part ?? 0) - (right.part ?? 0));
26
+
27
+ /** The first source, for "on blog.example.com" links. */
28
+ export const blogSource: string = writing.sources[0] ?? '';
29
+ export const blogHost: string = blogSource ? new URL(blogSource).host : '';
30
+ export const blogFetchedAt = writing.fetchedAt;
31
+ export const readingMinutes = posts.reduce((total, post) => total + (post.minutes ?? 0), 0);
32
+ export const postsFor = (theme: ThemeId | undefined): Post[] => (theme ? posts.filter(post => post.themes.includes(theme)) : []);