@rxova/astro-ui 0.0.0 → 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.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/package.json +64 -6
- package/src/components/BackLink.astro +24 -0
- package/src/components/Header.astro +148 -0
- package/src/components/PageHeader.astro +63 -0
- package/src/components/ProjectSwitcher.astro +95 -0
- package/src/components/ShowMore.astro +82 -0
- package/src/components/SiteFooter.astro +149 -0
- package/src/components/SiteShell.astro +155 -0
- package/src/components/ThemeScript.astro +31 -0
- package/src/components/ThemeToggle.astro +163 -0
- package/src/components/VisuallyHidden.astro +23 -0
- package/src/env.d.ts +15 -0
- package/src/lib/entries.ts +72 -0
- package/src/scripts/show-more.ts +46 -0
- package/src/starlight/Footer.astro +16 -0
- package/src/starlight/SiteTitle.astro +34 -0
- package/src/starlight/SocialIcons.astro +18 -0
- package/src/starlight/ThemeSelect.astro +42 -0
- package/src/starlight/index.ts +103 -0
- package/src/styles/chrome.css +8 -0
- package/src/styles/document.css +72 -0
- package/src/styles/footer.css +124 -0
- package/src/styles/starlight.css +162 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The rxova footer on every surface: brand block, link columns, then copyright and legal links.
|
|
4
|
+
* Styles live in `../styles/footer.css`; all cross-project hrefs are absolute.
|
|
5
|
+
*/
|
|
6
|
+
import { PROJECTS, RXOVA_ORIGIN, getProject, siteUrl, type ProjectId } from '@rxova/brand'
|
|
7
|
+
|
|
8
|
+
/** A standalone surface of rxova.org, for the "Site" column. */
|
|
9
|
+
export interface SiteLink {
|
|
10
|
+
label: string
|
|
11
|
+
/** Absolute — see sites.ts in @rxova/brand. */
|
|
12
|
+
href: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Current project, if this is a docs site. Omitted on the landing. */
|
|
17
|
+
project?: ProjectId
|
|
18
|
+
/** Optional per-site "Docs" column. */
|
|
19
|
+
docs?: { label: string; href: string }[]
|
|
20
|
+
/**
|
|
21
|
+
* Standalone surfaces listed under "Site", below Home. Only the umbrella repo knows which are
|
|
22
|
+
* deployed; the default (for docs sites) includes /about, a page of the landing build.
|
|
23
|
+
*/
|
|
24
|
+
site?: readonly SiteLink[]
|
|
25
|
+
/**
|
|
26
|
+
* Projects whose docs are actually mounted; only the umbrella repo knows which are deployed.
|
|
27
|
+
* Defaults to every project, which is right for a docs site.
|
|
28
|
+
*/
|
|
29
|
+
projects?: readonly SiteLink[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { project, docs, site, projects } = Astro.props
|
|
33
|
+
const self = project ? getProject(project) : undefined
|
|
34
|
+
const year = new Date().getFullYear()
|
|
35
|
+
|
|
36
|
+
const projectLinks: readonly SiteLink[] =
|
|
37
|
+
projects ?? PROJECTS.map((p) => ({ label: p.label, href: `${RXOVA_ORIGIN}${p.mount}` }))
|
|
38
|
+
|
|
39
|
+
const surfaces: readonly SiteLink[] = site ?? [
|
|
40
|
+
{ label: 'Blog', href: siteUrl('/blog') },
|
|
41
|
+
{ label: 'Updates', href: siteUrl('/updates') },
|
|
42
|
+
{ label: 'About', href: siteUrl('/about') },
|
|
43
|
+
]
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<footer class="rx-footer">
|
|
47
|
+
<div class="rx-footer__top">
|
|
48
|
+
<div class="rx-footer__brand">
|
|
49
|
+
<a class="rx-footer__mark" href={RXOVA_ORIGIN}>
|
|
50
|
+
<img src={siteUrl('/rxova-logo-256.png')} alt="" width="28" height="28" loading="lazy" />
|
|
51
|
+
<span>Rxova</span>
|
|
52
|
+
</a>
|
|
53
|
+
{/* Not "React libraries": most ship a framework-agnostic core; some are extensions/CLIs. */}
|
|
54
|
+
<p class="rx-footer__blurb">
|
|
55
|
+
Small TypeScript libraries and developer tools, documented in one place. MIT licensed, built
|
|
56
|
+
in the open.
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="rx-footer__columns">
|
|
61
|
+
{docs && docs.length > 0 && (
|
|
62
|
+
<div>
|
|
63
|
+
<h2 class="rx-footer__title">Docs</h2>
|
|
64
|
+
<ul class="rx-footer__list">
|
|
65
|
+
{docs.map((link) => (
|
|
66
|
+
<li>
|
|
67
|
+
<a href={link.href}>{link.label}</a>
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{projectLinks.length > 0 && (
|
|
75
|
+
<div>
|
|
76
|
+
<h2 class="rx-footer__title">Projects</h2>
|
|
77
|
+
<ul class="rx-footer__list">
|
|
78
|
+
{projectLinks.map((link) => (
|
|
79
|
+
<li>
|
|
80
|
+
<a href={link.href}>{link.label}</a>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{self && (
|
|
88
|
+
<div>
|
|
89
|
+
<h2 class="rx-footer__title">Community</h2>
|
|
90
|
+
<ul class="rx-footer__list">
|
|
91
|
+
<li>
|
|
92
|
+
<a href={self.repo}>GitHub</a>
|
|
93
|
+
</li>
|
|
94
|
+
<li>
|
|
95
|
+
<a href={`${self.repo}/issues`}>Issues</a>
|
|
96
|
+
</li>
|
|
97
|
+
<li>
|
|
98
|
+
<a href={`${self.repo}/discussions`}>Discussions</a>
|
|
99
|
+
</li>
|
|
100
|
+
<li>
|
|
101
|
+
<a href={self.npm}>npm</a>
|
|
102
|
+
</li>
|
|
103
|
+
<li>
|
|
104
|
+
<a href={`${self.repo}/blob/main/CONTRIBUTING.md`}>Contributing</a>
|
|
105
|
+
</li>
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Blog and Updates on every surface, so readers landing on a docs page find the rest. */}
|
|
111
|
+
<div>
|
|
112
|
+
<h2 class="rx-footer__title">Site</h2>
|
|
113
|
+
<ul class="rx-footer__list">
|
|
114
|
+
<li>
|
|
115
|
+
<a href={RXOVA_ORIGIN}>Home</a>
|
|
116
|
+
</li>
|
|
117
|
+
{surfaces.map((s) => (
|
|
118
|
+
<li>
|
|
119
|
+
<a href={s.href}>{s.label}</a>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div>
|
|
126
|
+
<h2 class="rx-footer__title">Connect</h2>
|
|
127
|
+
<ul class="rx-footer__list">
|
|
128
|
+
<li>
|
|
129
|
+
<a href="https://github.com/rxova">GitHub</a>
|
|
130
|
+
</li>
|
|
131
|
+
<li>
|
|
132
|
+
<a href="https://www.npmjs.com/org/rxova">npm</a>
|
|
133
|
+
</li>
|
|
134
|
+
<li>
|
|
135
|
+
<a href="mailto:rxova@proton.me">rxova@proton.me</a>
|
|
136
|
+
</li>
|
|
137
|
+
</ul>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="rx-footer__legal">
|
|
143
|
+
<p>© {year} Rxova · MIT licensed</p>
|
|
144
|
+
<div class="rx-footer__legal-links">
|
|
145
|
+
<a href={siteUrl('/privacy')}>Privacy</a>
|
|
146
|
+
<a href={siteUrl('/terms')}>Terms</a>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</footer>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Artifact document for rxova.org's non-Starlight pages: metadata and assets only, no header,
|
|
4
|
+
* footer, analytics or theme bootstrap. rxova-website supplies the public shell at deploy time.
|
|
5
|
+
*/
|
|
6
|
+
import '@rxova/brand/fonts.css'
|
|
7
|
+
import '../styles/document.css'
|
|
8
|
+
|
|
9
|
+
import { canonicalUrl, siteUrl } from '@rxova/brand'
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
title: string
|
|
13
|
+
description: string
|
|
14
|
+
/** Canonical path, e.g. `/blog/why-rxova-has-a-blog`. Normalised below. */
|
|
15
|
+
path: string
|
|
16
|
+
ogType?: 'website' | 'article'
|
|
17
|
+
/** Absolute URL of the social card, chosen by the caller; defaults to the umbrella card. */
|
|
18
|
+
image?: string
|
|
19
|
+
/** Advertises an RSS feed for this surface, as `rel="alternate"`. */
|
|
20
|
+
feed?: { href: string; title: string }
|
|
21
|
+
/** JSON-LD for this page, emitted verbatim as `application/ld+json`. */
|
|
22
|
+
jsonLd?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { title, description, path, ogType = 'website', image, feed, jsonLd } = Astro.props
|
|
26
|
+
|
|
27
|
+
// Normalised here so no caller can declare a canonical URL without the trailing slash (a 301).
|
|
28
|
+
const canonical = canonicalUrl(path)
|
|
29
|
+
|
|
30
|
+
const ogImage = image ?? siteUrl('/og/rxova.png')
|
|
31
|
+
|
|
32
|
+
/** Serialise JSON-LD for an inline `<script>`; `<` is escaped so a `</script>` cannot close it. */
|
|
33
|
+
const serialiseJsonLd = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8" />
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
41
|
+
<link rel="canonical" href={canonical} />
|
|
42
|
+
<title>{title}</title>
|
|
43
|
+
<meta name="description" content={description} />
|
|
44
|
+
<meta property="og:title" content={title} />
|
|
45
|
+
<meta property="og:description" content={description} />
|
|
46
|
+
<meta property="og:type" content={ogType} />
|
|
47
|
+
<meta property="og:url" content={canonical} />
|
|
48
|
+
<meta property="og:image" content={ogImage} />
|
|
49
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
50
|
+
{feed && (
|
|
51
|
+
<link rel="alternate" type="application/rss+xml" title={feed.title} href={feed.href} />
|
|
52
|
+
)}
|
|
53
|
+
{jsonLd && (
|
|
54
|
+
<script type="application/ld+json" is:inline set:html={serialiseJsonLd(jsonLd)}></script>
|
|
55
|
+
)}
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<main>
|
|
59
|
+
<slot />
|
|
60
|
+
</main>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
main {
|
|
64
|
+
max-width: var(--rx-max-wide);
|
|
65
|
+
margin: 0 auto;
|
|
66
|
+
padding: clamp(2rem, 6vh, 3.5rem) 1.5rem 4rem;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
69
|
+
|
|
70
|
+
<!-- Prose styling for rendered markdown. Global because <Content /> markup carries no Astro
|
|
71
|
+
scoping attributes. -->
|
|
72
|
+
<style is:global>
|
|
73
|
+
.prose {
|
|
74
|
+
color: var(--rx-muted);
|
|
75
|
+
line-height: 1.7;
|
|
76
|
+
}
|
|
77
|
+
.prose > * + * {
|
|
78
|
+
margin-top: 1.1rem;
|
|
79
|
+
}
|
|
80
|
+
.prose h2 {
|
|
81
|
+
color: var(--rx-fg);
|
|
82
|
+
font-size: 1.25rem;
|
|
83
|
+
font-weight: 640;
|
|
84
|
+
letter-spacing: -0.01em;
|
|
85
|
+
margin-top: 2.5rem;
|
|
86
|
+
}
|
|
87
|
+
.prose h3 {
|
|
88
|
+
color: var(--rx-fg);
|
|
89
|
+
font-size: 1.05rem;
|
|
90
|
+
font-weight: 620;
|
|
91
|
+
margin-top: 2rem;
|
|
92
|
+
}
|
|
93
|
+
.prose a {
|
|
94
|
+
color: var(--rx-fg);
|
|
95
|
+
text-decoration: underline;
|
|
96
|
+
text-underline-offset: 2px;
|
|
97
|
+
}
|
|
98
|
+
.prose strong {
|
|
99
|
+
color: var(--rx-fg);
|
|
100
|
+
font-weight: 620;
|
|
101
|
+
}
|
|
102
|
+
.prose ul,
|
|
103
|
+
.prose ol {
|
|
104
|
+
margin-left: 1.2rem;
|
|
105
|
+
}
|
|
106
|
+
.prose li + li {
|
|
107
|
+
margin-top: 0.35rem;
|
|
108
|
+
}
|
|
109
|
+
.prose code {
|
|
110
|
+
font-family: var(--rx-font-mono);
|
|
111
|
+
font-size: 0.88em;
|
|
112
|
+
background: var(--rx-tag-bg);
|
|
113
|
+
padding: 0.05rem 0.35rem;
|
|
114
|
+
border-radius: 4px;
|
|
115
|
+
}
|
|
116
|
+
.prose pre {
|
|
117
|
+
background: var(--rx-card);
|
|
118
|
+
border: 1px solid var(--rx-rule);
|
|
119
|
+
border-radius: 10px;
|
|
120
|
+
padding: 1rem 1.1rem;
|
|
121
|
+
/* Long lines scroll inside the block; the page itself never does. */
|
|
122
|
+
overflow-x: auto;
|
|
123
|
+
}
|
|
124
|
+
.prose pre code {
|
|
125
|
+
background: none;
|
|
126
|
+
padding: 0;
|
|
127
|
+
}
|
|
128
|
+
.prose blockquote {
|
|
129
|
+
border-left: 2px solid var(--rx-rule);
|
|
130
|
+
padding-left: 1rem;
|
|
131
|
+
color: var(--rx-faint);
|
|
132
|
+
}
|
|
133
|
+
.prose img {
|
|
134
|
+
max-width: 100%;
|
|
135
|
+
height: auto;
|
|
136
|
+
border-radius: 10px;
|
|
137
|
+
}
|
|
138
|
+
.prose table {
|
|
139
|
+
width: 100%;
|
|
140
|
+
border-collapse: collapse;
|
|
141
|
+
font-size: 0.95rem;
|
|
142
|
+
}
|
|
143
|
+
.prose th,
|
|
144
|
+
.prose td {
|
|
145
|
+
border-bottom: 1px solid var(--rx-rule);
|
|
146
|
+
padding: 0.5rem 0.6rem;
|
|
147
|
+
text-align: left;
|
|
148
|
+
}
|
|
149
|
+
.prose th {
|
|
150
|
+
color: var(--rx-fg);
|
|
151
|
+
font-weight: 620;
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
154
|
+
</body>
|
|
155
|
+
</html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The pre-paint theme script for non-Starlight surfaces; goes in `<head>`. It reads Starlight's
|
|
4
|
+
* `starlight-theme` key so the choice carries across the origin; no saved choice => CSS default.
|
|
5
|
+
*/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<script is:inline>
|
|
9
|
+
;(function () {
|
|
10
|
+
function stored() {
|
|
11
|
+
try {
|
|
12
|
+
const t = localStorage.getItem('starlight-theme')
|
|
13
|
+
return t === 'light' || t === 'dark' ? t : null
|
|
14
|
+
} catch {
|
|
15
|
+
/* localStorage may be unavailable */
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function sync() {
|
|
20
|
+
const t = stored()
|
|
21
|
+
if (t) document.documentElement.setAttribute('data-theme', t)
|
|
22
|
+
else document.documentElement.removeAttribute('data-theme')
|
|
23
|
+
}
|
|
24
|
+
sync()
|
|
25
|
+
// Back from a docs site restores this page from the bfcache without rerunning the script;
|
|
26
|
+
// `pageshow` is the only notification, so re-sync the theme there.
|
|
27
|
+
window.addEventListener('pageshow', function (e) {
|
|
28
|
+
if (e.persisted) sync()
|
|
29
|
+
})
|
|
30
|
+
})()
|
|
31
|
+
</script>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The light/dark toggle for non-Starlight surfaces. Pairs with ThemeScript.astro in <head>, which
|
|
4
|
+
* avoids the flash; this only handles clicks and keeps the icon honest.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Pin the button to the viewport's top-right corner; `false` when it sits in a header. */
|
|
9
|
+
floating?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { floating = true } = Astro.props
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<button
|
|
16
|
+
id="theme-toggle"
|
|
17
|
+
class:list={['theme-toggle', { 'theme-toggle--floating': floating }]}
|
|
18
|
+
type="button"
|
|
19
|
+
aria-label="Toggle theme"
|
|
20
|
+
>
|
|
21
|
+
<span class="icon" aria-hidden="true">
|
|
22
|
+
<svg
|
|
23
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
24
|
+
viewBox="0 0 24 24"
|
|
25
|
+
width="24"
|
|
26
|
+
height="24"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
stroke-width="2"
|
|
30
|
+
stroke-linecap="round"
|
|
31
|
+
stroke-linejoin="round"
|
|
32
|
+
>
|
|
33
|
+
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
|
34
|
+
</svg>
|
|
35
|
+
</span>
|
|
36
|
+
</button>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
/* Quiet by default: no border, no fill, the same muted ink as the nav links beside it. */
|
|
40
|
+
.theme-toggle {
|
|
41
|
+
flex: none;
|
|
42
|
+
width: 2rem;
|
|
43
|
+
height: 2rem;
|
|
44
|
+
display: grid;
|
|
45
|
+
place-items: center;
|
|
46
|
+
border: 1px solid transparent;
|
|
47
|
+
border-radius: 8px;
|
|
48
|
+
background: none;
|
|
49
|
+
color: var(--rx-muted);
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
transition:
|
|
52
|
+
color 0.15s ease,
|
|
53
|
+
border-color 0.15s ease,
|
|
54
|
+
background-color 0.2s ease;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Floating over arbitrary content, it keeps the border and fill so it still reads as a button. */
|
|
58
|
+
.theme-toggle--floating {
|
|
59
|
+
position: fixed;
|
|
60
|
+
top: 1rem;
|
|
61
|
+
right: 1rem;
|
|
62
|
+
/* Above SiteShell's sticky header (z-index 10), so the fixed button stays reachable. */
|
|
63
|
+
z-index: 20;
|
|
64
|
+
width: 2.25rem;
|
|
65
|
+
height: 2.25rem;
|
|
66
|
+
border-color: var(--rx-rule);
|
|
67
|
+
background: var(--rx-card);
|
|
68
|
+
color: var(--rx-fg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.theme-toggle .icon {
|
|
72
|
+
display: grid;
|
|
73
|
+
place-items: center;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* `:global` because the script swaps the icon via innerHTML, which carries no Astro scoping
|
|
77
|
+
attributes. */
|
|
78
|
+
.theme-toggle :global(svg) {
|
|
79
|
+
width: 1.05rem;
|
|
80
|
+
height: 1.05rem;
|
|
81
|
+
display: block;
|
|
82
|
+
stroke-width: 1.6;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.theme-toggle:hover {
|
|
86
|
+
color: var(--rx-fg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.theme-toggle--floating:hover {
|
|
90
|
+
border-color: var(--rx-faint);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.theme-toggle:focus-visible {
|
|
94
|
+
outline: 2px solid var(--rx-fg);
|
|
95
|
+
outline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
98
|
+
|
|
99
|
+
<script is:inline>
|
|
100
|
+
;(function () {
|
|
101
|
+
const root = document.documentElement
|
|
102
|
+
const btn = document.getElementById('theme-toggle')
|
|
103
|
+
const glyph = btn.querySelector('.icon')
|
|
104
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
105
|
+
|
|
106
|
+
// Lucide icons (ISC licensed).
|
|
107
|
+
const ICON_MOON =
|
|
108
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>'
|
|
109
|
+
const ICON_SUN =
|
|
110
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>'
|
|
111
|
+
|
|
112
|
+
function current() {
|
|
113
|
+
return root.getAttribute('data-theme') || (media.matches ? 'dark' : 'light')
|
|
114
|
+
}
|
|
115
|
+
function stored() {
|
|
116
|
+
try {
|
|
117
|
+
const t = localStorage.getItem('starlight-theme')
|
|
118
|
+
return t === 'light' || t === 'dark' ? t : null
|
|
119
|
+
} catch {
|
|
120
|
+
/* localStorage may be unavailable */
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Re-read the saved choice and reflect it in the DOM. Same job as the
|
|
125
|
+
// pre-paint script in <head>, minus the write.
|
|
126
|
+
function sync() {
|
|
127
|
+
const t = stored()
|
|
128
|
+
if (t) root.setAttribute('data-theme', t)
|
|
129
|
+
else root.removeAttribute('data-theme')
|
|
130
|
+
render()
|
|
131
|
+
}
|
|
132
|
+
function render() {
|
|
133
|
+
const dark = current() === 'dark'
|
|
134
|
+
// Show the icon of the theme you'd switch TO.
|
|
135
|
+
glyph.innerHTML = dark ? ICON_SUN : ICON_MOON
|
|
136
|
+
btn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme')
|
|
137
|
+
}
|
|
138
|
+
function apply(t) {
|
|
139
|
+
root.setAttribute('data-theme', t)
|
|
140
|
+
try {
|
|
141
|
+
// Starlight's key — see the pre-paint script in <head>.
|
|
142
|
+
localStorage.setItem('starlight-theme', t)
|
|
143
|
+
} catch {
|
|
144
|
+
/* localStorage may be unavailable */
|
|
145
|
+
}
|
|
146
|
+
render()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
btn.addEventListener('click', function () {
|
|
150
|
+
apply(current() === 'dark' ? 'light' : 'dark')
|
|
151
|
+
})
|
|
152
|
+
// Track the OS preference while the user hasn't overridden it.
|
|
153
|
+
media.addEventListener('change', function () {
|
|
154
|
+
if (!root.getAttribute('data-theme')) render()
|
|
155
|
+
})
|
|
156
|
+
// Back from a docs site restores this page from the bfcache without rerunning any script;
|
|
157
|
+
// `pageshow` is the only notification, so re-sync the theme there.
|
|
158
|
+
window.addEventListener('pageshow', function (e) {
|
|
159
|
+
if (e.persisted) sync()
|
|
160
|
+
})
|
|
161
|
+
render()
|
|
162
|
+
})()
|
|
163
|
+
</script>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Text only screen readers get; unlike `display: none`, it stays in the accessibility tree. */
|
|
3
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
4
|
+
|
|
5
|
+
type Props = HTMLAttributes<'span'>
|
|
6
|
+
|
|
7
|
+
const { class: className, ...rest } = Astro.props
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<span class:list={['visually-hidden', className]} {...rest}>
|
|
11
|
+
<slot />
|
|
12
|
+
</span>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.visually-hidden {
|
|
16
|
+
position: absolute;
|
|
17
|
+
width: 1px;
|
|
18
|
+
height: 1px;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
clip-path: inset(50%);
|
|
21
|
+
white-space: nowrap;
|
|
22
|
+
}
|
|
23
|
+
</style>
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="astro/client" />
|
|
2
|
+
|
|
3
|
+
// Not referencing `@astrojs/starlight/locals`: it imports `virtual:starlight/*` modules that
|
|
4
|
+
// only exist in an Astro build, so plain `tsc` fails. Route data is narrowed structurally instead.
|
|
5
|
+
|
|
6
|
+
// Mirrors Starlight's `global.d.ts`: set by its `ThemeProvider.astro`, called by ThemeSelect.astro.
|
|
7
|
+
// Re-check against Starlight's `global.d.ts` on a major bump.
|
|
8
|
+
declare global {
|
|
9
|
+
interface StarlightThemeProvider {
|
|
10
|
+
updatePickers(theme?: string): void
|
|
11
|
+
}
|
|
12
|
+
var StarlightThemeProvider: StarlightThemeProvider
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export {}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Ordering, bylines, dates, facets and excerpts shared by /blog and /updates. Pure, so tests need no Astro. */
|
|
2
|
+
|
|
3
|
+
/** Newest first, ties broken by `id` so the order never depends on filesystem order. Sorts a copy. */
|
|
4
|
+
export function newestFirst<T extends { id: string }>(entries: readonly T[], date: (e: T) => Date) {
|
|
5
|
+
return [...entries].sort(
|
|
6
|
+
(a, b) => date(b).valueOf() - date(a).valueOf() || a.id.localeCompare(b.id),
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** "Rxova" · "Rxova and Ada" · "Rxova, Ada, and Grace" */
|
|
11
|
+
export function byline(names: readonly string[]): string {
|
|
12
|
+
if (names.length <= 1) return names[0] ?? ''
|
|
13
|
+
if (names.length === 2) return `${names[0]} and ${names[1]}`
|
|
14
|
+
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The distinct values some entry carries, so a filter row never offers a chip that matches nothing. */
|
|
18
|
+
export function usedValues<T>(entries: readonly T[], pick: (e: T) => readonly string[]): string[] {
|
|
19
|
+
return [...new Set(entries.flatMap((e) => pick(e)))]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DATE = new Intl.DateTimeFormat('en', {
|
|
23
|
+
year: 'numeric',
|
|
24
|
+
month: 'long',
|
|
25
|
+
day: 'numeric',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** "July 27, 2026". UTC, so the rendered date matches the filename it came from. */
|
|
30
|
+
export function formatDate(d: Date): string {
|
|
31
|
+
return DATE.format(d)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `2026-07-27`, for `<time datetime>`. */
|
|
35
|
+
export function isoDate(d: Date): string {
|
|
36
|
+
return d.toISOString().slice(0, 10)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The next "show more" limit: one `step` further, clamped to `total` so the control never outlives the list. */
|
|
40
|
+
export function nextLimit(current: number, step: number, total: number): number {
|
|
41
|
+
return Math.min(current + Math.max(step, 0), total)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The opening prose of a markdown body, up to `max` characters, cut on a word boundary.
|
|
46
|
+
* Skips headings, images, quotes, code, tables and lists; reports whether anything was cut.
|
|
47
|
+
*/
|
|
48
|
+
export function excerpt(body: string, max: number): { text: string; truncated: boolean } {
|
|
49
|
+
const prose = body
|
|
50
|
+
.split(/\n\s*\n/)
|
|
51
|
+
.map((p) => p.trim())
|
|
52
|
+
.filter((p) => p.length > 0 && !/^(#|!\[|>|```|\||-{3,}|\d+\.|[-*+]\s)/.test(p))
|
|
53
|
+
.join(' ')
|
|
54
|
+
|
|
55
|
+
const plain = prose
|
|
56
|
+
.replace(/!\[[^[\]]*\]\([^()]*\)/g, '')
|
|
57
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
58
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
59
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
60
|
+
.replace(/(\*|_)(.*?)\1/g, '$2')
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.trim()
|
|
63
|
+
|
|
64
|
+
if (plain.length <= max) return { text: plain, truncated: false }
|
|
65
|
+
|
|
66
|
+
const cut = plain.slice(0, max)
|
|
67
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
68
|
+
return {
|
|
69
|
+
text: (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.—–-]+$/, ''),
|
|
70
|
+
truncated: true,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { nextLimit } from '../lib/entries.ts'
|
|
2
|
+
|
|
3
|
+
/** Shows `list`'s children `data-reveal-step` at a time, driven by a ShowMore's `controls`. */
|
|
4
|
+
export function batchList(list: HTMLElement, controls: HTMLElement): void {
|
|
5
|
+
const items = Array.from(list.children) as HTMLElement[]
|
|
6
|
+
const moreBtn = controls.querySelector<HTMLElement>('[data-reveal-more]')
|
|
7
|
+
const allBtn = controls.querySelector<HTMLElement>('[data-reveal-all]')
|
|
8
|
+
const progress = controls.querySelector<HTMLElement>('[data-reveal-progress]')
|
|
9
|
+
|
|
10
|
+
const step = Number(list.dataset.revealStep) || items.length
|
|
11
|
+
let limit = step
|
|
12
|
+
|
|
13
|
+
function apply() {
|
|
14
|
+
items.forEach((el, i) => {
|
|
15
|
+
el.hidden = i >= limit
|
|
16
|
+
})
|
|
17
|
+
controls.hidden = limit >= items.length
|
|
18
|
+
if (progress) {
|
|
19
|
+
progress.textContent = limit < items.length ? `Showing ${limit} of ${items.length}` : ''
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Raise the limit, then focus the first item that appeared. */
|
|
24
|
+
function reveal(next: number) {
|
|
25
|
+
const first = items[limit]
|
|
26
|
+
limit = next
|
|
27
|
+
apply()
|
|
28
|
+
if (!first) return
|
|
29
|
+
// The new batch starts where the button was, so the viewport is already right.
|
|
30
|
+
first.tabIndex = -1
|
|
31
|
+
first.focus({ preventScroll: true })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
moreBtn?.addEventListener('click', () => reveal(nextLimit(limit, step, items.length)))
|
|
35
|
+
// Also the way out of find-in-page missing what is batched away.
|
|
36
|
+
allBtn?.addEventListener('click', () => reveal(items.length))
|
|
37
|
+
|
|
38
|
+
apply()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Batches the page's `[data-reveal-list]` behind its `[data-reveal-controls]`, when it has both. */
|
|
42
|
+
export function enhanceShowMore(root: ParentNode = document): void {
|
|
43
|
+
const list = root.querySelector<HTMLElement>('[data-reveal-list]')
|
|
44
|
+
const controls = root.querySelector<HTMLElement>('[data-reveal-controls]')
|
|
45
|
+
if (list && controls) batchList(list, controls)
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Starlight `Footer` override: keeps Starlight's pagination, edit link and last-updated, and adds
|
|
4
|
+
* the shared rxova site footer beneath them.
|
|
5
|
+
*/
|
|
6
|
+
import Default from '@astrojs/starlight/components/Footer.astro'
|
|
7
|
+
import SiteFooter from '../components/SiteFooter.astro'
|
|
8
|
+
import { projectFromBase } from '@rxova/brand'
|
|
9
|
+
|
|
10
|
+
const project = projectFromBase(import.meta.env.BASE_URL)
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<Default>
|
|
14
|
+
<slot />
|
|
15
|
+
</Default>
|
|
16
|
+
<SiteFooter project={project} />
|