@rxova/brand 0.3.0 → 0.5.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxova/brand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
|
|
6
6
|
"keywords": [
|
|
@@ -57,11 +57,13 @@
|
|
|
57
57
|
"@astrojs/starlight": "^0.41.4",
|
|
58
58
|
"@resvg/resvg-js": "^2.6.2",
|
|
59
59
|
"@types/node": "^26.1.1",
|
|
60
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
60
61
|
"astro": "^7.1.3",
|
|
61
62
|
"publint": "^0.3.21",
|
|
62
63
|
"satori": "^0.29.0",
|
|
63
64
|
"tsx": "^4.23.1",
|
|
64
|
-
"typescript": "6.0.3"
|
|
65
|
+
"typescript": "6.0.3",
|
|
66
|
+
"vitest": "^4.1.10"
|
|
65
67
|
},
|
|
66
68
|
"scripts": {
|
|
67
69
|
"typecheck": "tsc --noEmit",
|
|
@@ -69,6 +71,7 @@
|
|
|
69
71
|
"pack:smoke": "node --import tsx scripts/pack-smoke.ts",
|
|
70
72
|
"check:exports": "publint --strict",
|
|
71
73
|
"og": "node --import tsx scripts/generate-og.ts",
|
|
72
|
-
"check:og": "node --import tsx scripts/generate-og.ts --check"
|
|
74
|
+
"check:og": "node --import tsx scripts/generate-og.ts --check",
|
|
75
|
+
"test": "vitest run --coverage"
|
|
73
76
|
}
|
|
74
77
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The shell for rxova.org's non-Starlight surfaces — /blog and /updates.
|
|
4
|
+
*
|
|
5
|
+
* It lived in both packages as a 244-line file that differed on four lines, which
|
|
6
|
+
* is exactly how the menu came to render in a different order on each: a fix has to
|
|
7
|
+
* be made twice, and once was enough to forget.
|
|
8
|
+
*
|
|
9
|
+
* Not for the docs sites — those are Starlight and take their chrome from
|
|
10
|
+
* `sharedStarlightConfig`. This is for the surfaces that are plain Astro.
|
|
11
|
+
*/
|
|
12
|
+
import '../fonts.css'
|
|
13
|
+
import '../astro.css'
|
|
14
|
+
|
|
15
|
+
import { RXOVA_ORIGIN, siteUrl } from '../sites.ts'
|
|
16
|
+
|
|
17
|
+
import ThemeScript from './ThemeScript.astro'
|
|
18
|
+
import ThemeToggle from './ThemeToggle.astro'
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
title: string
|
|
22
|
+
description: string
|
|
23
|
+
/** Canonical path, e.g. `/blog/why-rxova-has-a-blog`. */
|
|
24
|
+
path: string
|
|
25
|
+
ogType?: 'website' | 'article'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { title, description, path, ogType = 'website' } = Astro.props
|
|
29
|
+
const canonical = `${RXOVA_ORIGIN}${path}`
|
|
30
|
+
const year = new Date().getFullYear()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The menu, in one fixed order for every surface.
|
|
34
|
+
*
|
|
35
|
+
* Which item is current is *derived* from the base the surface was built for, not
|
|
36
|
+
* declared per package — declaring it is what let the two drift into different
|
|
37
|
+
* orders. `BASE_URL` is substituted by the consuming app's build, so this is
|
|
38
|
+
* `/blog` inside @rxova/blog and `/updates` inside @rxova/updates.
|
|
39
|
+
*/
|
|
40
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '')
|
|
41
|
+
|
|
42
|
+
const SECTIONS = [
|
|
43
|
+
{ label: 'Blog', path: '/blog' },
|
|
44
|
+
{ label: 'Updates', path: '/updates' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A URL inside this surface.
|
|
49
|
+
*
|
|
50
|
+
* Anything *leaving* it goes through `siteUrl`: a bare `/updates` would resolve
|
|
51
|
+
* against the mount and point at `/blog/updates`.
|
|
52
|
+
*/
|
|
53
|
+
const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
<!doctype html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8" />
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
61
|
+
<link rel="icon" type="image/png" href={siteUrl('/rxova-logo-256.png')} />
|
|
62
|
+
<link rel="apple-touch-icon" href={siteUrl('/rxova-logo-256.png')} />
|
|
63
|
+
<link rel="canonical" href={canonical} />
|
|
64
|
+
<title>{title}</title>
|
|
65
|
+
<meta name="description" content={description} />
|
|
66
|
+
<meta property="og:title" content={title} />
|
|
67
|
+
<meta property="og:description" content={description} />
|
|
68
|
+
<meta property="og:type" content={ogType} />
|
|
69
|
+
<meta property="og:url" content={canonical} />
|
|
70
|
+
<meta property="og:image" content={siteUrl('/og/rxova.png')} />
|
|
71
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
72
|
+
<ThemeScript />
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<ThemeToggle />
|
|
76
|
+
<main>
|
|
77
|
+
<header class="site">
|
|
78
|
+
<a class="brand" href={siteUrl('/')}>
|
|
79
|
+
<img src={siteUrl('/rxova-logo-256.png')} alt="" width="28" height="28" />
|
|
80
|
+
<span>rxova</span>
|
|
81
|
+
</a>
|
|
82
|
+
{
|
|
83
|
+
/* Fixed order on every surface. It used to render the current item
|
|
84
|
+
second, so Blog and Updates swapped places as you moved between them —
|
|
85
|
+
the menu shifting under the cursor you just clicked with. */
|
|
86
|
+
}
|
|
87
|
+
<nav aria-label="Sections">
|
|
88
|
+
<a href={siteUrl('/')}>Projects</a>
|
|
89
|
+
{
|
|
90
|
+
SECTIONS.map((section) =>
|
|
91
|
+
section.path === base ? (
|
|
92
|
+
<a href={inside()} aria-current="page">
|
|
93
|
+
{section.label}
|
|
94
|
+
</a>
|
|
95
|
+
) : (
|
|
96
|
+
<a href={siteUrl(section.path)}>{section.label}</a>
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
</nav>
|
|
101
|
+
</header>
|
|
102
|
+
|
|
103
|
+
<slot />
|
|
104
|
+
|
|
105
|
+
<footer class="foot">
|
|
106
|
+
<span>© {year} rxova</span>
|
|
107
|
+
<span class="dot" aria-hidden="true">·</span>
|
|
108
|
+
<a href={siteUrl('/')}>Projects</a>
|
|
109
|
+
<span class="dot" aria-hidden="true">·</span>
|
|
110
|
+
<a href={siteUrl('/blog')}>Blog</a>
|
|
111
|
+
<span class="dot" aria-hidden="true">·</span>
|
|
112
|
+
<a href={siteUrl('/updates')}>Updates</a>
|
|
113
|
+
<span class="dot" aria-hidden="true">·</span>
|
|
114
|
+
<a href={siteUrl('/privacy')}>Privacy</a>
|
|
115
|
+
<span class="dot" aria-hidden="true">·</span>
|
|
116
|
+
<a href={siteUrl('/terms')}>Terms</a>
|
|
117
|
+
</footer>
|
|
118
|
+
</main>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.site {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
justify-content: space-between;
|
|
125
|
+
gap: 1rem;
|
|
126
|
+
flex-wrap: wrap;
|
|
127
|
+
padding-bottom: 1.25rem;
|
|
128
|
+
margin-bottom: clamp(2rem, 6vh, 3rem);
|
|
129
|
+
border-bottom: 1px solid var(--rule);
|
|
130
|
+
}
|
|
131
|
+
.brand {
|
|
132
|
+
display: inline-flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 0.55rem;
|
|
135
|
+
color: var(--fg);
|
|
136
|
+
text-decoration: none;
|
|
137
|
+
font-weight: 640;
|
|
138
|
+
letter-spacing: -0.01em;
|
|
139
|
+
}
|
|
140
|
+
.brand img {
|
|
141
|
+
display: block;
|
|
142
|
+
border-radius: 6px;
|
|
143
|
+
}
|
|
144
|
+
.site nav {
|
|
145
|
+
display: flex;
|
|
146
|
+
gap: 1.4rem;
|
|
147
|
+
}
|
|
148
|
+
.site nav a {
|
|
149
|
+
color: var(--muted);
|
|
150
|
+
text-decoration: none;
|
|
151
|
+
font-size: 0.95rem;
|
|
152
|
+
}
|
|
153
|
+
.site nav a:hover {
|
|
154
|
+
color: var(--fg);
|
|
155
|
+
}
|
|
156
|
+
.site nav a[aria-current='page'] {
|
|
157
|
+
color: var(--fg);
|
|
158
|
+
text-decoration: underline;
|
|
159
|
+
text-underline-offset: 5px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main {
|
|
163
|
+
max-width: var(--max);
|
|
164
|
+
margin: 0 auto;
|
|
165
|
+
padding: clamp(2rem, 6vh, 3.5rem) 1.5rem 4rem;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.foot {
|
|
169
|
+
margin-top: clamp(3rem, 8vh, 5rem);
|
|
170
|
+
padding-top: 1.75rem;
|
|
171
|
+
border-top: 1px solid var(--rule);
|
|
172
|
+
color: var(--faint);
|
|
173
|
+
font-size: 0.9rem;
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 0.6rem;
|
|
177
|
+
flex-wrap: wrap;
|
|
178
|
+
}
|
|
179
|
+
.foot a {
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
text-decoration: none;
|
|
182
|
+
}
|
|
183
|
+
.foot a:hover {
|
|
184
|
+
color: var(--fg);
|
|
185
|
+
}
|
|
186
|
+
.dot {
|
|
187
|
+
color: var(--rule);
|
|
188
|
+
}
|
|
189
|
+
</style>
|
|
190
|
+
|
|
191
|
+
<!-- Prose styling for rendered markdown. Global rather than scoped: the
|
|
192
|
+
markup comes from <Content /> at build time, so it carries none of
|
|
193
|
+
Astro's scoping attributes. Kept here so both /blog and /updates read
|
|
194
|
+
the same. -->
|
|
195
|
+
<style is:global>
|
|
196
|
+
.prose {
|
|
197
|
+
color: var(--muted);
|
|
198
|
+
line-height: 1.7;
|
|
199
|
+
}
|
|
200
|
+
.prose > * + * {
|
|
201
|
+
margin-top: 1.1rem;
|
|
202
|
+
}
|
|
203
|
+
.prose h2 {
|
|
204
|
+
color: var(--fg);
|
|
205
|
+
font-size: 1.25rem;
|
|
206
|
+
font-weight: 640;
|
|
207
|
+
letter-spacing: -0.01em;
|
|
208
|
+
margin-top: 2.5rem;
|
|
209
|
+
}
|
|
210
|
+
.prose h3 {
|
|
211
|
+
color: var(--fg);
|
|
212
|
+
font-size: 1.05rem;
|
|
213
|
+
font-weight: 620;
|
|
214
|
+
margin-top: 2rem;
|
|
215
|
+
}
|
|
216
|
+
.prose a {
|
|
217
|
+
color: var(--fg);
|
|
218
|
+
text-decoration: underline;
|
|
219
|
+
text-underline-offset: 2px;
|
|
220
|
+
}
|
|
221
|
+
.prose strong {
|
|
222
|
+
color: var(--fg);
|
|
223
|
+
font-weight: 620;
|
|
224
|
+
}
|
|
225
|
+
.prose ul,
|
|
226
|
+
.prose ol {
|
|
227
|
+
margin-left: 1.2rem;
|
|
228
|
+
}
|
|
229
|
+
.prose li + li {
|
|
230
|
+
margin-top: 0.35rem;
|
|
231
|
+
}
|
|
232
|
+
.prose code {
|
|
233
|
+
font-family: var(--mono);
|
|
234
|
+
font-size: 0.88em;
|
|
235
|
+
background: var(--tag-bg);
|
|
236
|
+
padding: 0.05rem 0.35rem;
|
|
237
|
+
border-radius: 4px;
|
|
238
|
+
}
|
|
239
|
+
.prose pre {
|
|
240
|
+
background: var(--card);
|
|
241
|
+
border: 1px solid var(--rule);
|
|
242
|
+
border-radius: 10px;
|
|
243
|
+
padding: 1rem 1.1rem;
|
|
244
|
+
/* Long lines scroll inside the block; the page itself never does. */
|
|
245
|
+
overflow-x: auto;
|
|
246
|
+
}
|
|
247
|
+
.prose pre code {
|
|
248
|
+
background: none;
|
|
249
|
+
padding: 0;
|
|
250
|
+
}
|
|
251
|
+
.prose blockquote {
|
|
252
|
+
border-left: 2px solid var(--rule);
|
|
253
|
+
padding-left: 1rem;
|
|
254
|
+
color: var(--faint);
|
|
255
|
+
}
|
|
256
|
+
.prose img {
|
|
257
|
+
max-width: 100%;
|
|
258
|
+
height: auto;
|
|
259
|
+
border-radius: 10px;
|
|
260
|
+
}
|
|
261
|
+
.prose table {
|
|
262
|
+
width: 100%;
|
|
263
|
+
border-collapse: collapse;
|
|
264
|
+
font-size: 0.95rem;
|
|
265
|
+
}
|
|
266
|
+
.prose th,
|
|
267
|
+
.prose td {
|
|
268
|
+
border-bottom: 1px solid var(--rule);
|
|
269
|
+
padding: 0.5rem 0.6rem;
|
|
270
|
+
text-align: left;
|
|
271
|
+
}
|
|
272
|
+
.prose th {
|
|
273
|
+
color: var(--fg);
|
|
274
|
+
font-weight: 620;
|
|
275
|
+
}
|
|
276
|
+
</style>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The pre-paint theme script. Goes in `<head>`, before anything renders.
|
|
4
|
+
*
|
|
5
|
+
* This was copy-pasted between the landing page and the legal layout, and it had
|
|
6
|
+
* already drifted: the copy in Legal.astro read `theme`, a key nothing on this
|
|
7
|
+
* origin ever writes, so /privacy and /terms silently ignored the visitor's choice
|
|
8
|
+
* and always rendered the prefers-color-scheme default. Adding /blog and
|
|
9
|
+
* /changelog would have made four copies of a script whose whole job is to be
|
|
10
|
+
* identical everywhere, so it lives here now.
|
|
11
|
+
*
|
|
12
|
+
* The key is Starlight's, not ours. The docs sites at /packages/* are Starlight
|
|
13
|
+
* and write their choice to `starlight-theme`; same origin plus the same key means
|
|
14
|
+
* picking dark here keeps you in dark all the way into a project's docs, and back.
|
|
15
|
+
*
|
|
16
|
+
* No saved choice => no attribute => the CSS prefers-color-scheme default wins.
|
|
17
|
+
*
|
|
18
|
+
* Lives here because it is the one thing every rxova surface must do identically:
|
|
19
|
+
* the docs sites, the umbrella landing and /blog and /updates all read the same
|
|
20
|
+
* localStorage key, so a visitor keeps their choice walking between them. It was
|
|
21
|
+
* copied into three repos and had already drifted once — one copy read `theme`,
|
|
22
|
+
* which nothing on this origin writes, so those pages silently ignored the choice.
|
|
23
|
+
*
|
|
24
|
+
* Not the same thing as ThemeSelect.astro: that is the Starlight override for the
|
|
25
|
+
* docs sites' own toggle. This pair is for the surfaces that are not Starlight.
|
|
26
|
+
*/
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<script is:inline>
|
|
30
|
+
;(function () {
|
|
31
|
+
function stored() {
|
|
32
|
+
try {
|
|
33
|
+
const t = localStorage.getItem('starlight-theme')
|
|
34
|
+
return t === 'light' || t === 'dark' ? t : null
|
|
35
|
+
} catch {
|
|
36
|
+
/* localStorage may be unavailable */
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function sync() {
|
|
41
|
+
const t = stored()
|
|
42
|
+
if (t) document.documentElement.setAttribute('data-theme', t)
|
|
43
|
+
else document.documentElement.removeAttribute('data-theme')
|
|
44
|
+
}
|
|
45
|
+
sync()
|
|
46
|
+
// Going to a docs site and pressing Back restores this document from the
|
|
47
|
+
// bfcache: the DOM is reused as it was and this script does not run again, so
|
|
48
|
+
// the page would keep the theme it had when you left — even though the docs
|
|
49
|
+
// site may have written a new one to `starlight-theme` in the meantime.
|
|
50
|
+
// `pageshow` is the only notification of that restore.
|
|
51
|
+
window.addEventListener('pageshow', function (e) {
|
|
52
|
+
if (e.persisted) sync()
|
|
53
|
+
})
|
|
54
|
+
})()
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The fixed light/dark toggle in the top-right corner.
|
|
4
|
+
*
|
|
5
|
+
* Button, styles and behaviour in one place. It was inline on the landing page;
|
|
6
|
+
* /blog and /changelog want the same control, and a second copy of the icon
|
|
7
|
+
* swapping and the bfcache handling is exactly the duplication that let the
|
|
8
|
+
* pre-paint script drift (see ThemeScript.astro).
|
|
9
|
+
*
|
|
10
|
+
* Pairs with `<ThemeScript />` in `<head>`, which is what actually avoids the
|
|
11
|
+
* flash — this only handles clicks and keeps the icon honest.
|
|
12
|
+
*
|
|
13
|
+
* For the surfaces that are not Starlight — the umbrella landing, /blog, /updates.
|
|
14
|
+
* The docs sites get their toggle from Starlight, themed by ThemeSelect.astro.
|
|
15
|
+
*
|
|
16
|
+
* Pairs with ThemeScript.astro in <head>, which is what actually avoids the flash;
|
|
17
|
+
* this only handles clicks and keeps the icon honest.
|
|
18
|
+
*/
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Toggle theme">
|
|
22
|
+
<span class="icon" aria-hidden="true"
|
|
23
|
+
><svg
|
|
24
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
width="24"
|
|
27
|
+
height="24"
|
|
28
|
+
fill="none"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
stroke-width="2"
|
|
31
|
+
stroke-linecap="round"
|
|
32
|
+
stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path></svg
|
|
33
|
+
></span
|
|
34
|
+
>
|
|
35
|
+
</button>
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
.theme-toggle {
|
|
39
|
+
position: fixed;
|
|
40
|
+
top: 1rem;
|
|
41
|
+
right: 1rem;
|
|
42
|
+
width: 2.25rem;
|
|
43
|
+
height: 2.25rem;
|
|
44
|
+
display: grid;
|
|
45
|
+
place-items: center;
|
|
46
|
+
border: 1px solid var(--rule);
|
|
47
|
+
border-radius: 8px;
|
|
48
|
+
background: var(--card);
|
|
49
|
+
color: var(--fg);
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
z-index: 3;
|
|
52
|
+
transition:
|
|
53
|
+
border-color 0.15s ease,
|
|
54
|
+
background-color 0.2s ease;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.theme-toggle .icon {
|
|
58
|
+
display: grid;
|
|
59
|
+
place-items: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.theme-toggle svg {
|
|
63
|
+
width: 1.15rem;
|
|
64
|
+
height: 1.15rem;
|
|
65
|
+
display: block;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.theme-toggle:hover {
|
|
69
|
+
border-color: var(--faint);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.theme-toggle:focus-visible {
|
|
73
|
+
outline: 2px solid var(--accent);
|
|
74
|
+
outline-offset: 2px;
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
77
|
+
|
|
78
|
+
<script is:inline>
|
|
79
|
+
;(function () {
|
|
80
|
+
const root = document.documentElement
|
|
81
|
+
const btn = document.getElementById('theme-toggle')
|
|
82
|
+
const glyph = btn.querySelector('.icon')
|
|
83
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
84
|
+
|
|
85
|
+
// Lucide icons (ISC licensed).
|
|
86
|
+
const ICON_MOON =
|
|
87
|
+
'<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>'
|
|
88
|
+
const ICON_SUN =
|
|
89
|
+
'<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>'
|
|
90
|
+
|
|
91
|
+
function current() {
|
|
92
|
+
return root.getAttribute('data-theme') || (media.matches ? 'dark' : 'light')
|
|
93
|
+
}
|
|
94
|
+
function stored() {
|
|
95
|
+
try {
|
|
96
|
+
const t = localStorage.getItem('starlight-theme')
|
|
97
|
+
return t === 'light' || t === 'dark' ? t : null
|
|
98
|
+
} catch {
|
|
99
|
+
/* localStorage may be unavailable */
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Re-read the saved choice and reflect it in the DOM. Same job as the
|
|
104
|
+
// pre-paint script in <head>, minus the write.
|
|
105
|
+
function sync() {
|
|
106
|
+
const t = stored()
|
|
107
|
+
if (t) root.setAttribute('data-theme', t)
|
|
108
|
+
else root.removeAttribute('data-theme')
|
|
109
|
+
render()
|
|
110
|
+
}
|
|
111
|
+
function render() {
|
|
112
|
+
const dark = current() === 'dark'
|
|
113
|
+
// Show the icon of the theme you'd switch TO.
|
|
114
|
+
glyph.innerHTML = dark ? ICON_SUN : ICON_MOON
|
|
115
|
+
btn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme')
|
|
116
|
+
}
|
|
117
|
+
function apply(t) {
|
|
118
|
+
root.setAttribute('data-theme', t)
|
|
119
|
+
try {
|
|
120
|
+
// Starlight's key — see the pre-paint script in <head>.
|
|
121
|
+
localStorage.setItem('starlight-theme', t)
|
|
122
|
+
} catch {
|
|
123
|
+
/* localStorage may be unavailable */
|
|
124
|
+
}
|
|
125
|
+
render()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
btn.addEventListener('click', function () {
|
|
129
|
+
apply(current() === 'dark' ? 'light' : 'dark')
|
|
130
|
+
})
|
|
131
|
+
// Track the OS preference while the user hasn't overridden it.
|
|
132
|
+
media.addEventListener('change', function () {
|
|
133
|
+
if (!root.getAttribute('data-theme')) render()
|
|
134
|
+
})
|
|
135
|
+
// Going to a docs site and pressing Back restores this document from the
|
|
136
|
+
// bfcache: the DOM is reused as it was, so neither the pre-paint script nor
|
|
137
|
+
// this one runs again, and the page keeps the theme it had when you left —
|
|
138
|
+
// even though the docs site wrote a new one to `starlight-theme` in the
|
|
139
|
+
// meantime. `pageshow` is the only notification of that restore.
|
|
140
|
+
window.addEventListener('pageshow', function (e) {
|
|
141
|
+
if (e.persisted) sync()
|
|
142
|
+
})
|
|
143
|
+
render()
|
|
144
|
+
})()
|
|
145
|
+
</script>
|
package/src/sites.ts
CHANGED
|
@@ -102,7 +102,7 @@ export const REPOS = [
|
|
|
102
102
|
|
|
103
103
|
export type RepoId = (typeof REPOS)[number]['id']
|
|
104
104
|
|
|
105
|
-
/** The ids only, for schema validation — see packages/
|
|
105
|
+
/** The ids only, for schema validation — see packages/website-schemas. */
|
|
106
106
|
export const REPO_IDS: readonly RepoId[] = REPOS.map((r) => r.id)
|
|
107
107
|
|
|
108
108
|
export function getRepo(id: RepoId): (typeof REPOS)[number] {
|