@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.
- package/README.md +13 -1
- package/badges/RegistryMember.ts +18 -0
- package/badges/ResolvedTier.ts +17 -0
- package/badges/Tier.ts +14 -0
- package/badges/TierSource.ts +14 -0
- package/badges/badgeSvg.ts +137 -0
- package/badges/constants.ts +17 -0
- package/badges/cratisMark.ts +15 -0
- package/badges/index.ts +12 -0
- package/badges/registry.ts +68 -0
- package/badges/resolveTier.ts +24 -0
- package/cast-json.schema.json +1496 -0
- package/cast.schema.json +1113 -0
- package/config/CastConfig.ts +17 -0
- package/config/CastConfigError.ts +10 -0
- package/config/LiveStat.ts +17 -0
- package/config/ResolvedSection.ts +13 -0
- package/config/SectionId.ts +21 -0
- package/config/ThemeMode.ts +9 -0
- package/config/calendar.ts +34 -0
- package/config/castSchema.ts +263 -0
- package/config/index.ts +12 -0
- package/config/parseCast.ts +58 -0
- package/config/resolveSections.ts +48 -0
- package/github/GitHubSnapshot.ts +66 -0
- package/github/fetchGitHub.ts +235 -0
- package/github/gitHubSnapshotSchema.ts +58 -0
- package/github/parse.ts +63 -0
- package/index.ts +88 -0
- package/integration/CastData.ts +20 -0
- package/integration/CastOptions.ts +17 -0
- package/integration/Snapshot.ts +12 -0
- package/integration/loadRegistry.ts +37 -0
- package/integration/resolveData.ts +138 -0
- package/integration/snapshotFile.ts +23 -0
- package/integration/virtualModule.ts +43 -0
- package/machine/castJson.ts +77 -0
- package/machine/castJsonSchema.ts +35 -0
- package/machine/index.ts +6 -0
- package/machine/llmsText.ts +69 -0
- package/machine/ogImageSvg.ts +79 -0
- package/machine/profileFromCastJson.ts +214 -0
- package/machine/resumeJson.ts +87 -0
- package/package.json +80 -8
- package/registry/members.yaml +16 -0
- package/routes/404.astro +42 -0
- package/routes/apple-touch-icon.png.ts +17 -0
- package/routes/badge-card.svg.ts +13 -0
- package/routes/badge.svg.ts +11 -0
- package/routes/cast.json.ts +17 -0
- package/routes/cv.astro +9 -0
- package/routes/favicon.svg.ts +16 -0
- package/routes/index.astro +9 -0
- package/routes/llms.txt.ts +10 -0
- package/routes/og.png.ts +23 -0
- package/routes/resume.json.ts +13 -0
- package/routes/robots.txt.ts +10 -0
- package/site/components/Activity.astro +196 -0
- package/site/components/Agentic.astro +144 -0
- package/site/components/CareerReplay.astro +415 -0
- package/site/components/CastBadge.astro +132 -0
- package/site/components/CommandPalette.astro +122 -0
- package/site/components/Contact.astro +166 -0
- package/site/components/Cratis.astro +235 -0
- package/site/components/CratisMark.astro +18 -0
- package/site/components/EventModel.astro +265 -0
- package/site/components/Footer.astro +33 -0
- package/site/components/Hero.astro +211 -0
- package/site/components/Inline.astro +45 -0
- package/site/components/Monogram.astro +28 -0
- package/site/components/Nav.astro +127 -0
- package/site/components/Now.astro +116 -0
- package/site/components/Projects.astro +185 -0
- package/site/components/PullRequestFeed.astro +88 -0
- package/site/components/SectionHeader.astro +30 -0
- package/site/components/Skills.astro +44 -0
- package/site/components/Stats.astro +106 -0
- package/site/components/Talks.astro +76 -0
- package/site/components/Terminal.astro +164 -0
- package/site/components/Writing.astro +128 -0
- package/site/data.ts +11 -0
- package/site/layouts/Base.astro +156 -0
- package/site/lib/accent.ts +52 -0
- package/site/lib/blog.ts +58 -0
- package/site/lib/career.ts +148 -0
- package/site/lib/clipboard.ts +12 -0
- package/site/lib/contributions.ts +63 -0
- package/site/lib/github.ts +72 -0
- package/site/lib/inline.ts +79 -0
- package/site/lib/lifecycle.ts +44 -0
- package/site/lib/motion.ts +43 -0
- package/site/lib/nav.ts +49 -0
- package/site/lib/reltime.ts +33 -0
- package/site/lib/safeJson.ts +15 -0
- package/site/lib/sections.ts +41 -0
- package/site/lib/theme.ts +28 -0
- package/site/pages/CvPage.astro +379 -0
- package/site/pages/ProfilePage.astro +78 -0
- package/site/profile/CastHost.ts +32 -0
- package/site/profile/CastView.ts +96 -0
- package/site/profile/assertRenderable.ts +151 -0
- package/site/profile/clientCast.ts +117 -0
- package/site/profile/index.ts +8 -0
- package/site/scripts/ambient.ts +93 -0
- package/site/scripts/event-board.ts +403 -0
- package/site/scripts/event-model.ts +155 -0
- package/site/scripts/heatmap.ts +94 -0
- package/site/scripts/live.ts +155 -0
- package/site/scripts/palette.ts +313 -0
- package/site/scripts/replay.ts +252 -0
- package/site/scripts/terminal.ts +559 -0
- package/site/styles/global.css +508 -0
- package/virtual.d.ts +15 -0
- package/writing/WritingSnapshot.ts +20 -0
- package/writing/fetchWriting.ts +69 -0
- package/writing/parse.ts +72 -0
- package/writing/writingSnapshotSchema.ts +27 -0
|
@@ -0,0 +1,106 @@
|
|
|
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 type { CastView } from '../profile/CastView';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
cast: CastView;
|
|
9
|
+
}
|
|
10
|
+
const { cast } = Astro.props;
|
|
11
|
+
const { stats: siteStats, cratis, github, cratisRepos, mergedPrFloor, snapshotDate } = cast;
|
|
12
|
+
|
|
13
|
+
type Stat = (typeof siteStats)[number];
|
|
14
|
+
const rankOne = cratisRepos.filter((r) => r.rank === 1).length;
|
|
15
|
+
const live: Record<string, (s: Stat) => Partial<Stat>> = {
|
|
16
|
+
// Floored to hundreds so the "+" stays true between hourly builds.
|
|
17
|
+
contributionsLastYear: () => (github.contributions.total ? { value: Math.floor(github.contributions.total / 100) * 100 } : {}),
|
|
18
|
+
mergedPullRequests: () => (mergedPrFloor ? { value: mergedPrFloor } : {}),
|
|
19
|
+
rankOneRepositories: (s) => (rankOne ? { label: s.label.replace(/\d+/, String(rankOne)) } : {}),
|
|
20
|
+
clientLanguages: () => (cratis?.clientLanguages.length ? { value: cratis.clientLanguages.length } : {}),
|
|
21
|
+
};
|
|
22
|
+
const stats = siteStats.map((s) => ({ ...s, ...(s.live ? live[s.live]?.(s) ?? {} : {}) }));
|
|
23
|
+
const fmt = new Intl.NumberFormat('en-US');
|
|
24
|
+
const accents = ['text-event', 'text-command', 'text-view', 'text-fg'];
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<section id="stats" aria-label="By the numbers" class="relative border-y border-line bg-ink-900">
|
|
28
|
+
<div class="container-x">
|
|
29
|
+
<dl class="grid grid-cols-2 gap-px bg-line lg:grid-cols-4">
|
|
30
|
+
{
|
|
31
|
+
stats.map((s, i) => {
|
|
32
|
+
const plain = s.plain === true;
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
class="flex flex-col gap-3 bg-ink-900 px-5 py-10 first:pl-0 md:px-10 md:py-14 [&:nth-child(3)]:pl-0 lg:[&:nth-child(3)]:pl-10"
|
|
36
|
+
data-reveal
|
|
37
|
+
style={`--reveal-delay:${i * 80}ms`}
|
|
38
|
+
>
|
|
39
|
+
<dt class="order-2 max-w-[18ch] text-sm leading-snug text-fg-muted">{s.label}
|
|
40
|
+
{s.live === 'mergedPullRequests' && <span data-pr-status class="mt-1 block font-mono text-[0.6875rem] text-fg-faint">as of {snapshotDate()}</span>}
|
|
41
|
+
</dt>
|
|
42
|
+
<dd class={`order-1 font-display text-[clamp(2.5rem,5vw,4rem)] leading-none font-[650] tracking-[-0.04em] tabular-nums ${accents[i % accents.length]}`}>
|
|
43
|
+
{s.prefix}<span data-merged-prs={s.live === 'mergedPullRequests' ? 'number' : undefined} data-count={plain ? undefined : s.value}>{plain ? s.value : fmt.format(s.value)}</span>
|
|
44
|
+
{s.suffix && <span class="text-fg-faint">{s.suffix}</span>}
|
|
45
|
+
</dd>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
</dl>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<script>
|
|
55
|
+
import { motionReduced } from '../lib/motion';
|
|
56
|
+
import { onPageLoad } from '../lib/lifecycle';
|
|
57
|
+
onPageLoad(() => {
|
|
58
|
+
const fmt = new Intl.NumberFormat('en-US');
|
|
59
|
+
const els = document.querySelectorAll<HTMLElement>('[data-count]');
|
|
60
|
+
const frames = new Set<number>();
|
|
61
|
+
let io: IntersectionObserver | undefined;
|
|
62
|
+
if (!motionReduced() && 'IntersectionObserver' in window) {
|
|
63
|
+
const run = (el: HTMLElement) => {
|
|
64
|
+
const target = Number(el.dataset['count']);
|
|
65
|
+
const duration = 1600;
|
|
66
|
+
const start = performance.now();
|
|
67
|
+
let frame: number;
|
|
68
|
+
const step = (ts: number) => {
|
|
69
|
+
frames.delete(frame);
|
|
70
|
+
// A browser refresh supersedes this build-time tween.
|
|
71
|
+
if (el.dataset['live'] === 'true') return;
|
|
72
|
+
const p = Math.min(1, (ts - start) / duration);
|
|
73
|
+
const eased = 1 - Math.pow(1 - p, 4);
|
|
74
|
+
el.textContent = fmt.format(Math.round(target * eased));
|
|
75
|
+
if (p < 1 && !motionReduced()) {
|
|
76
|
+
frame = requestAnimationFrame(step);
|
|
77
|
+
frames.add(frame);
|
|
78
|
+
} else el.textContent = fmt.format(target);
|
|
79
|
+
};
|
|
80
|
+
frame = requestAnimationFrame(step);
|
|
81
|
+
frames.add(frame);
|
|
82
|
+
};
|
|
83
|
+
io = new IntersectionObserver(
|
|
84
|
+
(entries) => {
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
if (!e.isIntersecting) continue;
|
|
87
|
+
io?.unobserve(e.target);
|
|
88
|
+
run(e.target as HTMLElement);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{ threshold: 0.6 },
|
|
92
|
+
);
|
|
93
|
+
els.forEach((el) => {
|
|
94
|
+
// Reserve the final width so counting never shifts layout.
|
|
95
|
+
el.style.display = 'inline-block';
|
|
96
|
+
el.style.minWidth = `${el.getBoundingClientRect().width}px`;
|
|
97
|
+
el.textContent = '0';
|
|
98
|
+
io?.observe(el);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
io?.disconnect();
|
|
103
|
+
frames.forEach((frame) => cancelAnimationFrame(frame));
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,76 @@
|
|
|
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 type { CastView } from '../profile/CastView';
|
|
6
|
+
import Inline from './Inline.astro';
|
|
7
|
+
import { formatMonth } from '../lib/career';
|
|
8
|
+
import SectionHeader from './SectionHeader.astro';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
cast: CastView;
|
|
12
|
+
index: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
const { index, title = 'Talks & *community.*', label = 'TalkGiven' } = Astro.props;
|
|
17
|
+
const { cast } = Astro.props;
|
|
18
|
+
const { talks, community } = cast;
|
|
19
|
+
const talksNewest = [...talks].sort((a, b) => b.date.localeCompare(a.date));
|
|
20
|
+
const day = (d: string) =>
|
|
21
|
+
d.length === 10 ? new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : formatMonth(d);
|
|
22
|
+
const languageName = (lang: string) => {
|
|
23
|
+
try {
|
|
24
|
+
return new Intl.DisplayNames(['en'], { type: 'language' }).of(lang) ?? lang;
|
|
25
|
+
} catch {
|
|
26
|
+
return lang;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<section id="talks" class="relative py-24 md:py-32" aria-labelledby="talks-title">
|
|
32
|
+
<div class="container-x grid grid-cols-1 items-start gap-x-12 lg:grid-cols-12">
|
|
33
|
+
<div class="lg:sticky lg:top-[calc(var(--nav-h)+2rem)] lg:col-span-5 [&>header]:mb-10 lg:[&>header]:mb-0">
|
|
34
|
+
<SectionHeader host={cast.host} index={index} label={label} title={title} id="talks-title" />
|
|
35
|
+
</div>
|
|
36
|
+
<div class="grid gap-4 lg:col-span-7">
|
|
37
|
+
{talksNewest.length > 0 && <ul class="grid grid-cols-1 gap-4">
|
|
38
|
+
{
|
|
39
|
+
talksNewest.map((t) => {
|
|
40
|
+
const Tag = t.url ? 'a' : 'div';
|
|
41
|
+
return (
|
|
42
|
+
<li data-reveal>
|
|
43
|
+
<Tag href={t.url} rel={t.url ? cast.host.rel : undefined} class:list={['panel flex h-full flex-col gap-4 p-6 md:p-7', t.url && 'glow group']}>
|
|
44
|
+
<span class="flex flex-wrap items-center justify-between gap-2 font-mono text-xs text-fg-faint">
|
|
45
|
+
<span class="note">TalkGiven</span>
|
|
46
|
+
<span><time datetime={t.date}>{day(t.date)}</time>{!t.lang.startsWith('en') && <> · in {languageName(t.lang)}</>}</span>
|
|
47
|
+
</span>
|
|
48
|
+
<span lang={t.lang} class="font-display text-2xl leading-tight font-[700] tracking-[-0.025em] transition-colors group-hover:text-event-soft md:text-[1.75rem]">{t.title}</span>
|
|
49
|
+
<span class="text-fg-muted"><Inline text={t.summary} host={cast.host} /></span>
|
|
50
|
+
<span class="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-2 text-sm text-fg-muted">
|
|
51
|
+
{t.url && <svg viewBox="0 0 16 16" class="size-4 text-event" fill="currentColor" aria-hidden="true"><path d="M5 3.5v9a.5.5 0 0 0 .77.42l7-4.5a.5.5 0 0 0 0-.84l-7-4.5A.5.5 0 0 0 5 3.5Z" /></svg>}
|
|
52
|
+
{t.url ? 'Watch · ' : ''}{t.host}
|
|
53
|
+
{t.with && <span class="text-fg-faint">· with {t.with}</span>}
|
|
54
|
+
</span>
|
|
55
|
+
</Tag>
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
</ul>}
|
|
61
|
+
{community.length > 0 && <div class="panel p-5 md:px-7" data-reveal>
|
|
62
|
+
<p class="note note-view">Community</p>
|
|
63
|
+
<ul class="mt-3 divide-y divide-line">
|
|
64
|
+
{
|
|
65
|
+
community.map((c) => (
|
|
66
|
+
<li class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2.5">
|
|
67
|
+
<span class="text-fg">{c.title}{c.detail && <span class="text-fg-muted"> · {c.detail}</span>}</span>
|
|
68
|
+
<span class="font-mono text-xs text-fg-faint">attended · {formatMonth(c.date)}</span>
|
|
69
|
+
</li>
|
|
70
|
+
))
|
|
71
|
+
}
|
|
72
|
+
</ul>
|
|
73
|
+
</div>}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
@@ -0,0 +1,164 @@
|
|
|
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 type { CastView } from '../profile/CastView';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
cast: CastView;
|
|
11
|
+
}
|
|
12
|
+
const { person, projects } = Astro.props.cast;
|
|
13
|
+
const firstProject = projects[0]?.name;
|
|
14
|
+
const chips = ['help', 'whoami', 'now', 'git log --oneline', 'stream tail', 'ls projects', ...(firstProject ? [`cat ${firstProject}`] : []), 'contact', 'replay'];
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<dialog class="term" data-terminal aria-label="Terminal" aria-describedby="term-hint" transition:persist={`terminal-${person.handle.toLowerCase()}`}>
|
|
18
|
+
<div class="shell panel flex flex-col overflow-hidden">
|
|
19
|
+
<div class="flex items-center gap-3 border-b border-line px-4 py-2.5">
|
|
20
|
+
<span class="flex gap-1.5" aria-hidden="true">
|
|
21
|
+
<span class="size-2.5 rounded-full bg-[#ff5f57]/80"></span>
|
|
22
|
+
<span class="size-2.5 rounded-full bg-[#febc2e]/80"></span>
|
|
23
|
+
<span class="size-2.5 rounded-full bg-[#28c840]/80"></span>
|
|
24
|
+
</span>
|
|
25
|
+
<p class="flex-1 truncate text-center font-mono text-xs text-fg-muted">{person.handle}@stream: ~</p>
|
|
26
|
+
<button type="button" class="kbd cursor-pointer" data-terminal-close aria-label="Close terminal">esc</button>
|
|
27
|
+
</div>
|
|
28
|
+
<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>
|
|
29
|
+
<form class="flex items-center gap-2 border-t border-line px-4 py-3 font-mono text-[0.8125rem]" data-terminal-form>
|
|
30
|
+
<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>
|
|
31
|
+
<input
|
|
32
|
+
id="term-input"
|
|
33
|
+
type="text"
|
|
34
|
+
class="min-w-0 flex-1 bg-transparent text-fg focus:outline-none"
|
|
35
|
+
autocomplete="off"
|
|
36
|
+
autocapitalize="off"
|
|
37
|
+
autocorrect="off"
|
|
38
|
+
spellcheck="false"
|
|
39
|
+
enterkeyhint="go"
|
|
40
|
+
aria-describedby="term-hint"
|
|
41
|
+
data-terminal-input
|
|
42
|
+
/>
|
|
43
|
+
</form>
|
|
44
|
+
<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>
|
|
45
|
+
{chips.map((c) => <button type="button" class="chip" data-cmd={c}>{c}</button>)}
|
|
46
|
+
</div>
|
|
47
|
+
<p id="term-hint" class="sr-only">Type help for a list of commands. Up and down browse history, Tab completes, Escape closes.</p>
|
|
48
|
+
</div>
|
|
49
|
+
</dialog>
|
|
50
|
+
|
|
51
|
+
<style>
|
|
52
|
+
.term {
|
|
53
|
+
position: fixed;
|
|
54
|
+
inset: 0;
|
|
55
|
+
margin: 0;
|
|
56
|
+
padding: 10vh 1rem 1rem;
|
|
57
|
+
width: 100%;
|
|
58
|
+
max-width: none;
|
|
59
|
+
height: 100%;
|
|
60
|
+
max-height: none;
|
|
61
|
+
background: transparent;
|
|
62
|
+
color: inherit;
|
|
63
|
+
border: 0;
|
|
64
|
+
}
|
|
65
|
+
.term:not([open]) {
|
|
66
|
+
display: none;
|
|
67
|
+
}
|
|
68
|
+
.term::backdrop {
|
|
69
|
+
background: rgb(4 4 8 / 0.6);
|
|
70
|
+
backdrop-filter: blur(6px);
|
|
71
|
+
}
|
|
72
|
+
.shell {
|
|
73
|
+
margin-inline: auto;
|
|
74
|
+
max-width: 46rem;
|
|
75
|
+
height: min(34rem, 80dvh);
|
|
76
|
+
background: color-mix(in oklab, var(--color-ink-900) 94%, transparent);
|
|
77
|
+
box-shadow:
|
|
78
|
+
inset 0 1px 0 color-mix(in srgb, var(--color-fg) 6%, transparent),
|
|
79
|
+
0 50px 120px -20px rgb(0 0 0 / 0.8),
|
|
80
|
+
0 0 0 1px color-mix(in srgb, var(--color-fg) 4%, transparent);
|
|
81
|
+
}
|
|
82
|
+
@media (max-width: 639px) {
|
|
83
|
+
.term {
|
|
84
|
+
padding: 0.75rem;
|
|
85
|
+
}
|
|
86
|
+
.shell {
|
|
87
|
+
height: min(30rem, 70dvh);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
:global(.js-motion) .term[open] .shell {
|
|
91
|
+
animation: shell-in 260ms var(--ease-out-expo);
|
|
92
|
+
}
|
|
93
|
+
@keyframes shell-in {
|
|
94
|
+
from {
|
|
95
|
+
opacity: 0;
|
|
96
|
+
transform: translateY(10px) scale(0.985);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
#term-input {
|
|
100
|
+
caret-color: var(--color-event);
|
|
101
|
+
caret-shape: block;
|
|
102
|
+
}
|
|
103
|
+
.chip {
|
|
104
|
+
flex-shrink: 0;
|
|
105
|
+
border-radius: 999px;
|
|
106
|
+
border: 1px solid var(--color-line);
|
|
107
|
+
padding: 0.2rem 0.65rem;
|
|
108
|
+
font-family: var(--font-mono);
|
|
109
|
+
font-size: 0.6875rem;
|
|
110
|
+
color: var(--color-fg-muted);
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
transition: color 150ms ease, border-color 150ms ease;
|
|
114
|
+
}
|
|
115
|
+
.chip:hover {
|
|
116
|
+
color: var(--color-fg);
|
|
117
|
+
border-color: var(--color-line-strong);
|
|
118
|
+
}
|
|
119
|
+
.chips {
|
|
120
|
+
scrollbar-width: none;
|
|
121
|
+
}
|
|
122
|
+
.out :global(.ln) {
|
|
123
|
+
white-space: pre-wrap;
|
|
124
|
+
overflow-wrap: anywhere;
|
|
125
|
+
}
|
|
126
|
+
.out :global(a) {
|
|
127
|
+
text-decoration: underline;
|
|
128
|
+
text-decoration-color: var(--color-line-strong);
|
|
129
|
+
}
|
|
130
|
+
.out :global(a:hover) {
|
|
131
|
+
color: var(--color-fg);
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
134
|
+
|
|
135
|
+
<script>
|
|
136
|
+
import { onPageLoad } from '../lib/lifecycle';
|
|
137
|
+
|
|
138
|
+
type Mod = typeof import('../scripts/terminal');
|
|
139
|
+
let mod: Promise<Mod> | null = null;
|
|
140
|
+
const load = () => (mod ??= import('../scripts/terminal'));
|
|
141
|
+
const open = () => void load().then((m) => m.openTerminal());
|
|
142
|
+
|
|
143
|
+
const typing = (el: EventTarget | null) =>
|
|
144
|
+
el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
|
|
145
|
+
|
|
146
|
+
// Bound once per full page load; the router swaps the DOM but keeps these.
|
|
147
|
+
document.addEventListener('keydown', (e) => {
|
|
148
|
+
if ((e.key !== '`' && e.key !== '~') || e.metaKey || e.ctrlKey || e.altKey || typing(e.target)) return;
|
|
149
|
+
if (document.querySelector('dialog[open]')) return;
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
open();
|
|
152
|
+
});
|
|
153
|
+
document.addEventListener('click', (e) => {
|
|
154
|
+
if ((e.target as Element | null)?.closest('[data-terminal-open]')) open();
|
|
155
|
+
});
|
|
156
|
+
document.addEventListener('terminal:open', open);
|
|
157
|
+
|
|
158
|
+
// Warm the chunk when the visitor shows intent, so the first open feels instant.
|
|
159
|
+
onPageLoad(() => {
|
|
160
|
+
const warm = () => void load();
|
|
161
|
+
const triggers = document.querySelectorAll<HTMLElement>('[data-terminal-open]');
|
|
162
|
+
triggers.forEach((t) => t.addEventListener('pointerenter', warm, { once: true }));
|
|
163
|
+
});
|
|
164
|
+
</script>
|
|
@@ -0,0 +1,128 @@
|
|
|
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 type { CastView } from '../profile/CastView';
|
|
6
|
+
import Inline from './Inline.astro';
|
|
7
|
+
import SectionHeader from './SectionHeader.astro';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
cast: CastView;
|
|
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 { cast } = Astro.props;
|
|
18
|
+
const { snapshotDate, posts, themes, blogSource, blogHost, blogFetchedAt, readingMinutes } = cast;
|
|
19
|
+
const hours = Math.round(readingMinutes / 60);
|
|
20
|
+
const INITIAL = 8;
|
|
21
|
+
const series = [...new Set(posts.map((p) => p.series).filter(Boolean))];
|
|
22
|
+
const day = (d: string) => new Date(d).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<section id="writing" class="relative py-24 md:py-36" aria-labelledby="writing-title">
|
|
26
|
+
<div class="container-x">
|
|
27
|
+
<SectionHeader host={cast.host} index={index} label={label} title={title} id="writing-title">
|
|
28
|
+
<p class="mt-6 max-w-[62ch] text-lg leading-relaxed text-fg-muted">
|
|
29
|
+
{posts.length} {posts.length === 1 ? 'post' : 'posts'}{blogHost && <> on <a href={blogSource} class="text-fg underline decoration-line-strong hover:decoration-fg" rel={cast.host.rel}>{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</>}.
|
|
30
|
+
{intro && <> <Inline text={intro} host={cast.host} /></>}
|
|
31
|
+
</p>
|
|
32
|
+
</SectionHeader>
|
|
33
|
+
|
|
34
|
+
{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>}
|
|
35
|
+
<div class="mb-6 flex flex-wrap gap-2" role="group" aria-label="Filter posts by theme" data-writing-filters hidden>
|
|
36
|
+
<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>
|
|
37
|
+
{
|
|
38
|
+
themes.map((t) => (
|
|
39
|
+
<button type="button" class="filter" data-theme-filter={t.id} aria-pressed="false">
|
|
40
|
+
{t.label} <span class="font-mono text-[0.6875rem] opacity-60">{posts.filter((p) => p.themes.includes(t.id)).length}</span>
|
|
41
|
+
</button>
|
|
42
|
+
))
|
|
43
|
+
}
|
|
44
|
+
</div>
|
|
45
|
+
<p class="sr-only" aria-live="polite" data-writing-status></p>
|
|
46
|
+
|
|
47
|
+
<ol class="panel divide-y divide-line overflow-hidden" data-writing-list data-reveal>
|
|
48
|
+
{
|
|
49
|
+
posts.map((p, i) => (
|
|
50
|
+
<li class:list={['post', i >= INITIAL && 'extra']} data-themes={p.themes.join(' ')}>
|
|
51
|
+
<a href={p.url} rel={cast.host.rel} 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">
|
|
52
|
+
<span class="font-mono text-xs leading-6 text-fg-faint">
|
|
53
|
+
{p.part !== undefined ? <>Part <span class="text-event">{String(p.part).padStart(2, '0')}</span></> : <span class="text-event">Overview</span>}
|
|
54
|
+
</span>
|
|
55
|
+
<span class="min-w-0">
|
|
56
|
+
<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>
|
|
57
|
+
<span class="clamp mt-1.5 block text-[0.9375rem] leading-relaxed text-fg-muted">{p.description}</span>
|
|
58
|
+
<span class="mt-3 flex flex-wrap gap-1.5">
|
|
59
|
+
{p.tags.slice(0, 5).map((t) => <span class="tag px-2 py-0 font-mono text-[0.6875rem]">{t}</span>)}
|
|
60
|
+
</span>
|
|
61
|
+
</span>
|
|
62
|
+
<span class="font-mono text-xs whitespace-nowrap text-fg-faint md:text-right md:leading-6">
|
|
63
|
+
<time datetime={p.date}>{day(p.date)}</time>
|
|
64
|
+
{p.minutes && <span class="md:block"><span class="md:hidden"> · </span>{p.minutes} min read</span>}
|
|
65
|
+
</span>
|
|
66
|
+
</a>
|
|
67
|
+
</li>
|
|
68
|
+
))
|
|
69
|
+
}
|
|
70
|
+
</ol>
|
|
71
|
+
<div class="mt-5 flex flex-wrap items-center justify-between gap-3">
|
|
72
|
+
{posts.length > INITIAL && <button type="button" class="btn btn-sm" data-writing-more aria-expanded="false">Show all {posts.length} posts</button>}
|
|
73
|
+
<p class="font-mono text-xs text-fg-faint">snapshot taken <time datetime={blogFetchedAt}>{snapshotDate(blogFetchedAt)}</time></p>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.clamp {
|
|
80
|
+
display: -webkit-box;
|
|
81
|
+
-webkit-line-clamp: 2;
|
|
82
|
+
-webkit-box-orient: vertical;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
/* Without JS everything shows; with JS the list starts collapsed. */
|
|
86
|
+
:global(.js) [data-writing-list]:not([data-expanded]):not([data-filtered]) .extra {
|
|
87
|
+
display: none;
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
90
|
+
|
|
91
|
+
<script>
|
|
92
|
+
import { onPageLoad, disposer } from '../lib/lifecycle';
|
|
93
|
+
|
|
94
|
+
onPageLoad(() => {
|
|
95
|
+
const bar = document.querySelector<HTMLElement>('[data-writing-filters]');
|
|
96
|
+
const list = document.querySelector<HTMLElement>('[data-writing-list]');
|
|
97
|
+
const more = document.querySelector<HTMLButtonElement>('[data-writing-more]');
|
|
98
|
+
const status = document.querySelector<HTMLElement>('[data-writing-status]');
|
|
99
|
+
if (!bar || !list) return;
|
|
100
|
+
const d = disposer();
|
|
101
|
+
bar.hidden = false;
|
|
102
|
+
const items = [...list.querySelectorAll<HTMLElement>('.post')];
|
|
103
|
+
|
|
104
|
+
d.on<MouseEvent>(bar, 'click', (e) => {
|
|
105
|
+
const btn = (e.target as Element).closest<HTMLButtonElement>('[data-theme-filter]');
|
|
106
|
+
if (!btn) return;
|
|
107
|
+
const id = btn.dataset['themeFilter'] ?? 'all';
|
|
108
|
+
bar.querySelectorAll('[data-theme-filter]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
|
|
109
|
+
list.toggleAttribute('data-filtered', id !== 'all');
|
|
110
|
+
let shown = 0;
|
|
111
|
+
for (const li of items) {
|
|
112
|
+
const match = id === 'all' || (li.dataset['themes'] ?? '').split(' ').includes(id);
|
|
113
|
+
li.hidden = !match;
|
|
114
|
+
if (match) shown++;
|
|
115
|
+
}
|
|
116
|
+
if (more) more.hidden = id !== 'all';
|
|
117
|
+
if (status) status.textContent = `Showing ${shown} post${shown === 1 ? '' : 's'}`;
|
|
118
|
+
});
|
|
119
|
+
if (more)
|
|
120
|
+
d.on(more, 'click', () => {
|
|
121
|
+
const open = !list.hasAttribute('data-expanded');
|
|
122
|
+
list.toggleAttribute('data-expanded', open);
|
|
123
|
+
more.setAttribute('aria-expanded', String(open));
|
|
124
|
+
more.textContent = open ? 'Show fewer' : `Show all ${items.length} posts`;
|
|
125
|
+
});
|
|
126
|
+
return d.dispose;
|
|
127
|
+
});
|
|
128
|
+
</script>
|
package/site/data.ts
ADDED
|
@@ -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
|
+
// The site's routes read the build's data here. Components never import it: they take the view as a prop,
|
|
5
|
+
// so the Cast directory can render them for any member.
|
|
6
|
+
import { config, github, writing, tier, sections, build, photo, photoUrl } from 'virtual:cast';
|
|
7
|
+
import { siteHost } from './profile/CastHost';
|
|
8
|
+
import { castView, type CastView } from './profile/CastView';
|
|
9
|
+
|
|
10
|
+
/** This site's profile, rendered on its own site. */
|
|
11
|
+
export const cast: CastView = castView({ config, github, writing, tier, sections, build, ...(photo ? { photo } : {}), ...(photoUrl ? { photoUrl } : {}) }, siteHost);
|
|
@@ -0,0 +1,156 @@
|
|
|
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 type { CastView } from '../profile/CastView';
|
|
13
|
+
import { clientCast } from '../profile/clientCast';
|
|
14
|
+
import { plainText } from '../lib/inline';
|
|
15
|
+
import { accentTokens } from '../lib/accent';
|
|
16
|
+
import { safeJson } from '../lib/safeJson';
|
|
17
|
+
import CommandPalette from '../components/CommandPalette.astro';
|
|
18
|
+
import Terminal from '../components/Terminal.astro';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
cast: CastView;
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
/** Path of the OG image under the profile. Defaults to og.png on a Cast site and the portrait elsewhere. */
|
|
25
|
+
image?: string;
|
|
26
|
+
noindex?: boolean;
|
|
27
|
+
bodyClass?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { cast, title, description, image, noindex = false, bodyClass = '' } = Astro.props;
|
|
31
|
+
const { config, person, githubUrl, profileLinks, host } = cast;
|
|
32
|
+
const canonical = new URL(Astro.url.pathname, Astro.site);
|
|
33
|
+
const ogImageUrl = image ? cast.href(image) : host.siteFiles ? cast.href('og.png') : cast.photoUrl;
|
|
34
|
+
const ogImage = ogImageUrl ? new URL(ogImageUrl, Astro.site) : undefined;
|
|
35
|
+
const iconUrl = host.siteFiles ? cast.href('favicon.svg') : '/favicon.svg';
|
|
36
|
+
const [firstName, ...rest] = (person.fullName ?? person.name).split(/\s+/);
|
|
37
|
+
const themeMode = config.theme.mode;
|
|
38
|
+
const accent = accentTokens(config.theme.accent);
|
|
39
|
+
|
|
40
|
+
const jsonLd = {
|
|
41
|
+
'@context': 'https://schema.org',
|
|
42
|
+
'@type': 'Person',
|
|
43
|
+
name: person.name,
|
|
44
|
+
alternateName: [person.fullName, person.handle].filter(Boolean),
|
|
45
|
+
jobTitle: person.title,
|
|
46
|
+
...(person.email ? { email: `mailto:${person.email}` } : {}),
|
|
47
|
+
url: host.siteFiles ? Astro.site?.toString() : config.site.url,
|
|
48
|
+
...(host.siteFiles ? { image: new URL(cast.href('apple-touch-icon.png'), Astro.site).toString() } : cast.photoUrl ? { image: cast.photoUrl } : {}),
|
|
49
|
+
...(person.location ? { address: { '@type': 'PostalAddress', addressLocality: person.location.split(',')[0]?.trim(), ...(person.country ? { addressCountry: person.country } : {}) } } : {}),
|
|
50
|
+
knowsAbout: person.interests,
|
|
51
|
+
sameAs: [githubUrl, ...profileLinks.filter((link) => link.key !== 'github').map((link) => link.url)],
|
|
52
|
+
};
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<!doctype html>
|
|
56
|
+
<html lang={config.site.language} data-theme-default={themeMode}>
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="utf-8" />
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
60
|
+
<title>{title}</title>
|
|
61
|
+
<meta name="description" content={plainText(description)} />
|
|
62
|
+
<link rel="canonical" href={canonical} />
|
|
63
|
+
{noindex && <meta name="robots" content="noindex" />}
|
|
64
|
+
<meta name="theme-color" content="#08080e" />
|
|
65
|
+
<meta name="color-scheme" content="dark light" />
|
|
66
|
+
<meta name="author" content={person.name} />
|
|
67
|
+
<meta name="generator" content={Astro.generator} />
|
|
68
|
+
<meta name="cast:handle" content={person.handle} />
|
|
69
|
+
|
|
70
|
+
<link rel="icon" href={iconUrl} type="image/svg+xml" />
|
|
71
|
+
{
|
|
72
|
+
host.siteFiles && (
|
|
73
|
+
<>
|
|
74
|
+
<link rel="apple-touch-icon" href={cast.href('apple-touch-icon.png')} />
|
|
75
|
+
<link rel="sitemap" href={cast.href('sitemap-index.xml')} />
|
|
76
|
+
<link rel="alternate" type="application/json" href={cast.href('cast.json')} title="Cast profile" />
|
|
77
|
+
<link rel="alternate" type="text/plain" href={cast.href('llms.txt')} title="Profile for AI agents" />
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
<link rel="preload" href={interUrl} as="font" type="font/woff2" crossorigin />
|
|
83
|
+
<link rel="preload" href={monoUrl} as="font" type="font/woff2" crossorigin />
|
|
84
|
+
|
|
85
|
+
<meta property="og:type" content="profile" />
|
|
86
|
+
<meta property="og:site_name" content={person.name} />
|
|
87
|
+
<meta property="og:title" content={title} />
|
|
88
|
+
<meta property="og:description" content={plainText(description)} />
|
|
89
|
+
<meta property="og:url" content={canonical} />
|
|
90
|
+
{ogImage && <meta property="og:image" content={ogImage} />}
|
|
91
|
+
{ogImage && host.siteFiles && <meta property="og:image:width" content="1200" />}
|
|
92
|
+
{ogImage && host.siteFiles && <meta property="og:image:height" content="630" />}
|
|
93
|
+
{ogImage && <meta property="og:image:alt" content={`${person.name}: ${plainText(person.tagline)}`} />}
|
|
94
|
+
{firstName && <meta property="profile:first_name" content={firstName} />}
|
|
95
|
+
{rest.length > 0 && <meta property="profile:last_name" content={rest[rest.length - 1]} />}
|
|
96
|
+
<meta property="profile:username" content={person.handle} />
|
|
97
|
+
<meta name="twitter:card" content={host.siteFiles ? 'summary_large_image' : 'summary'} />
|
|
98
|
+
<meta name="twitter:title" content={title} />
|
|
99
|
+
<meta name="twitter:description" content={plainText(description)} />
|
|
100
|
+
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
|
101
|
+
|
|
102
|
+
<script type="application/ld+json" set:html={safeJson(jsonLd)} />
|
|
103
|
+
<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>
|
|
104
|
+
|
|
105
|
+
<script is:inline>
|
|
106
|
+
/* eslint-disable no-var, no-empty, @typescript-eslint/no-unused-vars -- runs before anything else loads, so it stays ES5 */
|
|
107
|
+
// Apply the stored theme and motion preference before first paint, and carry them
|
|
108
|
+
// over to every page the view-transition router swaps in.
|
|
109
|
+
(function () {
|
|
110
|
+
function apply(d) {
|
|
111
|
+
var preference = document.documentElement.getAttribute('data-theme-default') || 'dark';
|
|
112
|
+
var t = preference === 'light' ? 'light' : 'dark';
|
|
113
|
+
if (preference === 'system') t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
114
|
+
try {
|
|
115
|
+
var m = localStorage.getItem('cast:motion');
|
|
116
|
+
if (m === 'reduced' || m === 'full') d.dataset.motion = m;
|
|
117
|
+
var stored = localStorage.getItem('cast:theme');
|
|
118
|
+
if (stored === 'light' || stored === 'dark') t = stored;
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
d.dataset.theme = t;
|
|
121
|
+
d.style.colorScheme = t;
|
|
122
|
+
var reduced =
|
|
123
|
+
d.dataset.motion === 'reduced' ||
|
|
124
|
+
(d.dataset.motion !== 'full' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
125
|
+
if (!reduced) d.classList.add('js-motion');
|
|
126
|
+
d.classList.add('js');
|
|
127
|
+
var meta = d.querySelector('meta[name="theme-color"]');
|
|
128
|
+
if (meta) meta.setAttribute('content', t === 'light' ? '#faf9f6' : '#08080e');
|
|
129
|
+
}
|
|
130
|
+
apply(document.documentElement);
|
|
131
|
+
document.addEventListener('astro:before-swap', function (e) {
|
|
132
|
+
apply(e.newDocument.documentElement);
|
|
133
|
+
});
|
|
134
|
+
})();
|
|
135
|
+
</script>
|
|
136
|
+
<ClientRouter fallback="swap" />
|
|
137
|
+
</head>
|
|
138
|
+
<body class:list={['grain', bodyClass]}>
|
|
139
|
+
<a
|
|
140
|
+
href="#main"
|
|
141
|
+
class="sr-only-focusable fixed top-3 left-3 z-[100] rounded-lg bg-accent px-4 py-2 font-medium text-accent-ink"
|
|
142
|
+
>Skip to content</a
|
|
143
|
+
>
|
|
144
|
+
<slot name="nav" />
|
|
145
|
+
<main id="main" tabindex="-1" class="outline-none">
|
|
146
|
+
<slot />
|
|
147
|
+
</main>
|
|
148
|
+
<slot name="footer" />
|
|
149
|
+
<script type="application/json" data-cast-client set:html={safeJson(clientCast(cast))} />
|
|
150
|
+
<CommandPalette cast={cast} />
|
|
151
|
+
<Terminal cast={cast} />
|
|
152
|
+
<script>
|
|
153
|
+
import '../scripts/ambient';
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|