@cratis/cast 0.1.0 → 0.2.1

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 (57) hide show
  1. package/README.md +3 -1
  2. package/badges/RegistryMember.ts +2 -0
  3. package/badges/badgeSvg.ts +10 -1
  4. package/badges/registry.ts +5 -0
  5. package/cast-json.schema.json +1496 -0
  6. package/config/calendar.ts +34 -0
  7. package/config/castSchema.ts +3 -2
  8. package/github/gitHubSnapshotSchema.ts +58 -0
  9. package/machine/castJson.ts +17 -4
  10. package/machine/castJsonSchema.ts +35 -0
  11. package/machine/index.ts +6 -0
  12. package/machine/profileFromCastJson.ts +215 -0
  13. package/package.json +5 -1
  14. package/routes/404.astro +6 -4
  15. package/routes/cast.json.ts +7 -1
  16. package/routes/cv.astro +3 -365
  17. package/routes/index.astro +3 -55
  18. package/site/components/Activity.astro +9 -8
  19. package/site/components/Agentic.astro +11 -10
  20. package/site/components/CareerReplay.astro +11 -8
  21. package/site/components/CommandPalette.astro +10 -4
  22. package/site/components/Contact.astro +8 -5
  23. package/site/components/Cratis.astro +18 -16
  24. package/site/components/EventModel.astro +8 -5
  25. package/site/components/Footer.astro +9 -4
  26. package/site/components/Hero.astro +13 -10
  27. package/site/components/Inline.astro +45 -0
  28. package/site/components/Monogram.astro +4 -1
  29. package/site/components/Nav.astro +12 -9
  30. package/site/components/Now.astro +13 -10
  31. package/site/components/Projects.astro +11 -14
  32. package/site/components/PullRequestFeed.astro +6 -4
  33. package/site/components/SectionHeader.astro +6 -3
  34. package/site/components/Skills.astro +5 -2
  35. package/site/components/Stats.astro +9 -4
  36. package/site/components/Talks.astro +8 -5
  37. package/site/components/Terminal.astro +7 -2
  38. package/site/components/Writing.astro +9 -7
  39. package/site/data.ts +6 -43
  40. package/site/layouts/Base.astro +31 -19
  41. package/site/lib/blog.ts +42 -16
  42. package/site/lib/github.ts +56 -31
  43. package/site/lib/inline.ts +65 -9
  44. package/site/lib/nav.ts +14 -3
  45. package/site/pages/CvPage.astro +379 -0
  46. package/site/pages/ProfilePage.astro +78 -0
  47. package/site/profile/CastHost.ts +32 -0
  48. package/site/profile/CastView.ts +96 -0
  49. package/site/profile/assertRenderable.ts +151 -0
  50. package/site/profile/clientCast.ts +120 -0
  51. package/site/profile/index.ts +8 -0
  52. package/site/scripts/live.ts +21 -11
  53. package/site/scripts/palette.ts +30 -13
  54. package/site/scripts/replay.ts +2 -1
  55. package/site/scripts/terminal.ts +40 -29
  56. package/writing/fetchWriting.ts +17 -2
  57. package/writing/writingSnapshotSchema.ts +27 -0
@@ -2,10 +2,11 @@
2
2
  // Copyright (c) Cratis. All rights reserved.
3
3
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
4
 
5
- import { skills } from '../data';
5
+ import type { CastView } from '../profile/CastView';
6
6
  import SectionHeader from './SectionHeader.astro';
7
7
 
8
8
  interface Props {
9
+ cast: CastView;
9
10
  index: string;
10
11
  title?: string;
11
12
  label?: string;
@@ -13,13 +14,15 @@ interface Props {
13
14
  compact?: boolean;
14
15
  }
15
16
  const { index, title = 'The *toolbox.*', label = 'Read model', compact = false } = Astro.props;
17
+ const { cast } = Astro.props;
18
+ const { skills } = cast;
16
19
  const groups = Object.entries(skills);
17
20
  const accents = ['bg-event', 'bg-command', 'bg-view', 'bg-accent'];
18
21
  ---
19
22
 
20
23
  <section id="skills" class:list={['relative pb-24 md:pb-36', compact ? '' : 'pt-24 md:pt-32']} aria-labelledby="skills-title">
21
24
  <div class="container-x">
22
- <SectionHeader index={index} kind="view" label={label} title={title} id="skills-title" />
25
+ <SectionHeader host={cast.host} index={index} kind="view" label={label} title={title} id="skills-title" />
23
26
  <div class="grid grid-cols-1 gap-px overflow-hidden rounded-[1.25rem] border border-line bg-line sm:grid-cols-2 lg:grid-cols-3">
24
27
  {
25
28
  groups.map(([group, list], i) => (
@@ -2,8 +2,13 @@
2
2
  // Copyright (c) Cratis. All rights reserved.
3
3
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
4
 
5
- import { stats as siteStats, cratis } from '../data';
6
- import { github, cratisRepos, mergedPrFloor, snapshotDate } from '../lib/github';
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, mergedPrStatus } = cast;
7
12
 
8
13
  type Stat = (typeof siteStats)[number];
9
14
  const rankOne = cratisRepos.filter((r) => r.rank === 1).length;
@@ -32,9 +37,9 @@ const accents = ['text-event', 'text-command', 'text-view', 'text-fg'];
32
37
  style={`--reveal-delay:${i * 80}ms`}
33
38
  >
34
39
  <dt class="order-2 max-w-[18ch] text-sm leading-snug text-fg-muted">{s.label}
35
- {s.live === 'mergedPullRequests' && <span data-pr-status class="mt-1 block font-mono text-[0.6875rem] text-fg-faint">as of {snapshotDate()}</span>}
40
+ {s.live === 'mergedPullRequests' && <span data-pr-status class="mt-1 block font-mono text-[0.6875rem] text-fg-faint">{mergedPrStatus}</span>}
36
41
  </dt>
37
- <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]}`}>
42
+ <dd class={`order-1 font-display whitespace-nowrap text-[clamp(2.5rem,5vw,4rem)] leading-none font-[650] tracking-[-0.04em] tabular-nums ${accents[i % accents.length]}`}>
38
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>
39
44
  {s.suffix && <span class="text-fg-faint">{s.suffix}</span>}
40
45
  </dd>
@@ -2,17 +2,20 @@
2
2
  // Copyright (c) Cratis. All rights reserved.
3
3
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
4
 
5
- import { talks, community } from '../data';
5
+ import type { CastView } from '../profile/CastView';
6
+ import Inline from './Inline.astro';
6
7
  import { formatMonth } from '../lib/career';
7
- import { renderInline } from '../lib/inline';
8
8
  import SectionHeader from './SectionHeader.astro';
9
9
 
10
10
  interface Props {
11
+ cast: CastView;
11
12
  index: string;
12
13
  title?: string;
13
14
  label?: string;
14
15
  }
15
16
  const { index, title = 'Talks & *community.*', label = 'TalkGiven' } = Astro.props;
17
+ const { cast } = Astro.props;
18
+ const { talks, community } = cast;
16
19
  const talksNewest = [...talks].sort((a, b) => b.date.localeCompare(a.date));
17
20
  const day = (d: string) =>
18
21
  d.length === 10 ? new Date(`${d}T00:00:00Z`).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : formatMonth(d);
@@ -28,7 +31,7 @@ const languageName = (lang: string) => {
28
31
  <section id="talks" class="relative py-24 md:py-32" aria-labelledby="talks-title">
29
32
  <div class="container-x grid grid-cols-1 items-start gap-x-12 lg:grid-cols-12">
30
33
  <div class="lg:sticky lg:top-[calc(var(--nav-h)+2rem)] lg:col-span-5 [&>header]:mb-10 lg:[&>header]:mb-0">
31
- <SectionHeader index={index} label={label} title={title} id="talks-title" />
34
+ <SectionHeader host={cast.host} index={index} label={label} title={title} id="talks-title" />
32
35
  </div>
33
36
  <div class="grid gap-4 lg:col-span-7">
34
37
  {talksNewest.length > 0 && <ul class="grid grid-cols-1 gap-4">
@@ -37,13 +40,13 @@ const languageName = (lang: string) => {
37
40
  const Tag = t.url ? 'a' : 'div';
38
41
  return (
39
42
  <li data-reveal>
40
- <Tag href={t.url} rel={t.url ? 'noopener' : undefined} class:list={['panel flex h-full flex-col gap-4 p-6 md:p-7', t.url && 'glow group']}>
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']}>
41
44
  <span class="flex flex-wrap items-center justify-between gap-2 font-mono text-xs text-fg-faint">
42
45
  <span class="note">TalkGiven</span>
43
46
  <span><time datetime={t.date}>{day(t.date)}</time>{!t.lang.startsWith('en') && <> · in {languageName(t.lang)}</>}</span>
44
47
  </span>
45
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>
46
- <span class="text-fg-muted" set:html={renderInline(t.summary)} />
49
+ <span class="text-fg-muted"><Inline text={t.summary} host={cast.host} /></span>
47
50
  <span class="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-2 text-sm text-fg-muted">
48
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>}
49
52
  {t.url ? 'Watch · ' : ''}{t.host}
@@ -4,12 +4,17 @@
4
4
 
5
5
  // Shell for the interactive terminal. The behavior lives in scripts/terminal.ts and is
6
6
  // only downloaded the first time the terminal opens.
7
- import { person, projects } from '../data';
7
+ import type { CastView } from '../profile/CastView';
8
+
9
+ interface Props {
10
+ cast: CastView;
11
+ }
12
+ const { person, projects } = Astro.props.cast;
8
13
  const firstProject = projects[0]?.name;
9
14
  const chips = ['help', 'whoami', 'now', 'git log --oneline', 'stream tail', 'ls projects', ...(firstProject ? [`cat ${firstProject}`] : []), 'contact', 'replay'];
10
15
  ---
11
16
 
12
- <dialog class="term" data-terminal aria-label="Terminal" aria-describedby="term-hint" transition:persist="terminal">
17
+ <dialog class="term" data-terminal aria-label="Terminal" aria-describedby="term-hint" transition:persist={`terminal-${person.handle.toLowerCase()}`}>
13
18
  <div class="shell panel flex flex-col overflow-hidden">
14
19
  <div class="flex items-center gap-3 border-b border-line px-4 py-2.5">
15
20
  <span class="flex gap-1.5" aria-hidden="true">
@@ -2,18 +2,20 @@
2
2
  // Copyright (c) Cratis. All rights reserved.
3
3
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
4
4
 
5
+ import type { CastView } from '../profile/CastView';
6
+ import Inline from './Inline.astro';
5
7
  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
8
 
10
9
  interface Props {
10
+ cast: CastView;
11
11
  index: string;
12
12
  title?: string;
13
13
  label?: string;
14
14
  intro?: string;
15
15
  }
16
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;
17
19
  const hours = Math.round(readingMinutes / 60);
18
20
  const INITIAL = 8;
19
21
  const series = [...new Set(posts.map((p) => p.series).filter(Boolean))];
@@ -22,10 +24,10 @@ const day = (d: string) => new Date(d).toLocaleDateString('en-US', { day: 'numer
22
24
 
23
25
  <section id="writing" class="relative py-24 md:py-36" aria-labelledby="writing-title">
24
26
  <div class="container-x">
25
- <SectionHeader index={index} label={label} title={title} id="writing-title">
27
+ <SectionHeader host={cast.host} index={index} label={label} title={title} id="writing-title">
26
28
  <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
+ {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} /></>}
29
31
  </p>
30
32
  </SectionHeader>
31
33
 
@@ -46,7 +48,7 @@ const day = (d: string) => new Date(d).toLocaleDateString('en-US', { day: 'numer
46
48
  {
47
49
  posts.map((p, i) => (
48
50
  <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">
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">
50
52
  <span class="font-mono text-xs leading-6 text-fg-faint">
51
53
  {p.part !== undefined ? <>Part <span class="text-event">{String(p.part).padStart(2, '0')}</span></> : <span class="text-event">Overview</span>}
52
54
  </span>
package/site/data.ts CHANGED
@@ -1,48 +1,11 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- // The one place components read cast.yaml and build-time data from.
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.
5
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';
6
9
 
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';
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);
@@ -9,7 +9,8 @@ import '../styles/global.css';
9
9
  import interUrl from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url';
10
10
  import monoUrl from '@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2?url';
11
11
  import { ClientRouter } from 'astro:transitions';
12
- import { config, person, githubUrl, profileLinks } from '../data';
12
+ import type { CastView } from '../profile/CastView';
13
+ import { clientCast } from '../profile/clientCast';
13
14
  import { plainText } from '../lib/inline';
14
15
  import { accentTokens } from '../lib/accent';
15
16
  import { safeJson } from '../lib/safeJson';
@@ -17,17 +18,21 @@ import CommandPalette from '../components/CommandPalette.astro';
17
18
  import Terminal from '../components/Terminal.astro';
18
19
 
19
20
  interface Props {
21
+ cast: CastView;
20
22
  title: string;
21
23
  description: string;
22
- /** Path of the OG image. */
24
+ /** Path of the OG image under the profile. Defaults to og.png on a Cast site and the portrait elsewhere. */
23
25
  image?: string;
24
26
  noindex?: boolean;
25
27
  bodyClass?: string;
26
28
  }
27
29
 
28
- const { title, description, image = '/og.png', noindex = false, bodyClass = '' } = Astro.props;
30
+ const { cast, title, description, image, noindex = false, bodyClass = '' } = Astro.props;
31
+ const { config, person, githubUrl, profileLinks, host } = cast;
29
32
  const canonical = new URL(Astro.url.pathname, Astro.site);
30
- const ogImage = new URL(image, 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';
31
36
  const [firstName, ...rest] = (person.fullName ?? person.name).split(/\s+/);
32
37
  const themeMode = config.theme.mode;
33
38
  const accent = accentTokens(config.theme.accent);
@@ -39,8 +44,8 @@ const jsonLd = {
39
44
  alternateName: [person.fullName, person.handle].filter(Boolean),
40
45
  jobTitle: person.title,
41
46
  ...(person.email ? { email: `mailto:${person.email}` } : {}),
42
- url: Astro.site?.toString(),
43
- image: new URL('/apple-touch-icon.png', Astro.site).toString(),
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 } : {}),
44
49
  ...(person.location ? { address: { '@type': 'PostalAddress', addressLocality: person.location.split(',')[0]?.trim(), ...(person.country ? { addressCountry: person.country } : {}) } } : {}),
45
50
  knowsAbout: person.interests,
46
51
  sameAs: [githubUrl, ...profileLinks.filter((link) => link.key !== 'github').map((link) => link.url)],
@@ -62,11 +67,17 @@ const jsonLd = {
62
67
  <meta name="generator" content={Astro.generator} />
63
68
  <meta name="cast:handle" content={person.handle} />
64
69
 
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
+ <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
+ }
70
81
 
71
82
  <link rel="preload" href={interUrl} as="font" type="font/woff2" crossorigin />
72
83
  <link rel="preload" href={monoUrl} as="font" type="font/woff2" crossorigin />
@@ -76,17 +87,17 @@ const jsonLd = {
76
87
  <meta property="og:title" content={title} />
77
88
  <meta property="og:description" content={plainText(description)} />
78
89
  <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)}`} />
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)}`} />}
83
94
  {firstName && <meta property="profile:first_name" content={firstName} />}
84
95
  {rest.length > 0 && <meta property="profile:last_name" content={rest[rest.length - 1]} />}
85
96
  <meta property="profile:username" content={person.handle} />
86
- <meta name="twitter:card" content="summary_large_image" />
97
+ <meta name="twitter:card" content={host.siteFiles ? 'summary_large_image' : 'summary'} />
87
98
  <meta name="twitter:title" content={title} />
88
99
  <meta name="twitter:description" content={plainText(description)} />
89
- <meta name="twitter:image" content={ogImage} />
100
+ {ogImage && <meta name="twitter:image" content={ogImage} />}
90
101
 
91
102
  <script type="application/ld+json" set:html={safeJson(jsonLd)} />
92
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>
@@ -135,8 +146,9 @@ const jsonLd = {
135
146
  <slot />
136
147
  </main>
137
148
  <slot name="footer" />
138
- <CommandPalette />
139
- <Terminal />
149
+ <script type="application/json" data-cast-client set:html={safeJson(clientCast(cast))} />
150
+ <CommandPalette cast={cast} />
151
+ <Terminal cast={cast} />
140
152
  <script>
141
153
  import '../scripts/ambient';
142
154
  </script>
package/site/lib/blog.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
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';
4
+ // Typed views over the writing captured at build time.
5
+ import type { CastConfig } from '../../config/CastConfig';
6
+ import type { Post as RawPost, WritingSnapshot } from '../../writing/WritingSnapshot';
7
7
 
8
8
  export type ThemeId = string;
9
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;
10
+ export type WritingTheme = { id: ThemeId; label: string; tags: string[] };
13
11
 
14
12
  /** Splits "Title · Series, Part 3" into its parts. */
15
13
  function split(title: string): Pick<Post, 'headline' | 'series' | 'part'> {
@@ -19,14 +17,42 @@ function split(title: string): Pick<Post, 'headline' | 'series' | 'part'> {
19
17
  return series ? { headline: series[1]!, series: series[2]! } : { headline: title };
20
18
  }
21
19
 
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));
20
+ const hostOf = (url: string): string => {
21
+ try {
22
+ return new URL(url).host;
23
+ } catch {
24
+ return '';
25
+ }
26
+ };
27
+
28
+ /** What the writing sections read. */
29
+ export type WritingView = {
30
+ /** Reading themes for the filter, each a set of tags, from cast.yaml. */
31
+ themes: WritingTheme[];
32
+ /** Newest first; posts published together keep their series order. */
33
+ posts: Post[];
34
+ /** The first source, for "on blog.example.com" links. */
35
+ blogSource: string;
36
+ blogHost: string;
37
+ blogFetchedAt?: string;
38
+ readingMinutes: number;
39
+ postsFor: (theme: ThemeId | undefined) => Post[];
40
+ };
26
41
 
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)) : []);
42
+ /** Builds the writing view for one profile. */
43
+ export function writingView(config: CastConfig, writing: WritingSnapshot): WritingView {
44
+ const themes = config.writing.themes;
45
+ const posts: Post[] = writing.posts
46
+ .map(post => ({ ...post, ...split(post.title), themes: themes.filter(theme => post.tags.some(tag => theme.tags.includes(tag))).map(theme => theme.id) }))
47
+ .sort((left, right) => right.date.slice(0, 10).localeCompare(left.date.slice(0, 10)) || (left.part ?? 0) - (right.part ?? 0));
48
+ const blogSource = writing.sources[0] ?? '';
49
+ return {
50
+ themes,
51
+ posts,
52
+ blogSource,
53
+ blogHost: blogSource ? hostOf(blogSource) : '',
54
+ ...(writing.fetchedAt ? { blogFetchedAt: writing.fetchedAt } : {}),
55
+ readingMinutes: posts.reduce((total, post) => total + (post.minutes ?? 0), 0),
56
+ postsFor: theme => (theme ? posts.filter(post => post.themes.includes(theme)) : []),
57
+ };
58
+ }
@@ -1,23 +1,16 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- // Typed access to the GitHub data captured at build time.
4
+ // Typed views over the GitHub data captured at build time.
5
5
  import type { z } from 'zod';
6
+ import type { CastConfig } from '../../config/CastConfig';
6
7
  import type { cratisRepositorySchema, projectSchema } from '../../config/castSchema';
7
- import { config, cratis, github, projects } from '../data';
8
+ import type { GitHubSnapshot } from '../../github/GitHubSnapshot';
8
9
 
9
10
  export type { PullRequest, ContributionWeek as Week } from '../../github/GitHubSnapshot';
10
11
  export type CratisRepo = z.output<typeof cratisRepositorySchema> & { role: string };
11
12
  export type Project = z.output<typeof projectSchema>;
12
13
 
13
- export { github };
14
-
15
- /** Merged pull requests across the configured organizations, falling back to a stat written in cast.yaml. */
16
- export const mergedPrTotal: number = github.mergedPullRequests.total || (config.stats.find(stat => stat.live === 'mergedPullRequests')?.value ?? 0);
17
-
18
- /** Floors to hundreds for an honest "3,100+" style figure. */
19
- export const mergedPrFloor: number = mergedPrTotal >= 100 ? Math.floor(mergedPrTotal / 100) * 100 : mergedPrTotal;
20
-
21
14
  const roleFor = (repo: z.output<typeof cratisRepositorySchema>, rank: number): string =>
22
15
  repo.founded
23
16
  ? (rank === 1 ? 'Founder & lead' : 'Founder')
@@ -27,29 +20,61 @@ const roleFor = (repo: z.output<typeof cratisRepositorySchema>, rank: number): s
27
20
  ? (repo.group === 'client' ? '#2 contributor' : '#2 all-time')
28
21
  : 'Contributor';
29
22
 
30
- /** Per-repository figures prefer the snapshot; values written in cast.yaml are fallbacks only. */
31
- export const cratisRepos: CratisRepo[] = (cratis?.repos ?? []).map(repo => {
32
- const counts = repo.url ? github.repositories[`Cratis/${repo.name}`] : undefined;
33
- if (!counts) return { ...repo, role: repo.role ?? roleFor(repo, repo.rank) };
34
- return { ...repo, ...counts, role: roleFor(repo, counts.rank) };
35
- });
36
-
37
23
  /** owner/name of a github.com URL. */
38
24
  export const repositoryOf = (url: string): string | undefined => url.match(/^https:\/\/github\.com\/([^/]+\/[^/#?]+)/i)?.[1];
39
25
 
40
- /** Projects with versions taken from the latest GitHub release when one was captured. */
41
- export const liveProjects: Project[] = projects.map(project => {
42
- const repository = repositoryOf(project.url);
43
- const release = repository ? Object.entries(github.releases).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1] : undefined;
44
- return release ? { ...project, version: release.tag } : project;
45
- });
46
-
47
- /** Stars for an owner/name, if tracked. */
48
- export const starsFor = (repository: string): number | undefined =>
49
- Object.entries(github.stars).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1];
50
-
51
- export const snapshotDate = (date = github.fetchedAt): string =>
26
+ /** Formats a capture date as "Oct 16, 2026", or "the last build" when unknown. */
27
+ export const formatSnapshotDate = (date: string | undefined): string =>
52
28
  date ? new Date(date).toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : 'the last build';
53
29
 
54
- /** Repository names in the live feed, most recent first, without duplicates. */
55
- export const liveRepos: string[] = [...new Set(github.mergedPullRequests.latest.map(pullRequest => pullRequest.repository.split('/')[1] ?? pullRequest.repository))];
30
+ /** What the sections read from the GitHub snapshot, with cast.yaml values as fallbacks. */
31
+ export type GitHubView = {
32
+ github: GitHubSnapshot;
33
+ /** Merged pull requests across the configured organizations, falling back to a stat written in cast.yaml. */
34
+ mergedPrTotal: number;
35
+ /** Where {@link mergedPrTotal} comes from: 'as of' the capture date, or 'self-reported' for the cast.yaml fallback. */
36
+ mergedPrStatus: string;
37
+ /** True when {@link mergedPrTotal} is the cast.yaml fallback: never captured, so never shown as a snapshot or as live. */
38
+ mergedPrSelfReported: boolean;
39
+ /** Floors to hundreds for an honest "3,100+" style figure. */
40
+ mergedPrFloor: number;
41
+ /** Per-repository figures prefer the snapshot; values written in cast.yaml are fallbacks only. */
42
+ cratisRepos: CratisRepo[];
43
+ /** Projects with versions taken from the latest GitHub release when one was captured. */
44
+ liveProjects: Project[];
45
+ /** Stars for an owner/name, if tracked. */
46
+ starsFor: (repository: string) => number | undefined;
47
+ /** The capture date of a snapshot, by default this one. */
48
+ snapshotDate: (date?: string) => string;
49
+ /** Repository names in the live feed, most recent first, without duplicates. */
50
+ liveRepos: string[];
51
+ };
52
+
53
+ /** Builds the GitHub view for one profile. */
54
+ export function gitHubView(config: CastConfig, github: GitHubSnapshot): GitHubView {
55
+ const captured = github.mergedPullRequests.total;
56
+ // The cast.yaml figure was never captured: it carries no capture date, so it is labelled as the owner's own.
57
+ const mergedPrTotal = captured || (config.stats.find(stat => stat.live === 'mergedPullRequests')?.value ?? 0);
58
+ const cratisRepos = (config.cratis?.repos ?? []).map(repo => {
59
+ const counts = repo.url ? github.repositories[`Cratis/${repo.name}`] : undefined;
60
+ if (!counts) return { ...repo, role: repo.role ?? roleFor(repo, repo.rank) };
61
+ return { ...repo, ...counts, role: roleFor(repo, counts.rank) };
62
+ });
63
+ const liveProjects = config.projects.map(project => {
64
+ const repository = repositoryOf(project.url);
65
+ const release = repository ? Object.entries(github.releases).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1] : undefined;
66
+ return release ? { ...project, version: release.tag } : project;
67
+ });
68
+ return {
69
+ github,
70
+ mergedPrTotal,
71
+ mergedPrStatus: captured ? `as of ${formatSnapshotDate(github.fetchedAt)}` : 'self-reported',
72
+ mergedPrSelfReported: !captured && mergedPrTotal > 0,
73
+ mergedPrFloor: mergedPrTotal >= 100 ? Math.floor(mergedPrTotal / 100) * 100 : mergedPrTotal,
74
+ cratisRepos,
75
+ liveProjects,
76
+ starsFor: repository => Object.entries(github.stars).find(([name]) => name.toLowerCase() === repository.toLowerCase())?.[1],
77
+ snapshotDate: (date = github.fetchedAt) => formatSnapshotDate(date),
78
+ liveRepos: [...new Set(github.mergedPullRequests.latest.map(pullRequest => pullRequest.repository.split('/')[1] ?? pullRequest.repository))],
79
+ };
80
+ }
@@ -1,21 +1,77 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ /** One piece of inline text: plain text, or markup with its own pieces inside. */
5
+ export type InlineToken =
6
+ | { kind: 'text'; text: string }
7
+ | { kind: 'code'; text: string }
8
+ | { kind: 'em' | 'strong'; children: InlineToken[] }
9
+ | { kind: 'link'; href: string; children: InlineToken[] };
10
+
11
+ type Pattern = { kind: 'code' | 'link' | 'strong' | 'em'; expression: RegExp };
12
+
13
+ /** In order of precedence when two start at the same position. */
14
+ const patterns: Pattern[] = [
15
+ { kind: 'code', expression: /`([^`]+)`/g },
16
+ { kind: 'link', expression: /\[([^\]]+)\]\(([^)\s]+)\)/g },
17
+ { kind: 'strong', expression: /\*\*([^*]+)\*\*/g },
18
+ { kind: 'em', expression: /\*([^*]+)\*/g },
19
+ ];
20
+
21
+ /** Link targets inline text may use: http(s), mailto, a path or an anchor. Anything else stays plain text. */
22
+ const allowedHref = /^(https?:\/\/[^\s]+|mailto:[^\s]+|\/[^\s]*|#[^\s]*)$/i;
23
+
24
+ /**
25
+ * Parses the inline markup cast.yaml text may use into tokens: `*emphasis*` (the italic serif accent),
26
+ * `**strong**`, `` `code` `` and `[links](https://…)`. Tokens are rendered as elements with escaped text,
27
+ * never as HTML, so markup in member text cannot inject anything. Links to other schemes stay plain text.
28
+ */
29
+ export function parseInline(text: string): InlineToken[] {
30
+ const tokens: InlineToken[] = [];
31
+ let position = 0;
32
+ while (position < text.length) {
33
+ let best: { pattern: Pattern; match: RegExpExecArray } | undefined;
34
+ for (const pattern of patterns) {
35
+ pattern.expression.lastIndex = position;
36
+ const match = pattern.expression.exec(text);
37
+ if (match && (!best || match.index < best.match.index)) best = { pattern, match };
38
+ }
39
+ if (!best) break;
40
+ const { pattern, match } = best;
41
+ if (match.index > position) tokens.push({ kind: 'text', text: text.slice(position, match.index) });
42
+ const inner = match[1]!;
43
+ if (pattern.kind === 'code') tokens.push({ kind: 'code', text: inner });
44
+ else if (pattern.kind === 'link') {
45
+ const href = match[2]!;
46
+ if (allowedHref.test(href)) tokens.push({ kind: 'link', href, children: parseInline(inner) });
47
+ else tokens.push({ kind: 'text', text: match[0] });
48
+ } else tokens.push({ kind: pattern.kind, children: parseInline(inner) });
49
+ position = match.index + match[0].length;
50
+ }
51
+ if (position < text.length) tokens.push({ kind: 'text', text: text.slice(position) });
52
+ return tokens;
53
+ }
54
+
4
55
  const escapeHtml = (value: string): string =>
5
56
  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6
57
 
58
+ const linkClass = 'text-fg underline decoration-line-strong hover:decoration-fg';
59
+
7
60
  /**
8
- * Renders the inline markup cast.yaml text may use, escaping everything else:
9
- * `*emphasis*` (the italic serif accent), `**strong**`, `` `code` `` and `[links](https://…)`.
61
+ * Renders inline markup to an HTML string with everything escaped, for outputs that are not Astro components.
62
+ * Components render `Inline.astro`, which builds elements from the same tokens.
10
63
  */
11
64
  export function renderInline(text: string, emphasisClass = 'serif'): string {
12
- let html = escapeHtml(text);
13
- html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
14
- html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*|#[^)\s]*|mailto:[^)\s]+)\)/g, (_, label: string, href: string) =>
15
- `<a href="${href}" class="text-fg underline decoration-line-strong hover:decoration-fg"${href.startsWith('http') ? ' rel="noopener"' : ''}>${label}</a>`);
16
- html = html.replace(/\*\*([^*]+)\*\*/g, '<strong class="text-fg font-semibold">$1</strong>');
17
- html = html.replace(/\*([^*]+)\*/g, `<em class="${emphasisClass}">$1</em>`);
18
- return html;
65
+ const render = (tokens: InlineToken[]): string => tokens.map(token => {
66
+ switch (token.kind) {
67
+ case 'text': return escapeHtml(token.text);
68
+ case 'code': return `<code class="inline-code">${escapeHtml(token.text)}</code>`;
69
+ case 'strong': return `<strong class="text-fg font-semibold">${render(token.children)}</strong>`;
70
+ case 'em': return `<em class="${escapeHtml(emphasisClass)}">${render(token.children)}</em>`;
71
+ case 'link': return `<a href="${escapeHtml(token.href)}" class="${linkClass}"${/^https?:/i.test(token.href) ? ' rel="noopener"' : ''}>${render(token.children)}</a>`;
72
+ }
73
+ }).join('');
74
+ return render(parseInline(text));
19
75
  }
20
76
 
21
77
  /** Removes inline markup, for meta tags, JSON and plain-text outputs. */